aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDustin Mierau <dustin@mierau.me>2025-10-24 14:16:30 -0700
committerDustin Mierau <dustin@mierau.me>2025-10-24 14:16:30 -0700
commit8bab12046b2c91cff7e1194e576acecf57551e08 (patch)
treeefd06a26ee7a12e43b6d77c147347459f8092427
parent2df678753e2e6fe309de6b546925540f89e3d680 (diff)
Further along work towards auto updating. Show release notes for the builds that are more recent than yours.
-rw-r--r--Hotline.xcodeproj/project.pbxproj35
-rw-r--r--Hotline.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved11
-rw-r--r--Hotline/MacApp.swift56
-rw-r--r--Hotline/State/AppUpdate.swift341
-rw-r--r--Hotline/Utility/NSWindowBridge.swift21
-rw-r--r--Hotline/macOS/AboutView.swift339
-rw-r--r--Hotline/macOS/AppUpdateView.swift187
7 files changed, 788 insertions, 202 deletions
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 = "<group>"; };
DAC3D9822BC33FD000A727C9 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = "<group>"; };
DAC87F062C5010E80060FADF /* HotlineExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineExtensions.swift; sourceTree = "<group>"; };
+ DACCE5E02EABE4B4008CDD92 /* AppUpdate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdate.swift; sourceTree = "<group>"; };
+ DACCE5E22EABE86A008CDD92 /* AppUpdateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdateView.swift; sourceTree = "<group>"; };
DADDB28A2B22B31F0024040D /* Tracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tracker.swift; sourceTree = "<group>"; };
DADDB28C2B22B5920024040D /* Server.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Server.swift; sourceTree = "<group>"; };
DADDB28E2B238D850024040D /* Hotline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Hotline.swift; sourceTree = "<group>"; };
@@ -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 = "<group>";
@@ -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()
+}