// Utils/UpdateChecker.swift // -------------------------- // Checks the App Store for a newer published version of this app and // surfaces an "Update Available" prompt when one exists. // // The app is distributed via App Store *unlisted* distribution — it is a // real App Store listing (just excluded from public search/browse), so the // standard public iTunes Lookup API works correctly here: // https://itunes.apple.com/lookup?bundleId= // No private server endpoint or App Store Connect API key is required. // // Usage: // - Automatic: ContentView calls `await UpdateChecker.shared.checkForUpdate()` // once per app launch (see .task in ContentView.swift). If a newer version // is found, `updateAvailable` flips true and the root view presents an alert. // - Manual: Settings → "Check for Updates" button calls checkForUpdate(force: true). // // Throttling: automatic launch checks are skipped if the last check was less // than 24 hours ago, to avoid hitting Apple's endpoint on every cold start. // The manual button always bypasses the throttle (force: true). import Foundation import Combine @MainActor final class UpdateChecker: ObservableObject { static let shared = UpdateChecker() /// True once a newer version has been confirmed on the App Store. /// The root view observes this to present the update alert. @Published var updateAvailable = false /// The newer version string, for display in the alert ("Version 1.3 is available"). @Published var latestVersion: String? /// The App Store page URL to open when the user taps "Update". @Published var appStoreURL: URL? /// True while a check is in flight — lets the Settings button show a spinner. @Published var isChecking = false private static let lastCheckKey = "jqc.updateChecker.lastCheckedAt" private static let throttleInterval: TimeInterval = 24 * 60 * 60 // 24 hours /// Must match PRODUCT_BUNDLE_IDENTIFIER in project.pbxproj. private let bundleId = "com.ltservicesinc.JanitorialQC" private init() {} /// Compares the running app's version against the App Store's published /// version. Sets `updateAvailable`/`latestVersion`/`appStoreURL` when a /// newer version exists. Silent no-op on any network/parse failure — /// an update check failing must never block or interrupt app usage. /// /// - Parameter force: bypass the 24-hour throttle (used by the manual /// "Check for Updates" button in Settings). func checkForUpdate(force: Bool = false) async { guard force || shouldCheck() else { return } isChecking = true defer { isChecking = false } guard let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String, let url = URL(string: "https://itunes.apple.com/lookup?bundleId=\(bundleId)") else { return } do { let (data, _) = try await URLSession.shared.data(from: url) let lookup = try JSONDecoder().decode(LookupResponse.self, from: data) UserDefaults.standard.set(Date(), forKey: Self.lastCheckKey) guard let result = lookup.results.first else { return } if isVersion(result.version, newerThan: currentVersion) { latestVersion = result.version appStoreURL = URL(string: result.trackViewUrl) updateAvailable = true } else { updateAvailable = false } } catch { // Network failure, offline, or unexpected response shape — // fail silently. The next launch (or manual check) will retry. } } /// Dismisses the current prompt without disabling future checks. /// Called when the user taps "Later" on the update alert. func dismissForNow() { updateAvailable = false } // ── Throttle ───────────────────────────────────────────────────────── private func shouldCheck() -> Bool { guard let last = UserDefaults.standard.object(forKey: Self.lastCheckKey) as? Date else { return true } return Date().timeIntervalSince(last) >= Self.throttleInterval } // ── Version comparison ────────────────────────────────────────────── // Compares dotted version strings ("1.10" > "1.9") component-by-component // as integers, rather than lexicographically (which would incorrectly // rank "1.10" before "1.9"). Missing trailing components are treated as 0. private func isVersion(_ a: String, newerThan b: String) -> Bool { let aParts = a.split(separator: ".").compactMap { Int($0) } let bParts = b.split(separator: ".").compactMap { Int($0) } let count = max(aParts.count, bParts.count) for i in 0.. bv } } return false } // ── iTunes Lookup response shape ──────────────────────────────────── private struct LookupResponse: Decodable { let results: [LookupResult] } private struct LookupResult: Decodable { let version: String let trackViewUrl: String } }