From ede41868962ffed386b0da694d14cdfe6cfdb34f Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Mon, 20 Oct 2025 22:15:48 -0700 Subject: Cleanup and a bunch of work to fix issues syncing, loading, and general bookmark states in TrackerView. Added search field (substring conjunctive filter on server/bookmark names and descriptions). --- .../xcshareddata/swiftpm/Package.resolved | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) (limited to 'Hotline.xcodeproj/project.xcworkspace/xcshareddata') diff --git a/Hotline.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Hotline.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 13c2046..28a7003 100644 --- a/Hotline.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Hotline.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/gonzalezreal/NetworkImage", "state" : { - "revision" : "7aff8d1b31148d32c5933d75557d42f6323ee3d1", - "version" : "6.0.0" + "revision" : "2849f5323265386e200484b0d0f896e73c3411b9", + "version" : "6.0.1" } }, { @@ -19,13 +19,22 @@ "version" : "3.5.3" } }, + { + "identity" : "swift-cmark", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-cmark", + "state" : { + "revision" : "b97d09472e847a416629f026eceae0e2afcfad65", + "version" : "0.7.0" + } + }, { "identity" : "swift-markdown-ui", "kind" : "remoteSourceControl", "location" : "https://github.com/gonzalezreal/swift-markdown-ui", "state" : { - "revision" : "9a8119b37e09a770367eeb26e05267c75d854053", - "version" : "2.3.1" + "revision" : "5f613358148239d0292c0cef674a3c2314737f9e", + "version" : "2.4.1" } } ], -- cgit From 8bab12046b2c91cff7e1194e576acecf57551e08 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Fri, 24 Oct 2025 14:16:30 -0700 Subject: Further along work towards auto updating. Show release notes for the builds that are more recent than yours. --- Hotline.xcodeproj/project.pbxproj | 35 ++- .../xcshareddata/swiftpm/Package.resolved | 11 +- Hotline/MacApp.swift | 56 +++- Hotline/State/AppUpdate.swift | 341 +++++++++++++++++++++ Hotline/Utility/NSWindowBridge.swift | 21 ++ Hotline/macOS/AboutView.swift | 339 +++++++++----------- Hotline/macOS/AppUpdateView.swift | 187 +++++++++++ 7 files changed, 788 insertions(+), 202 deletions(-) create mode 100644 Hotline/State/AppUpdate.swift create mode 100644 Hotline/macOS/AppUpdateView.swift (limited to 'Hotline.xcodeproj/project.xcworkspace/xcshareddata') diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index d35440f..642a329 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -36,7 +36,7 @@ DA6300972B24036B0034CBFD /* HotlineClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6300962B24036B0034CBFD /* HotlineClient.swift */; }; DA65499A2BEC280E00EDB697 /* ServerMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6549992BEC280E00EDB697 /* ServerMessageView.swift */; }; DA65499C2BEC3FBD00EDB697 /* ServerAgreementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA65499B2BEC3FBD00EDB697 /* ServerAgreementView.swift */; }; - DA65499E2BEC438A00EDB697 /* NSWindowBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA65499D2BEC438A00EDB697 /* NSWindowBridge.swift */; }; + DA65499E2BEC438A00EDB697 /* NSWindowBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA65499D2BEC438A00EDB697 /* NSWindowBridge.swift */; platformFilters = (macos, ); }; DA6980792BF80525003E434B /* TextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6980782BF80525003E434B /* TextView.swift */; }; DA69807E2BFD449B003E434B /* MessageBoardEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA69807D2BFD449B003E434B /* MessageBoardEditorView.swift */; }; DA6980832BFFD06C003E434B /* BookmarkDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6980822BFFD06C003E434B /* BookmarkDocument.swift */; }; @@ -75,6 +75,9 @@ DAC002192B21630900A6C290 /* SwiftUIExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC002182B21630900A6C290 /* SwiftUIExtensions.swift */; }; DAC3D9832BC33FD000A727C9 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC3D9822BC33FD000A727C9 /* AppState.swift */; }; DAC87F072C5010E80060FADF /* HotlineExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC87F062C5010E80060FADF /* HotlineExtensions.swift */; }; + DACCE5E12EABE4B4008CDD92 /* AppUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DACCE5E02EABE4B4008CDD92 /* AppUpdate.swift */; platformFilters = (macos, ); }; + DACCE5E32EABE86A008CDD92 /* AppUpdateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DACCE5E22EABE86A008CDD92 /* AppUpdateView.swift */; platformFilters = (macos, ); }; + DACCE5EF2EAC19C9008CDD92 /* SwiftUIIntrospect in Frameworks */ = {isa = PBXBuildFile; productRef = DACCE5EE2EAC19C9008CDD92 /* SwiftUIIntrospect */; }; DADDB28B2B22B31F0024040D /* Tracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = DADDB28A2B22B31F0024040D /* Tracker.swift */; }; DADDB28D2B22B5920024040D /* Server.swift in Sources */ = {isa = PBXBuildFile; fileRef = DADDB28C2B22B5920024040D /* Server.swift */; }; DADDB28F2B238D850024040D /* Hotline.swift in Sources */ = {isa = PBXBuildFile; fileRef = DADDB28E2B238D850024040D /* Hotline.swift */; }; @@ -159,6 +162,8 @@ DAC002182B21630900A6C290 /* SwiftUIExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUIExtensions.swift; sourceTree = ""; }; DAC3D9822BC33FD000A727C9 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; DAC87F062C5010E80060FADF /* HotlineExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineExtensions.swift; sourceTree = ""; }; + DACCE5E02EABE4B4008CDD92 /* AppUpdate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdate.swift; sourceTree = ""; }; + DACCE5E22EABE86A008CDD92 /* AppUpdateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdateView.swift; sourceTree = ""; }; DADDB28A2B22B31F0024040D /* Tracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tracker.swift; sourceTree = ""; }; DADDB28C2B22B5920024040D /* Server.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Server.swift; sourceTree = ""; }; DADDB28E2B238D850024040D /* Hotline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Hotline.swift; sourceTree = ""; }; @@ -178,6 +183,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + DACCE5EF2EAC19C9008CDD92 /* SwiftUIIntrospect in Frameworks */, DA72A0E02B4DA8CA00A0F48A /* SplitView in Frameworks */, DAB4D8872B4CB3610048A05C /* MarkdownUI in Frameworks */, ); @@ -191,6 +197,7 @@ children = ( DAC3D9822BC33FD000A727C9 /* AppState.swift */, DA2863D92B37BF6E00A7D050 /* Preferences.swift */, + DACCE5E02EABE4B4008CDD92 /* AppUpdate.swift */, ); path = State; sourceTree = ""; @@ -362,6 +369,7 @@ DA2863D72B37AD1C00A7D050 /* SettingsView.swift */, DAE136B92B9D1147007D8307 /* HotlinePanelView.swift */, DA55AC762BE589F700034857 /* AboutView.swift */, + DACCE5E22EABE86A008CDD92 /* AppUpdateView.swift */, DA65499B2BEC3FBD00EDB697 /* ServerAgreementView.swift */, DA6549992BEC280E00EDB697 /* ServerMessageView.swift */, 11F8288A2BF9428100216BA0 /* AccountManagerView.swift */, @@ -388,6 +396,7 @@ packageProductDependencies = ( DAB4D8862B4CB3610048A05C /* MarkdownUI */, DA72A0DF2B4DA8CA00A0F48A /* SplitView */, + DACCE5EE2EAC19C9008CDD92 /* SwiftUIIntrospect */, ); productName = Hotline; productReference = DA9CAFB72B126D5700CDA197 /* Hotline.app */; @@ -420,6 +429,7 @@ packageReferences = ( DAB4D8852B4CB3610048A05C /* XCRemoteSwiftPackageReference "swift-markdown-ui" */, DA72A0DE2B4DA8CA00A0F48A /* XCRemoteSwiftPackageReference "SplitView" */, + DACCE5ED2EAC19C9008CDD92 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */, ); productRefGroup = DA9CAFB82B126D5700CDA197 /* Products */; projectDirPath = ""; @@ -500,6 +510,7 @@ DA72A0DD2B4CD0BF00A0F48A /* NewsEditorView.swift in Sources */, DABFCC292B1530DC009F40D2 /* FoundationExtensions.swift in Sources */, DA57536C2B36BA1D00FAC277 /* TextDocument.swift in Sources */, + DACCE5E12EABE4B4008CDD92 /* AppUpdate.swift in Sources */, DA77253F2B21176D006C5ABB /* NewsView.swift in Sources */, DA99218E2C51AA5D0058FA6C /* HotlineDataBuilder.swift in Sources */, DA872B132BDDBF78008B1012 /* HotlinePanel.swift in Sources */, @@ -522,6 +533,7 @@ DA6980832BFFD06C003E434B /* BookmarkDocument.swift in Sources */, DAB4D87E2B4C8BCA0048A05C /* FilePreviewTextView.swift in Sources */, DA4F2C012B1A558E00D8ADDC /* ChatView.swift in Sources */, + DACCE5E32EABE86A008CDD92 /* AppUpdateView.swift in Sources */, DA32CD492B2931640053B98B /* User.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -656,7 +668,7 @@ CODE_SIGN_ENTITLEMENTS = Hotline/Hotline.entitlements; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 23; + CURRENT_PROJECT_VERSION = 24; DEVELOPMENT_ASSET_PATHS = "\"Hotline/Preview Content\""; DEVELOPMENT_TEAM = 5AEEV7QB2U; "ENABLE_HARDENED_RUNTIME[sdk=macosx*]" = YES; @@ -675,7 +687,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 14.3; + MACOSX_DEPLOYMENT_TARGET = 15.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = co.goodmake.hotline; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -696,7 +708,7 @@ CODE_SIGN_ENTITLEMENTS = Hotline/Hotline.entitlements; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 23; + CURRENT_PROJECT_VERSION = 24; DEVELOPMENT_ASSET_PATHS = "\"Hotline/Preview Content\""; DEVELOPMENT_TEAM = 5AEEV7QB2U; "ENABLE_HARDENED_RUNTIME[sdk=macosx*]" = YES; @@ -715,7 +727,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 14.3; + MACOSX_DEPLOYMENT_TARGET = 15.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = co.goodmake.hotline; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -768,6 +780,14 @@ minimumVersion = 2.3.0; }; }; + DACCE5ED2EAC19C9008CDD92 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/siteline/SwiftUI-Introspect"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 26.0.0; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -781,6 +801,11 @@ package = DAB4D8852B4CB3610048A05C /* XCRemoteSwiftPackageReference "swift-markdown-ui" */; productName = MarkdownUI; }; + DACCE5EE2EAC19C9008CDD92 /* SwiftUIIntrospect */ = { + isa = XCSwiftPackageProductDependency; + package = DACCE5ED2EAC19C9008CDD92 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */; + productName = SwiftUIIntrospect; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = DA9CAFAF2B126D5700CDA197 /* Project object */; diff --git a/Hotline.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Hotline.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 28a7003..f112b32 100644 --- a/Hotline.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Hotline.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "e60904df125ffbce75adea70452a77ae65a068371a6b6c4cfe0e6c155c4bdcee", + "originHash" : "26db0d070cfc8e002044ea1380ec5e675c0718ebf212f6e8a57f12cc6c295723", "pins" : [ { "identity" : "networkimage", @@ -36,6 +36,15 @@ "revision" : "5f613358148239d0292c0cef674a3c2314737f9e", "version" : "2.4.1" } + }, + { + "identity" : "swiftui-introspect", + "kind" : "remoteSourceControl", + "location" : "https://github.com/siteline/SwiftUI-Introspect", + "state" : { + "revision" : "a08b87f96b41055577721a6e397562b21ad52454", + "version" : "26.0.0" + } } ], "version" : 3 diff --git a/Hotline/MacApp.swift b/Hotline/MacApp.swift index a4ff3cb..a91df52 100644 --- a/Hotline/MacApp.swift +++ b/Hotline/MacApp.swift @@ -61,6 +61,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { // NotificationCenter.default.removeObserver(token) // } // } + + Task { + await AppUpdate.shared.checkForUpdatesOnLaunch() + } } func applicationWillTerminate(_ notification: Notification) { @@ -78,6 +82,7 @@ struct Application: App { @State private var hotlinePanel: HotlinePanel? = nil @State private var selection: TrackerSelection? = nil + @Bindable private var update = AppUpdate.shared @FocusedValue(\.activeHotlineModel) private var activeHotline: Hotline? @FocusedValue(\.activeServerState) private var activeServerState: ServerState? @@ -112,15 +117,43 @@ struct Application: App { } } } + .onChange(of: self.update.showWindow) { + if self.update.showWindow { + self.openWindow(id: "update") + } + } // MARK: About Box Window("About", id: "about") { AboutView() - .ignoresSafeArea() - .background(Color.hotlineRed) + .background(Color.hotlineRed, ignoresSafeAreaEdges: .all) + .windowFullScreenBehavior(.disabled) + .toolbar(removing: .title) + .gesture(WindowDragGesture()) + .background( + WindowConfigurator { window in + window.titlebarAppearsTransparent = true + window.titlebarSeparatorStyle = .none + window.isMovableByWindowBackground = true + + if let closeButton = window.standardWindowButton(.closeButton) { + closeButton.isHidden = false // make sure it’s visible + closeButton.isEnabled = true + } + + if let btn = window.standardWindowButton(.zoomButton) { + btn.isHidden = true + } + + if let btn = window.standardWindowButton(.miniaturizeButton) { + btn.isHidden = true + } + } + ) } .windowResizability(.contentSize) .windowStyle(.hiddenTitleBar) + .restorationBehavior(.disabled) .defaultPosition(.center) .commandsRemoved() // Remove About that was automatically added to Window menu. .commands { @@ -128,9 +161,26 @@ struct Application: App { Button("About Hotline") { openWindow(id: "about") } + + Button("Check for Updates...") { + Task { + await AppUpdate.shared.checkForUpdatesManually() + } + } } } + // MARK: Update Window + Window("New Update", id: "update") { + AppUpdateView() + .windowFullScreenBehavior(.disabled) + } + .windowResizability(.contentSize) + .windowStyle(.hiddenTitleBar) + .restorationBehavior(.disabled) + .defaultPosition(.center) + .commandsRemoved() + // MARK: Server Window WindowGroup(id: "server", for: Server.self) { server in ServerView(server: server) @@ -189,7 +239,7 @@ struct Application: App { } } Divider() - Button("Download Latest...") { + Button("Open Latest Release Page...") { if let url = URL(string: "https://github.com/mierau/hotline/releases/latest") { openURL(url) } diff --git a/Hotline/State/AppUpdate.swift b/Hotline/State/AppUpdate.swift new file mode 100644 index 0000000..601859e --- /dev/null +++ b/Hotline/State/AppUpdate.swift @@ -0,0 +1,341 @@ +import Foundation +import Observation +import AppKit + +struct UpdateReleaseInfo: Equatable { + let tagName: String + let displayVersion: String + let versionNumber: Double + let buildNumber: Int + let notes: String + let downloadURL: URL + let assetName: String +} + +struct AppUpdateMessage: Equatable { + enum Kind { + case info + case success + case error + } + + let title: String + let detail: String + let kind: Kind +} + +@Observable +final class AppUpdate { + static let shared = AppUpdate() + + private init() {} + + private enum CheckTrigger { + case automatic + case manual + } + + // MARK: - Public State + + var isChecking = false + var isDownloading = false + var showWindow = false + var release: UpdateReleaseInfo? + var releases: [UpdateReleaseInfo] = [] + var message: AppUpdateMessage? + var userInitiated = false + var releaseNotesCombined: String? + + // MARK: - Configuration + + private let releasesURL = URL(string: "https://api.github.com/repos/mierau/hotline/releases?per_page=100")! + private let remindInterval: TimeInterval = 60 * 60 * 24 * 14 + + private let defaults = UserDefaults.standard + private let remindDateKey = "update.remind.date" + private let lastPromptedVersionKey = "update.last.prompt.version" + + // MARK: - Public API + + func checkForUpdatesOnLaunch() async { + await checkForUpdates(trigger: .automatic) + } + + func checkForUpdatesManually() async { + await checkForUpdates(trigger: .manual) + } + + @MainActor + func startDownload() { + guard let release, isDownloading == false else { return } + + isDownloading = true + message = nil + + Task(priority: .userInitiated) { + await self.downloadRelease(release) + } + } + + @MainActor + func remindLater() { + guard let release else { + resetAndCloseWindow() + return + } + + recordPrompt(for: release, remindLater: true) + resetAndCloseWindow() + } + + @MainActor + func acknowledgeMessage() { + resetAndCloseWindow() + } + + @MainActor + func handleWindowDismissed() { + guard showWindow else { return } + + resetAndCloseWindow() + } + + // MARK: - Internal Logic + + private func checkForUpdates(trigger: CheckTrigger) async { + await MainActor.run { + self.isChecking = true + self.userInitiated = (trigger == .manual) + self.message = nil + self.releases = [] + self.release = nil + self.releaseNotesCombined = nil + self.isDownloading = false + if trigger == .manual { + self.showWindow = false + } + } + + do { + let newerReleases = try await fetchNewerReleases() + let latestRelease = newerReleases.first + let shouldShow: Bool + switch trigger { + case .manual: + shouldShow = latestRelease != nil + case .automatic: + if let latestRelease { + shouldShow = shouldPrompt(for: latestRelease) + } else { + shouldShow = false + } + } + + await MainActor.run { + self.isChecking = false + if shouldShow, let latestRelease { + self.release = latestRelease + self.releases = newerReleases + self.releaseNotesCombined = combinedReleaseNotes(from: newerReleases) + self.isDownloading = false + self.message = nil + self.showWindow = true + } else { + self.release = nil + self.releases = [] + self.releaseNotesCombined = nil + self.isDownloading = false + if trigger == .manual { + self.message = AppUpdateMessage( + title: "Hotline is up to date", + detail: "You're running the latest and greatest.", + kind: .success + ) + self.showWindow = true + } else { + self.message = nil + self.showWindow = false + } + } + } + } catch { + await MainActor.run { + self.isChecking = false + self.release = nil + self.releases = [] + self.releaseNotesCombined = nil + self.isDownloading = false + if trigger == .manual { + self.message = AppUpdateMessage( + title: "Unable to Check for Updates", + detail: error.localizedDescription, + kind: .error + ) + self.showWindow = true + } + } + } + } + + private func fetchNewerReleases() async throws -> [UpdateReleaseInfo] { + let (data, _) = try await URLSession.shared.data(from: releasesURL) + guard let jsonArray = try JSONSerialization.jsonObject(with: data, options: []) as? [[String: Any]] else { + throw NSError(domain: "AppUpdate", code: -10, userInfo: [NSLocalizedDescriptionKey: "Malformed GitHub releases response."]) + } + + let parsed = jsonArray.compactMap(parseRelease) + let newer = parsed.filter(isReleaseNewer) + return newer + } + + private func parseRelease(_ json: [String: Any]) -> UpdateReleaseInfo? { + guard let tagName = json["tag_name"] as? String else { + return nil + } + + let notes = (json["body"] as? String) ?? "" + guard + let assets = json["assets"] as? [[String: Any]], + let asset = assets.first, + let downloadString = asset["browser_download_url"] as? String, + let downloadURL = URL(string: downloadString) + else { + return nil + } + + let assetName = (asset["name"] as? String) ?? downloadURL.lastPathComponent + + let versionPattern = #"^([0-9\.]+)beta([0-9]+)"# + guard let regex = try? NSRegularExpression(pattern: versionPattern, options: []) else { + return nil + } + + let range = NSRange(location: 0, length: tagName.utf16.count) + guard let match = regex.firstMatch(in: tagName, options: [], range: range), + match.numberOfRanges >= 3, + let versionRange = Range(match.range(at: 1), in: tagName), + let buildRange = Range(match.range(at: 2), in: tagName), + let versionNumber = Double(String(tagName[versionRange])), + let buildNumber = Int(String(tagName[buildRange])) + else { + return nil + } + + let versionComponent = String(tagName[versionRange]) + let buildComponent = String(tagName[buildRange]) + let displayVersion = "\(versionComponent)b\(buildComponent)" + + return UpdateReleaseInfo( + tagName: tagName, + displayVersion: displayVersion, + versionNumber: versionNumber, + buildNumber: buildNumber, + notes: notes, + downloadURL: downloadURL, + assetName: assetName + ) + } + + private func combinedReleaseNotes(from releases: [UpdateReleaseInfo]) -> String? { + guard let firstRelease = releases.first else { return nil } + + let firstNotes = firstRelease.notes.trimmingCharacters(in: .whitespacesAndNewlines) + let firstBody = firstNotes.isEmpty ? "_No release notes provided._" : firstNotes + + guard releases.count > 1 else { + return firstBody + } + + let olderSections = releases.dropFirst().map { release -> String in + let trimmed = release.notes.trimmingCharacters(in: .whitespacesAndNewlines) + let body = trimmed.isEmpty ? "_No release notes provided._" : trimmed + return "## Hotline \(release.displayVersion)\n\n\(body)" + } + + let olderCombined = olderSections.joined(separator: "\n\n---\n\n") + return "\(firstBody)\n\n---\n\n\(olderCombined)" + } + + private func downloadRelease(_ release: UpdateReleaseInfo) async { + do { + let (temporaryURL, _) = try await URLSession.shared.download(from: release.downloadURL) + let downloadsDirectory = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first! + let destinationURL = downloadsDirectory.appendingPathComponent(release.assetName) + + if FileManager.default.fileExists(atPath: destinationURL.path) { + try? FileManager.default.removeItem(at: destinationURL) + } + + try FileManager.default.moveItem(at: temporaryURL, to: destinationURL) + + await MainActor.run { + self.isDownloading = false + NSWorkspace.shared.activateFileViewerSelecting([destinationURL]) + self.resetAndCloseWindow() + } + } catch { + await MainActor.run { + self.isDownloading = false + self.message = AppUpdateMessage( + title: "Download Failed", + detail: error.localizedDescription, + kind: .error + ) + self.showWindow = true + } + } + } + + private func currentApplicationVersion() -> (version: Double, build: Int) { + let info = Bundle.main.infoDictionary ?? [:] + let versionString = info["CFBundleShortVersionString"] as? String ?? "0" + let buildString = info["CFBundleVersion"] as? String ?? "0" + let version = Double(versionString) ?? 0 + let build = Int(buildString) ?? 0 + return (version, build) + } + + private func isReleaseNewer(_ release: UpdateReleaseInfo) -> Bool { + let current = currentApplicationVersion() + if release.versionNumber > current.version { + return true + } + if release.versionNumber == current.version { + return release.buildNumber > current.build + } + return false + } + + private func shouldPrompt(for release: UpdateReleaseInfo) -> Bool { + if defaults.string(forKey: lastPromptedVersionKey) != release.tagName { + return true + } + guard let remindDate = defaults.object(forKey: remindDateKey) as? Date else { + return true + } + return remindDate <= Date() + } + + @MainActor + private func recordPrompt(for release: UpdateReleaseInfo, remindLater: Bool) { + defaults.set(release.tagName, forKey: lastPromptedVersionKey) + if remindLater { + let nextReminder = Date().addingTimeInterval(remindInterval) + defaults.set(nextReminder, forKey: remindDateKey) + } else { + defaults.removeObject(forKey: remindDateKey) + } + } + + @MainActor + private func resetAndCloseWindow() { + isChecking = false + isDownloading = false + release = nil + releases = [] + releaseNotesCombined = nil + message = nil + userInitiated = false + showWindow = false + } +} diff --git a/Hotline/Utility/NSWindowBridge.swift b/Hotline/Utility/NSWindowBridge.swift index 5cd7197..8f766d9 100644 --- a/Hotline/Utility/NSWindowBridge.swift +++ b/Hotline/Utility/NSWindowBridge.swift @@ -23,3 +23,24 @@ public struct NSWindowAccessor: NSViewRepresentable { public func makeNSView(context: Context) -> NSView { return NSWindowAccessorView( configCode ) } public func updateNSView(_ nsView: NSView, context: Context) {} } + + +//import SwiftUI + + /// A helper view you can embed once per window to run a closure +/// with the underlying NSWindow reference. +struct WindowConfigurator: NSViewRepresentable { + let configure: (NSWindow) -> Void + + func makeNSView(context: Context) -> NSView { + let view = NSView() + DispatchQueue.main.async { + if let window = view.window { + configure(window) + } + } + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { } +} diff --git a/Hotline/macOS/AboutView.swift b/Hotline/macOS/AboutView.swift index baafcf8..57f855e 100644 --- a/Hotline/macOS/AboutView.swift +++ b/Hotline/macOS/AboutView.swift @@ -1,11 +1,5 @@ import SwiftUI - -enum VersionCheckState { - case needToCheck - case checking - case upToDate - case updateAvailable(version: String) -} +import SwiftUIIntrospect struct AboutContributor: Identifiable { let id: UUID = UUID() @@ -14,164 +8,66 @@ struct AboutContributor: Identifiable { let pictureURL: URL? } -struct AboutView: View { +struct AboutContributorView: View { @Environment(\.openURL) private var openURL - @State private var versionCheck: VersionCheckState = .needToCheck - @State private var downloadURL: String = "https://github.com/mierau/hotline/releases/latest" - @State private var contributors: [AboutContributor] = [] + let contributor: AboutContributor var body: some View { - HStack(alignment: .center, spacing: 0) { - VStack(alignment: .center, spacing: 0) { - Spacer() - - Image("About Hotline") - .padding(.top, 44) - - Text("Hotline") - .font(.system(size: 28)) - .fontWeight(.bold) - .padding(.top, 12) - .kerning(-1.0) - .foregroundColor(.white) - - let appDetails = getAppVersionAndBuild() - Text("Version \(String(format: "%.1f", appDetails.version))b\(appDetails.build)") - .foregroundColor(.white) - .opacity(0.4) - - HStack(alignment: .center) { - switch versionCheck { - case .needToCheck: - Button("Check for Updates") { - Task { - await checkForUpdate() - } - } - .controlSize(.small) - case .checking: - HStack(spacing: 8) { - ProgressView() - .controlSize(.small) - Text("Checking for updates...") - .fontWeight(.semibold) - } - .foregroundStyle(.white) - .tint(.white) - case .upToDate: - Label("Hotline is up to date.", systemImage: "checkmark.circle.fill") - .foregroundStyle(.white) - .fontWeight(.semibold) - .tint(.white) - .onTapGesture { - versionCheck = .needToCheck - } - case .updateAvailable(let version): - Button("Download Latest \(version)") { - if let url = URL(string: downloadURL) { - openURL(url) - } - } - .controlSize(.small) + HStack { + if let pictureURL = contributor.pictureURL { + AsyncImage(url: pictureURL) { phase in + if let image = phase.image { + image + .interpolation(.high) + .resizable() + .scaledToFit() + .background(.white) + .frame(width: 32, height: 32) + } else if phase.error != nil { + Color.clear + .frame(width: 32, height: 32) + } else { + Color.white + .opacity(0.2) + .frame(width: 32, height: 32) } } - .frame(height: 40) - - Spacer() + .frame(width: 32, height: 32) + .clipShape(Circle()) } - .frame(width: 250) - Spacer() - - ScrollView(.vertical) { - VStack(alignment: .leading, spacing: 16) { - - VStack(alignment: .leading, spacing: 4) { - Link(destination: URL(string: "https://github.com/mierau/hotline")!) { - HStack(alignment: .center, spacing: 4) { - Text("Contributors") - .lineLimit(1) - .font(.system(size: 16)) - .fontWeight(.semibold) - .foregroundStyle(.black) - .opacity(0.75) + VStack(alignment: .leading, spacing: 2) { + Text(contributor.username) + .fontWeight(.semibold) + .foregroundStyle(.white) + .lineLimit(1) + + Text(contributor.webURL.absoluteString) + .lineLimit(1) + .truncationMode(.middle) + .font(.system(size: 11)) + .foregroundStyle(.white.opacity(0.4)) + } + } +// } +// .accessibilityAddTraits(.isLink) +// .pointerStyle(.link) + } +} - Image(systemName: "arrow.forward.circle.fill") - .resizable() - .fontWeight(.bold) - .scaledToFit() - .frame(width: 12, height: 12) - .foregroundStyle(.black) - .opacity(0.75) - } - } - .padding(.top, 24) - - Text("Hotline is an open source project made possible by its contributors.") - .font(.system(size: 11)) - .foregroundStyle(.black) - .blendMode(.overlay) - .padding(.trailing, 32) - } - .padding(.bottom, 8) - - ForEach(contributors) { contributor in - Link(destination: contributor.webURL) { - HStack { - if let pictureURL = contributor.pictureURL { - AsyncImage(url: pictureURL) { phase in - if let image = phase.image { - image - .interpolation(.high) - .resizable() - .scaledToFit() - .background(.white) - .frame(width: 32, height: 32) - } else if phase.error != nil { - Color.clear - .frame(width: 32, height: 32) - } else { - Color.white - .opacity(0.2) - .frame(width: 32, height: 32) - } - } - .frame(width: 32, height: 32) - .clipShape(Circle()) - -// AsyncImage(url: pictureURL) { img in -// img -// .interpolation(.high) -// .resizable() -// .scaledToFit() -// .background(.white) -// } placeholder: { -// Color.white.opacity(0.2) -// .frame(width: 32, height: 32) -// } -// .frame(width: 32, height: 32) -// .clipShape(Circle()) - } - - VStack(alignment: .leading, spacing: 2) { - Text(contributor.username) - .fontWeight(.semibold) - .foregroundStyle(.white) - .lineLimit(1) - - Text(contributor.webURL.absoluteString) - .lineLimit(1) - .truncationMode(.middle) - .font(.system(size: 11)) - .foregroundStyle(.white.opacity(0.4)) - } - } - } - } +struct AboutView: View { + @Environment(\.openURL) private var openURL + + @State private var contributors: [AboutContributor] = [] + + var body: some View { + HStack(alignment: .center, spacing: 0) { + self.brandView + self.contributorsList + .background { + Color.black.blendMode(.softLight).opacity(0.3).ignoresSafeArea() } - } - .scrollClipDisabled() } .frame(width: 570, height: 330) .background( @@ -184,12 +80,105 @@ struct AboutView: View { .offset(x: 250) } ) - .background(Color.hotlineRed) .task { await loadContributors() } } + private var brandView: some View { + VStack(alignment: .center, spacing: 4) { + Spacer() + + Image("About Hotline") + + Text("Hotline") + .font(.system(size: 28)) + .fontWeight(.bold) + .padding(.top, 12) + .kerning(-1.0) + .foregroundColor(.white) + + let appDetails = getAppVersionAndBuild() + Button { + self.openURL(URL(string: "https://github.com/mierau/hotline/releases/tag/\(appDetails.version)beta\(appDetails.build)")!) + } label: { + + Text("Version \(String(format: "%.1f", appDetails.version))b\(appDetails.build)") + .foregroundColor(.white.opacity(0.756)) + .padding(.vertical, 4) + .padding(.horizontal, 12) + .background { + Capsule() + .fill(.white.opacity(0.5)) + .blendMode(.softLight) + } + } + .buttonStyle(.plain) + .buttonBorderShape(.capsule) + .padding(.bottom, 16) + + Spacer() + } + .frame(width: 250) + } + + private var contributorsList: some View { + ScrollView(.vertical) { + VStack(alignment: .leading, spacing: 16) { + self.contributorHeaderView + ForEach(self.contributors) { contributor in + Button { + self.openURL(contributor.webURL) + } label: { + AboutContributorView(contributor: contributor) + } + .buttonStyle(.plain) + .accessibilityAddTraits(.isLink) + .pointerStyle(.link) + } + } + .frame(maxWidth: .infinity) + .padding() + } + .scrollClipDisabled() + .scrollContentBackground(.hidden) +// .introspect(.scrollView, on: .macOS(.v10_15, .v11, .v12, .v13, .v14, .v15, .v26)) { v in +// v.automaticallyAdjustsContentInsets = false +// } + } + + private var contributorHeaderView: some View { + VStack(alignment: .leading, spacing: 4) { + Link(destination: URL(string: "https://github.com/mierau/hotline")!) { + HStack(alignment: .center, spacing: 4) { + Text("Contributors") + .lineLimit(1) + .font(.system(size: 16)) + .fontWeight(.semibold) + .foregroundStyle(.black) + .opacity(0.8) + + Image(systemName: "arrow.forward.circle.fill") + .resizable() + .fontWeight(.bold) + .scaledToFit() + .frame(width: 12, height: 12) + .foregroundStyle(.black) + .opacity(0.4) + } + } + .accessibilityAddTraits(.isLink) + .pointerStyle(.link) + + Text("Hotline is an open source project made possible by its contributors.") + .font(.system(size: 11)) + .foregroundStyle(.black) + .opacity(0.5) + .padding(.trailing, 32) + } + .padding(.bottom, 8) + } + func loadContributors() async { var newContributors: [AboutContributor] = [] @@ -214,43 +203,7 @@ struct AboutView: View { contributors = newContributors } } - - func checkForUpdate() async { - let appDetails = getAppVersionAndBuild() - - self.versionCheck = .checking - - do { - let url = URL(string: "https://api.github.com/repos/mierau/hotline/releases/latest")! - let (data, _) = try await URLSession.shared.data(from: url) - let versionExpression = /^([0-9\.]+)beta([0-9]+)/ - - if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], - let tagName = json["tag_name"] as? String, - let assets = json["assets"] as? [[String: Any]], - let firstAsset = assets.first, - let assetDownloadURL = firstAsset["browser_download_url"] as? String, - let versionMatches = try? versionExpression.wholeMatch(in: tagName) { - if let versionNumber = Double(versionMatches.1), - let buildNumber = Int(versionMatches.2), - versionNumber > appDetails.version || buildNumber > appDetails.build { - let versionString = "\(versionMatches.1)b\(versionMatches.2)" - self.versionCheck = .updateAvailable(version: versionString) - downloadURL = assetDownloadURL - } - else { - self.versionCheck = .upToDate - } - } - else { - self.versionCheck = .needToCheck - } - } - catch { - self.versionCheck = .needToCheck - } - } - + func getAppVersionAndBuild() -> (version: Double, build: Int) { let infoDictionary = Bundle.main.infoDictionary! let version = Double(infoDictionary["CFBundleShortVersionString"]! as! String)! diff --git a/Hotline/macOS/AppUpdateView.swift b/Hotline/macOS/AppUpdateView.swift new file mode 100644 index 0000000..89312fc --- /dev/null +++ b/Hotline/macOS/AppUpdateView.swift @@ -0,0 +1,187 @@ +import SwiftUI +import MarkdownUI +import AppKit +import Observation + +struct AppUpdateView: View { + @Environment(\.dismiss) private var dismiss + @Bindable private var update = AppUpdate.shared + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + if let message = update.message { + messageOnlyView(message) + } + else if update.release != nil { + headerSection + releaseNotesSection + actionRow + } + else { + defaultPlaceholder + } + } + .padding(.horizontal, 24) + .padding(.bottom, 24) + .padding(.top, 8) + .frame(width: update.message != nil ? 380 : 520) + .frame(idealHeight: 360) + .onChange(of: update.showWindow) { _, show in + if !show { + dismiss() + } + } + .onDisappear { + update.handleWindowDismissed() + } + } + + private var headerSection: some View { + HStack(alignment: .center, spacing: 8) { + Image(nsImage: NSApplication.shared.applicationIconImage) + .resizable() + .scaledToFit() + .frame(width: 56, height: 56) + .shadow(color: Color.black.mix(with: .red, by: 0.4).opacity(0.15), radius: 3, y: 1.5) + + VStack(alignment: .leading, spacing: 2) { + if let release = update.release { + Text("Hotline \(release.displayVersion)") + .font(.title2) + .fontWeight(.semibold) + } + Text("A new version of Hotline is available. πŸŽ‰") + .foregroundStyle(.secondary) + } + } + } + + private var releaseNotesSection: some View { + ScrollView(.vertical) { + Markdown(releaseNotesMarkdown()) + .textSelection(.enabled) + .markdownTheme(.gitHub.text(text: { + FontSize(.em(0.85)) + })) + .font(.system(size: 14)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 12) + .padding(.horizontal, 12) + } + .frame(minHeight: 220, maxHeight: 260) + .background( + RoundedRectangle(cornerRadius: 12) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + ) + } + + private var actionRow: some View { + HStack { + Button("Not Now") { + update.remindLater() + } + .buttonBorderShape(.capsule) + .keyboardShortcut(.escape, modifiers: []) + .controlSize(.large) + .disabled(update.isDownloading) + + Spacer() + + if update.isDownloading { + ProgressView() + .controlSize(.small) + .padding(.trailing, 12) + } + + Button("Download") { + update.startDownload() + } + .buttonBorderShape(.capsule) + .keyboardShortcut(.defaultAction) + .controlSize(.large) + .disabled(update.isDownloading) + } + } + + @ViewBuilder + private func messageOnlyView(_ message: AppUpdateMessage) -> some View { + let iconName = { + switch message.kind { + case .info: + return "info.circle" + case .success: + return "checkmark.circle.fill" + case .error: + return "exclamationmark.triangle.fill" + } + }() + + HStack(alignment: .center, spacing: 12) { + + if message.kind == .success { + Text("πŸ‘") + .font(.system(size: 42)) + .shadow(color: .yellow.mix(with: .black, by: 0.3).opacity(0.2), radius: 4, y: 1.5) + } + else { + Image(systemName: iconName) + .resizable() + .scaledToFit() + .symbolRenderingMode(.multicolor) + .frame(width: 48, height: 48) + } + + VStack(alignment: .leading, spacing: 2) { + Text(message.title) + .font(.title2) + .fontWeight(.semibold) + Text(message.detail) + .foregroundStyle(.secondary) + } + + Spacer() + } + } + + private var defaultPlaceholder: some View { + VStack(alignment: .center, spacing: 12) { + Text("No update information available.") + .font(.headline) + Button("Close") { + update.acknowledgeMessage() + } + .keyboardShortcut(.defaultAction) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func releaseNotesMarkdown() -> String { + if let combined = update.releaseNotesCombined? + .trimmingCharacters(in: .whitespacesAndNewlines), + combined.isEmpty == false { + return combined + } + + let fallback = update.release?.notes.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return fallback.isEmpty ? "_No release notes provided._" : fallback + } +} + +#Preview { + AppUpdate.shared.release = UpdateReleaseInfo( + tagName: "1.0beta1", + displayVersion: "1.0b1", + versionNumber: 1.0, + buildNumber: 1, + notes: """ + - Added support for release notes in Markdown. + - Improved the update workflow for macOS users. + """, + downloadURL: URL(string: "https://example.com")!, + assetName: "Hotline.zip" + ) + AppUpdate.shared.releases = [AppUpdate.shared.release!] + AppUpdate.shared.releaseNotesCombined = nil + AppUpdate.shared.showWindow = true + return AppUpdateView() +} -- cgit From 637cd9af54a58db0c5dc2062b6c20291afd31147 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Fri, 7 Nov 2025 17:40:59 -0800 Subject: Add Kingfisher dependency. Add support for animated GIF banners. Add fast image format detection to Data. --- Hotline.xcodeproj/project.pbxproj | 17 ++++++ .../xcshareddata/swiftpm/Package.resolved | 11 +++- .../Transfers/HotlineFilePreviewClientNew.swift | 9 +-- Hotline/Library/Extensions.swift | 36 ++++++++++++ Hotline/State/HotlineState.swift | 14 ++++- Hotline/macOS/HotlinePanelView.swift | 64 +++++++++++++++++++--- 6 files changed, 130 insertions(+), 21 deletions(-) (limited to 'Hotline.xcodeproj/project.xcworkspace/xcshareddata') diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index 2861aab..cac321c 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -37,6 +37,7 @@ DA501BE72EBE9542001714F8 /* TrackerBookmarkSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA501BE62EBE9542001714F8 /* TrackerBookmarkSheet.swift */; }; DA501BE92EBE9589001714F8 /* TrackerItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA501BE82EBE9589001714F8 /* TrackerItemView.swift */; }; DA501BEB2EBE95B4001714F8 /* TrackerBookmarkServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA501BEA2EBE95B4001714F8 /* TrackerBookmarkServerView.swift */; }; + DA501BEE2EBEC42E001714F8 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = DA501BED2EBEC42E001714F8 /* Kingfisher */; }; DA52689C2EB0738B00DCB941 /* GeneralSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA52689B2EB0738B00DCB941 /* GeneralSettingsView.swift */; platformFilters = (macos, ); }; DA52689E2EB073A400DCB941 /* IconSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA52689D2EB073A400DCB941 /* IconSettingsView.swift */; platformFilters = (macos, ); }; DA5268A02EB073BC00DCB941 /* SoundSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA52689F2EB073BC00DCB941 /* SoundSettingsView.swift */; platformFilters = (macos, ); }; @@ -227,6 +228,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + DA501BEE2EBEC42E001714F8 /* Kingfisher in Frameworks */, DACCE5EF2EAC19C9008CDD92 /* SwiftUIIntrospect in Frameworks */, DA72A0E02B4DA8CA00A0F48A /* SplitView in Frameworks */, DAB4D8872B4CB3610048A05C /* MarkdownUI in Frameworks */, @@ -551,6 +553,7 @@ DAB4D8862B4CB3610048A05C /* MarkdownUI */, DA72A0DF2B4DA8CA00A0F48A /* SplitView */, DACCE5EE2EAC19C9008CDD92 /* SwiftUIIntrospect */, + DA501BED2EBEC42E001714F8 /* Kingfisher */, ); productName = Hotline; productReference = DA9CAFB72B126D5700CDA197 /* Hotline.app */; @@ -584,6 +587,7 @@ DAB4D8852B4CB3610048A05C /* XCRemoteSwiftPackageReference "swift-markdown-ui" */, DA72A0DE2B4DA8CA00A0F48A /* XCRemoteSwiftPackageReference "SplitView" */, DACCE5ED2EAC19C9008CDD92 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */, + DA501BEC2EBEC42E001714F8 /* XCRemoteSwiftPackageReference "Kingfisher" */, ); productRefGroup = DA9CAFB82B126D5700CDA197 /* Products */; projectDirPath = ""; @@ -934,6 +938,14 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + DA501BEC2EBEC42E001714F8 /* XCRemoteSwiftPackageReference "Kingfisher" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/onevcat/Kingfisher"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 8.6.1; + }; + }; DA72A0DE2B4DA8CA00A0F48A /* XCRemoteSwiftPackageReference "SplitView" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/stevengharris/SplitView"; @@ -961,6 +973,11 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + DA501BED2EBEC42E001714F8 /* Kingfisher */ = { + isa = XCSwiftPackageProductDependency; + package = DA501BEC2EBEC42E001714F8 /* XCRemoteSwiftPackageReference "Kingfisher" */; + productName = Kingfisher; + }; DA72A0DF2B4DA8CA00A0F48A /* SplitView */ = { isa = XCSwiftPackageProductDependency; package = DA72A0DE2B4DA8CA00A0F48A /* XCRemoteSwiftPackageReference "SplitView" */; diff --git a/Hotline.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Hotline.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index f112b32..3084548 100644 --- a/Hotline.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Hotline.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "26db0d070cfc8e002044ea1380ec5e675c0718ebf212f6e8a57f12cc6c295723", + "originHash" : "590f8ee2c1318c670012f5d30a51419d3387a50648e0dd04f9654da2b1b7cef5", "pins" : [ + { + "identity" : "kingfisher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/onevcat/Kingfisher", + "state" : { + "revision" : "4d75de347da985a70c63af4d799ed482021f6733", + "version" : "8.6.1" + } + }, { "identity" : "networkimage", "kind" : "remoteSourceControl", diff --git a/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift b/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift index 63fe099..93a68fc 100644 --- a/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift @@ -31,14 +31,7 @@ public class HotlineFilePreviewClientNew { self.transferSize = size } - deinit { - // Cleanup in deinit - must be synchronous - if let tempURL = temporaryFileURL { - try? FileManager.default.removeItem(at: tempURL) - } - } - - // MARK: - Public API + // MARK: - /// Download file to temporary location for preview /// - Parameter progressHandler: Optional progress callback diff --git a/Hotline/Library/Extensions.swift b/Hotline/Library/Extensions.swift index 469676c..b6a4b68 100644 --- a/Hotline/Library/Extensions.swift +++ b/Hotline/Library/Extensions.swift @@ -22,6 +22,42 @@ extension Data { } return false } + + enum ImageFormat { + case gif + case jpeg + case png + case webp + case unknown + } + + var detectedImageFormat: ImageFormat { + guard self.count >= 12 else { return .unknown } + + let bytes = [UInt8](self.prefix(12)) + + // GIF: "GIF8" + if bytes[0] == 0x47 && bytes[1] == 0x49 && bytes[2] == 0x46 && bytes[3] == 0x38 { + return .gif + } + + // JPEG: FF D8 FF + if bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF { + return .jpeg + } + + // PNG: 89 50 4E 47 0D 0A 1A 0A + if bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47 && bytes[4] == 0x0D && bytes[5] == 0x0A && bytes[6] == 0x1A && bytes[7] == 0x0A { + return .png + } + + // WebP: "RIFF" at 0-3 and "WEBP" at 8-11 + if bytes[0] == 0x52 && bytes[1] == 0x49 && bytes[2] == 0x46 && bytes[3] == 0x46 && bytes[8] == 0x57 && bytes[9] == 0x45 && bytes[10] == 0x42 && bytes[11] == 0x50 { + return .webp + } + + return .unknown + } } // MARK: - diff --git a/Hotline/State/HotlineState.swift b/Hotline/State/HotlineState.swift index d5ab438..4b74df7 100644 --- a/Hotline/State/HotlineState.swift +++ b/Hotline/State/HotlineState.swift @@ -206,6 +206,8 @@ class HotlineState: Equatable { var accountsLoaded: Bool = false // Banner + var bannerFileURL: URL? = nil + var bannerImageFormat: Data.ImageFormat = .unknown #if os(macOS) var bannerImage: Image? = nil var bannerColors: ColorArt? = nil @@ -471,8 +473,10 @@ class HotlineState: Equatable { self.bannerDownloadTask?.cancel() self.bannerDownloadTask = nil self.bannerImage = nil + self.bannerImageFormat = .unknown + self.bannerFileURL = nil self.bannerColors = nil - } else if self.bannerDownloadTask != nil || self.bannerImage != nil { + } else if self.bannerDownloadTask != nil || self.bannerFileURL != nil { return } @@ -503,13 +507,16 @@ class HotlineState: Equatable { ) let fileURL = try await previewClient.preview() - defer { - previewClient.cleanup() + + if let oldFileURL = self.bannerFileURL { + try? FileManager.default.removeItem(at: oldFileURL) } guard self.client != nil else { return } let data = try Data(contentsOf: fileURL) + self.bannerImageFormat = data.detectedImageFormat + print("HotlineState: Banner download complete, data size: \(data.count) bytes") #if os(macOS) @@ -525,6 +532,7 @@ class HotlineState: Equatable { } self.bannerImage = Image(uiImage: image) #endif + self.bannerFileURL = fileURL self.bannerImage = blah self.bannerColors = ColorArt.analyze(image: image) diff --git a/Hotline/macOS/HotlinePanelView.swift b/Hotline/macOS/HotlinePanelView.swift index 81c24b7..220ea97 100644 --- a/Hotline/macOS/HotlinePanelView.swift +++ b/Hotline/macOS/HotlinePanelView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Kingfisher struct HotlinePanelView: View { @Environment(\.openWindow) var openWindow @@ -20,19 +21,25 @@ struct HotlinePanelView: View { private var backgroundColor: Color { Color(nsColor: self.activeHotline?.bannerColors?.backgroundColor ?? NSColor.controlBackgroundColor) } + + private var bannerFileURL: URL? { + self.activeHotline?.bannerFileURL + } + + private var bannerIsAnimated: Bool { + self.activeHotline?.bannerImageFormat == .gif + } var body: some View { VStack(spacing: 0) { - self.bannerImage - .interpolation(.high) - .resizable() - .scaledToFill() - .frame(width: 468, height: 60) - .frame(minWidth: 468, maxWidth: 468, minHeight: 60, maxHeight: 60) - .clipped() - .background(.black) - .animation(.default, value: self.bannerImage) + self.bannerView + .id("banner image view") + .animation(.default, value: self.bannerFileURL) + .background { + Color.black + } + HStack(spacing: 12) { Button { if NSEvent.modifierFlags.contains(.option) { @@ -170,6 +177,45 @@ struct HotlinePanelView: View { // .cornerRadius(10.0) // ) } + + private var bannerView: some View { + ZStack { + if self.bannerIsAnimated { + KFAnimatedImage + .url(self.bannerFileURL) + .placeholder { + Image("Default Banner") + } + .cacheMemoryOnly() + .cacheOriginalImage() + .scaledToFill() + .frame(width: 468, height: 60) + .frame(minWidth: 468, maxWidth: 468, minHeight: 60, maxHeight: 60) + .clipped() + .transition(.opacity) + .id("animated banner \(self.bannerFileURL?.absoluteString ?? "")") + } + else { + KFImage + .url(self.bannerFileURL) + .resizable() + .interpolation(.high) + .placeholder { + Image("Default Banner") + } + .cacheMemoryOnly() + .cacheOriginalImage() + .scaledToFill() + .frame(width: 468, height: 60) + .frame(minWidth: 468, maxWidth: 468, minHeight: 60, maxHeight: 60) + .clipped() + .transition(.opacity) + .id("static banner \(self.bannerFileURL?.absoluteString ?? "")") + } + } + .animation(.default, value: self.bannerIsAnimated) + .animation(.default, value: self.bannerFileURL) + } } #Preview { -- cgit From 6a95b53616a4abfa306ddce43151cf4fefbd20ed Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Mon, 17 Nov 2025 11:48:02 -0800 Subject: New macOS 26 app icon. Prepping NewsView for category/topic management. --- Hotline.xcodeproj/project.pbxproj | 4 + .../xcshareddata/swiftpm/Package.resolved | 10 +-- Hotline/Assets/App Icon.icon/Assets/hl.svg | 3 + Hotline/Assets/App Icon.icon/icon.json | 49 +++++++++++ Hotline/macOS/News/NewsView.swift | 96 +++++++++++++--------- 5 files changed, 119 insertions(+), 43 deletions(-) create mode 100644 Hotline/Assets/App Icon.icon/Assets/hl.svg create mode 100644 Hotline/Assets/App Icon.icon/icon.json (limited to 'Hotline.xcodeproj/project.xcworkspace/xcshareddata') diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index cd331c8..f1013ec 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -72,6 +72,7 @@ DA72A0E22B4DAA4000A0F48A /* NewsArticle.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA72A0E12B4DAA4000A0F48A /* NewsArticle.swift */; }; DA77253F2B21176D006C5ABB /* NewsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA77253E2B21176D006C5ABB /* NewsView.swift */; platformFilter = ios; }; DA7725412B21435B006C5ABB /* ObservableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA7725402B21435B006C5ABB /* ObservableScrollView.swift */; }; + DA81CA0E2ECBAA4200B198C5 /* App Icon.icon in Resources */ = {isa = PBXBuildFile; fileRef = DA81CA0D2ECBAA4200B198C5 /* App Icon.icon */; }; DA872B132BDDBF78008B1012 /* HotlinePanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA872B122BDDBF78008B1012 /* HotlinePanel.swift */; platformFilters = (macos, ); }; DA872B152BDDEE1A008B1012 /* VisualEffectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA872B142BDDEE1A008B1012 /* VisualEffectView.swift */; platformFilters = (macos, ); }; DA99218E2C51AA5D0058FA6C /* HotlineDataBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA99218D2C51AA5D0058FA6C /* HotlineDataBuilder.swift */; }; @@ -188,6 +189,7 @@ DA72A0E12B4DAA4000A0F48A /* NewsArticle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewsArticle.swift; sourceTree = ""; }; DA77253E2B21176D006C5ABB /* NewsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewsView.swift; sourceTree = ""; }; DA7725402B21435B006C5ABB /* ObservableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservableScrollView.swift; sourceTree = ""; }; + DA81CA0D2ECBAA4200B198C5 /* App Icon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = "App Icon.icon"; sourceTree = ""; }; DA872B122BDDBF78008B1012 /* HotlinePanel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HotlinePanel.swift; sourceTree = ""; }; DA872B142BDDEE1A008B1012 /* VisualEffectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualEffectView.swift; sourceTree = ""; }; DA99218D2C51AA5D0058FA6C /* HotlineDataBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineDataBuilder.swift; sourceTree = ""; }; @@ -282,6 +284,7 @@ DA4B8F3C2EA6FDCE00CBFD53 /* Assets */ = { isa = PBXGroup; children = ( + DA81CA0D2ECBAA4200B198C5 /* App Icon.icon */, DABE8BF22B55DBFC00884D28 /* Sounds */, ); path = Assets; @@ -627,6 +630,7 @@ DABE8BFE2B55E69000884D28 /* error.aiff in Resources */, DABE8BF42B55DC0A00884D28 /* transfer-complete.aiff in Resources */, DABE8C002B55E69800884D28 /* server-message.aiff in Resources */, + DA81CA0E2ECBAA4200B198C5 /* App Icon.icon in Resources */, DABE8C022B55E69D00884D28 /* new-news.aiff in Resources */, DABE8BFC2B55DC6800884D28 /* user-logout.aiff in Resources */, DABE8BF62B55DC2E00884D28 /* chat-message.aiff in Resources */, diff --git a/Hotline.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Hotline.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3084548..cf8aa9c 100644 --- a/Hotline.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Hotline.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "590f8ee2c1318c670012f5d30a51419d3387a50648e0dd04f9654da2b1b7cef5", + "originHash" : "b3995b8a3dcfa496e06e7cf496be577ccc3da760d703ce9c3d81b0e5644397fc", "pins" : [ { "identity" : "kingfisher", "kind" : "remoteSourceControl", "location" : "https://github.com/onevcat/Kingfisher", "state" : { - "revision" : "4d75de347da985a70c63af4d799ed482021f6733", - "version" : "8.6.1" + "revision" : "d30a5fad881137e2267f96a8e3fc35c58999bb94", + "version" : "8.6.2" } }, { @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-cmark", "state" : { - "revision" : "b97d09472e847a416629f026eceae0e2afcfad65", - "version" : "0.7.0" + "revision" : "5d9bdaa4228b381639fff09403e39a04926e2dbe", + "version" : "0.7.1" } }, { diff --git a/Hotline/Assets/App Icon.icon/Assets/hl.svg b/Hotline/Assets/App Icon.icon/Assets/hl.svg new file mode 100644 index 0000000..05a1bbb --- /dev/null +++ b/Hotline/Assets/App Icon.icon/Assets/hl.svg @@ -0,0 +1,3 @@ + + + diff --git a/Hotline/Assets/App Icon.icon/icon.json b/Hotline/Assets/App Icon.icon/icon.json new file mode 100644 index 0000000..e147fe7 --- /dev/null +++ b/Hotline/Assets/App Icon.icon/icon.json @@ -0,0 +1,49 @@ +{ + "fill" : { + "linear-gradient" : [ + "display-p3:0.98824,0.15686,0.15686,1.00000", + "srgb:0.83024,0.00000,0.00000,1.00000" + ], + "orientation" : { + "start" : { + "x" : 0.5, + "y" : 0 + }, + "stop" : { + "x" : 0.5, + "y" : 0.7 + } + } + }, + "groups" : [ + { + "layers" : [ + { + "image-name" : "hl.svg", + "name" : "hl", + "position" : { + "scale" : 1.33, + "translation-in-points" : [ + 0, + -4.509732002588862e-05 + ] + } + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "translucency" : { + "enabled" : true, + "value" : 0.5 + } + } + ], + "supported-platforms" : { + "circles" : [ + "watchOS" + ], + "squares" : "shared" + } +} \ No newline at end of file diff --git a/Hotline/macOS/News/NewsView.swift b/Hotline/macOS/News/NewsView.swift index a07a441..39e8d68 100644 --- a/Hotline/macOS/News/NewsView.swift +++ b/Hotline/macOS/News/NewsView.swift @@ -9,7 +9,7 @@ struct NewsView: View { @State private var selection: NewsInfo? @State private var articleText: String? - @State private var splitHidden = SideHolder(.bottom) + @State private var splitHidden: SideHolder = SideHolder(.bottom) @State private var splitFraction = FractionHolder.usingUserDefaults(0.25, key: "News Split Fraction") @State private var editorOpen: Bool = false @State private var replyOpen: Bool = false @@ -20,19 +20,17 @@ struct NewsView: View { if model.serverVersion < 151 { disabledNewsView } + else if !model.newsLoaded { + loadingIndicator + } + else if model.news.isEmpty { + emptyNewsView + } else { NavigationStack { VSplit( top: { - if !model.newsLoaded { - loadingIndicator - } - else if model.news.isEmpty { - emptyNewsView - } - else { - newsBrowser - } + newsBrowser }, bottom: { articleViewer @@ -45,13 +43,13 @@ struct NewsView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(nsColor: .textBackgroundColor)) } - .task { - if !model.newsLoaded { - loading = true - try? await model.getNewsList() - loading = false - } - } + } + } + .task { + if !model.newsLoaded { + loading = true + try? await model.getNewsList() + loading = false } } .sheet(isPresented: $editorOpen) { @@ -78,7 +76,7 @@ struct NewsView: View { } } .toolbar { - ToolbarItem(placement: .primaryAction) { + ToolbarItem { Button { if selection?.type == .category || selection?.type == .article { editorOpen = true @@ -86,11 +84,11 @@ struct NewsView: View { } label: { Image(systemName: "square.and.pencil") } - .help("New Post") + .help("New post under current topic") .disabled(selection?.type != .category && selection?.type != .article) } - ToolbarItem(placement: .primaryAction) { + ToolbarItem { Button { if selection?.type == .article { replyOpen = true @@ -98,11 +96,37 @@ struct NewsView: View { } label: { Image(systemName: "arrowshape.turn.up.left") } - .help("Reply to Post") + .help("Reply to selected post") .disabled(selection?.type != .article) } - ToolbarItem(placement: .primaryAction) { + if #available(macOS 26.0, *) { + ToolbarSpacer(.fixed) + } + + ToolbarItem { + Button { +// if selection?.type == .category || selection?.type == .article { +// editorOpen = true +// } + } label: { + Image(systemName: "newspaper") + } + .help("Create a new topic") + } + + ToolbarItem { + Button { +// if selection?.type == .category || selection?.type == .article { +// editorOpen = true +// } + } label: { + Image(systemName: "tray") + } + .help("Create a new category") + } + + ToolbarItem { Button { loading = true if let selectionPath = selection?.path { @@ -120,7 +144,7 @@ struct NewsView: View { } label: { Image(systemName: "arrow.clockwise") } - .help("Reload News") + .help("Reload selected topic") .disabled(loading) } } @@ -265,22 +289,18 @@ struct NewsView: View { } } else { - HStack(alignment: .center) { - Spacer() - HStack(alignment: .center, spacing: 8) { -// Image(systemName: "doc.append") -// .resizable() -// .scaledToFit() + EmptyView() +// HStack(alignment: .center) { +// Spacer() +// HStack(alignment: .center, spacing: 8) { +// Text("Select a news article to read") // .foregroundStyle(.tertiary) -// .frame(width: 16, height: 16) - Text("Select a news post to read") - .foregroundStyle(.tertiary) - .font(.system(size: 13)) - } - Spacer() - } - .padding() - .padding(.top, 48) +// .font(.subheadline) +// } +// Spacer() +// } +// .padding() +// .padding(.top, 48) } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) -- cgit