aboutsummaryrefslogtreecommitdiff
path: root/Hotline
diff options
context:
space:
mode:
Diffstat (limited to 'Hotline')
-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
5 files changed, 748 insertions, 196 deletions
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()
+}