126 lines
5.4 KiB
Swift
126 lines
5.4 KiB
Swift
// 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=<bundle id>
|
|
// 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..<count {
|
|
let av = i < aParts.count ? aParts[i] : 0
|
|
let bv = i < bParts.count ? bParts[i] : 0
|
|
if av != bv { return av > bv }
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ── iTunes Lookup response shape ────────────────────────────────────
|
|
|
|
private struct LookupResponse: Decodable {
|
|
let results: [LookupResult]
|
|
}
|
|
private struct LookupResult: Decodable {
|
|
let version: String
|
|
let trackViewUrl: String
|
|
}
|
|
}
|