// Deliberately minimal: fetch the raw VERSION file from `main` on GitHub // and compare semver to the bundled version. No silent binary-replacing // auto-update — that needs signing/notarization/an appcast server this // project doesn't have. A manual check shows an alert (an explicit action // that expects a response); the periodic automatic check instead posts a // quiet system notification, since that one wasn't asked for in the // moment. import Foundation import UserNotifications enum UpdateChecker { enum Outcome { case upToDate case updateAvailable(version: String, url: URL) case failed } private static let versionURL = URL(string: "https://raw.githubusercontent.com/bdeshi/shannoncoat/main/VERSION")! private static let releaseURL = URL(string: "https://github.com/bdeshi/shannoncoat/releases/latest")! private static let enabledDefaultsKey = "AutomaticUpdateCheckEnabled" private static let lastCheckDefaultsKey = "LastUpdateCheckDate" private static let checkInterval: TimeInterval = 24 * 60 * 60 static var automaticCheckEnabled: Bool { get { UserDefaults.standard.object(forKey: enabledDefaultsKey) as? Bool ?? true } set { UserDefaults.standard.set(newValue, forKey: enabledDefaultsKey) } } static var currentVersion: String { guard let resourceURL = Bundle.main.resourceURL, let contents = try? String(contentsOf: resourceURL.appendingPathComponent("VERSION"), encoding: .utf8) else { return "unknown" } return contents.trimmingCharacters(in: .whitespacesAndNewlines) } // What the About panel shows: the version alone for a real release // build (COMMIT is written empty when build.sh finds HEAD sitting // exactly on a tag), or version + short SHA for a dev build, so two // local builds off the same version number are distinguishable. static var displayVersion: String { commitSHA.isEmpty ? currentVersion : "\(currentVersion) (\(commitSHA))" } private static var commitSHA: String { guard let resourceURL = Bundle.main.resourceURL, let contents = try? String(contentsOf: resourceURL.appendingPathComponent("COMMIT"), encoding: .utf8) else { return "" } return contents.trimmingCharacters(in: .whitespacesAndNewlines) } static func checkManually(completion: @escaping (Outcome) -> Void) { fetchLatestVersion { latest in guard let latest else { completion(.failed); return } if isNewer(latest, than: currentVersion) { completion(.updateAvailable(version: latest, url: releaseURL)) } else { completion(.upToDate) } } } // Called once at launch; a no-op unless the toggle is on and it's been // at least a day since the last check. static func checkAutomaticallyIfDue() { guard automaticCheckEnabled else { return } let last = UserDefaults.standard.object(forKey: lastCheckDefaultsKey) as? Date ?? .distantPast guard Date().timeIntervalSince(last) > checkInterval else { return } UserDefaults.standard.set(Date(), forKey: lastCheckDefaultsKey) fetchLatestVersion { latest in guard let latest, isNewer(latest, than: currentVersion) else { return } postAvailableNotification(version: latest) } } private static func fetchLatestVersion(completion: @escaping (String?) -> Void) { URLSession.shared.dataTask(with: versionURL) { data, _, _ in let version = data.flatMap { String(data: $0, encoding: .utf8) }? .trimmingCharacters(in: .whitespacesAndNewlines) completion((version?.isEmpty == false) ? version : nil) }.resume() } private static func isNewer(_ a: String, than b: String) -> Bool { let av = a.split(separator: ".").compactMap { Int($0) } let bv = b.split(separator: ".").compactMap { Int($0) } for i in 0.. y } } return false } private static func postAvailableNotification(version: String) { let center = UNUserNotificationCenter.current() center.requestAuthorization(options: [.alert]) { granted, _ in guard granted else { return } let content = UNMutableNotificationContent() content.title = "shannoncoat \(version) is available" content.body = "Click to view the release notes." center.add(UNNotificationRequest(identifier: "shannoncoat-update-available", content: content, trigger: nil)) } } }