06/22 Add Check for Update button in Settings
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user