diff options
| author | Dustin Mierau <dustin@mierau.me> | 2025-10-24 14:16:30 -0700 |
|---|---|---|
| committer | Dustin Mierau <dustin@mierau.me> | 2025-10-24 14:16:30 -0700 |
| commit | 8bab12046b2c91cff7e1194e576acecf57551e08 (patch) | |
| tree | efd06a26ee7a12e43b6d77c147347459f8092427 /Hotline/State/AppUpdate.swift | |
| parent | 2df678753e2e6fe309de6b546925540f89e3d680 (diff) | |
Further along work towards auto updating. Show release notes for the builds that are more recent than yours.
Diffstat (limited to 'Hotline/State/AppUpdate.swift')
| -rw-r--r-- | Hotline/State/AppUpdate.swift | 341 |
1 files changed, 341 insertions, 0 deletions
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 + } +} |