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/MacApp.swift | 56 +++++- Hotline/State/AppUpdate.swift | 341 +++++++++++++++++++++++++++++++++++ Hotline/Utility/NSWindowBridge.swift | 21 +++ Hotline/macOS/AboutView.swift | 339 +++++++++++++++------------------- Hotline/macOS/AppUpdateView.swift | 187 +++++++++++++++++++ 5 files changed, 748 insertions(+), 196 deletions(-) create mode 100644 Hotline/State/AppUpdate.swift create mode 100644 Hotline/macOS/AppUpdateView.swift (limited to 'Hotline') 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