Files
JQC_iOS_App/JanitorialQC/Utils/UpdateChecker.swift
T

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
}
}