From efe8df08614ce9c31708fef0565d40ded9050ff9 Mon Sep 17 00:00:00 2001 From: Christiaan Hendriksen Date: Sat, 11 Apr 2026 21:09:43 +0100 Subject: [PATCH] Add auto-update checker via GitHub Releases API On app launch, checks for newer releases and shows a native alert with a download link. Failures are silent. Checks are rate-limited to once per hour, and dismissed versions are remembered. Co-Authored-By: Claude Opus 4.6 (1M context) --- freewrite/UpdateChecker.swift | 98 ++++++++++++++++++++++++++++++++ freewrite/freewrite.entitlements | 2 + freewrite/freewriteApp.swift | 11 +++- 3 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 freewrite/UpdateChecker.swift diff --git a/freewrite/UpdateChecker.swift b/freewrite/UpdateChecker.swift new file mode 100644 index 0000000..1e5aaa0 --- /dev/null +++ b/freewrite/UpdateChecker.swift @@ -0,0 +1,98 @@ +// +// UpdateChecker.swift +// freewrite +// +// Checks GitHub Releases API for newer versions on app launch. +// + +import SwiftUI + +@Observable +final class UpdateChecker { + + var updateAvailable = false + var latestVersion = "" + var releaseURL: URL? + + private static let endpoint = "https://api.github.com/repos/farzaa/freewrite/releases/latest" + private static let cooldown: TimeInterval = 3600 // 1 hour + private static let lastCheckKey = "UpdateChecker.lastCheckTime" + private static let dismissedKey = "UpdateChecker.dismissedVersion" + + private struct GitHubRelease: Codable { + let tagName: String + let htmlUrl: String + + enum CodingKeys: String, CodingKey { + case tagName = "tag_name" + case htmlUrl = "html_url" + } + } + + func checkForUpdate() async { + let now = Date().timeIntervalSince1970 + let lastCheck = UserDefaults.standard.double(forKey: Self.lastCheckKey) + if now - lastCheck < Self.cooldown { return } + UserDefaults.standard.set(now, forKey: Self.lastCheckKey) + + do { + guard let url = URL(string: Self.endpoint) else { return } + var request = URLRequest(url: url) + request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") + request.timeoutInterval = 10 + + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return } + + let release = try JSONDecoder().decode(GitHubRelease.self, from: data) + let remote = release.tagName.hasPrefix("v") + ? String(release.tagName.dropFirst()) + : release.tagName + + let current = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0" + + guard isNewer(remote, than: current) else { return } + + let dismissed = UserDefaults.standard.string(forKey: Self.dismissedKey) + if dismissed == remote { return } + + await MainActor.run { + latestVersion = remote + releaseURL = URL(string: release.htmlUrl) + updateAvailable = true + } + } catch { + // Silent failure — never bother the user + } + } + + func openReleasePage() { + if let url = releaseURL { + NSWorkspace.shared.open(url) + } + updateAvailable = false + } + + func dismissUpdate() { + UserDefaults.standard.set(latestVersion, forKey: Self.dismissedKey) + updateAvailable = false + } + + private func isNewer(_ remote: String, than current: String) -> Bool { + let r = remote.split(separator: ".").compactMap { Int($0) } + let c = current.split(separator: ".").compactMap { Int($0) } + + // If parsing failed for either, don't show update + guard r.count == remote.split(separator: ".").count, + c.count == current.split(separator: ".").count else { return false } + + let maxLen = max(r.count, c.count) + for i in 0.. cv { return true } + if rv < cv { return false } + } + return false + } +} diff --git a/freewrite/freewrite.entitlements b/freewrite/freewrite.entitlements index 5e12138..97e4844 100644 --- a/freewrite/freewrite.entitlements +++ b/freewrite/freewrite.entitlements @@ -12,5 +12,7 @@ com.apple.security.personal-information.speech-recognition + com.apple.security.network.client + diff --git a/freewrite/freewriteApp.swift b/freewrite/freewriteApp.swift index 9966f1a..fcf5b1f 100644 --- a/freewrite/freewriteApp.swift +++ b/freewrite/freewriteApp.swift @@ -11,7 +11,8 @@ import SwiftUI struct freewriteApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @AppStorage("colorScheme") private var colorSchemeString: String = "light" - + @State private var updateChecker = UpdateChecker() + init() { // Register Lato font if let fontURL = Bundle.main.url(forResource: "Lato-Regular", withExtension: "ttf") { @@ -24,6 +25,14 @@ struct freewriteApp: App { ContentView() .toolbar(.hidden, for: .windowToolbar) .preferredColorScheme(colorSchemeString == "dark" ? .dark : .light) + .task { await updateChecker.checkForUpdate() } + .alert("Update Available", isPresented: $updateChecker.updateAvailable) { + Button("Download Update") { updateChecker.openReleasePage() } + Button("Later", role: .cancel) { updateChecker.dismissUpdate() } + } message: { + let current = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown" + Text("Freewrite \(updateChecker.latestVersion) is available. You're currently on \(current).") + } } .windowStyle(.hiddenTitleBar) .defaultSize(width: 1100, height: 600)