mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-11-11 19:07:33 +08:00
221 lines
9.7 KiB
Swift
221 lines
9.7 KiB
Swift
import Foundation
|
|
import Combine
|
|
import StoreKit
|
|
|
|
/// Main-thread checks of the version actually published on the App Store.
|
|
final class AppUpdateChecker: ObservableObject {
|
|
enum Status { case idle, checking, upToDate, available, unavailable, requiresNewerOS, failed }
|
|
// Fetchers complete asynchronously on the main thread.
|
|
typealias Fetch = (@escaping (Data?) -> Void) -> (() -> Void)
|
|
@Published private(set) var status: Status = .idle
|
|
@Published private(set) var availableVersion = ""
|
|
@Published private(set) var appStoreURL: URL?
|
|
@Published private(set) var requiredOSVersion = ""
|
|
var updateAvailable: Bool { !availableVersion.isEmpty && appStoreURL != nil }
|
|
let currentVersion: String
|
|
|
|
private let bundleIdentifier: String
|
|
private let appStoreID: String
|
|
private let systemVersion: String
|
|
private let fetch: Fetch
|
|
private let now: () -> Date
|
|
private var nextAutomaticCheck: Date?
|
|
private var statusBeforeCheck: Status = .idle
|
|
private var enabled = false
|
|
private var generation = UUID()
|
|
private var cancelRequest: (() -> Void)?
|
|
|
|
init(currentVersion: String = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "",
|
|
bundleIdentifier: String = Bundle.main.bundleIdentifier ?? "",
|
|
appStoreID: String = Bundle.main.object(forInfoDictionaryKey: "CrossDeskAppStoreID") as? String ?? "",
|
|
systemVersion: String = AppUpdateChecker.currentSystemVersion,
|
|
fetch: Fetch? = nil,
|
|
now: @escaping () -> Date = Date.init) {
|
|
self.currentVersion = currentVersion
|
|
self.bundleIdentifier = bundleIdentifier
|
|
self.appStoreID = appStoreID
|
|
self.systemVersion = systemVersion
|
|
self.fetch = fetch ?? { AppUpdateChecker.fetchRelease(appStoreID: appStoreID, completion: $0) }
|
|
self.now = now
|
|
}
|
|
|
|
deinit {
|
|
cancelRequest?()
|
|
}
|
|
|
|
func setEnabled(_ value: Bool) {
|
|
guard enabled != value else { return }
|
|
enabled = value
|
|
if enabled { checkIfNeeded(); return }
|
|
generation = UUID()
|
|
cancelRequest?()
|
|
cancelRequest = nil
|
|
if status == .checking {
|
|
status = statusBeforeCheck
|
|
}
|
|
}
|
|
|
|
/// Reuse this run's result for six hours; failed requests can retry after 15 minutes.
|
|
func checkIfNeeded() {
|
|
if let nextAutomaticCheck, now() < nextAutomaticCheck { return }
|
|
checkNow()
|
|
}
|
|
|
|
/// Manual checks bypass the automatic cooldown, but still require consent and foreground.
|
|
func checkNow() {
|
|
guard enabled, status != .checking else { return }
|
|
statusBeforeCheck = status
|
|
status = .checking
|
|
let token = UUID()
|
|
generation = token
|
|
cancelRequest = fetch { [weak self] data in
|
|
guard let self, self.enabled, self.generation == token else { return }
|
|
self.cancelRequest = nil
|
|
// A cancelled request does not consume the automatic-check interval.
|
|
self.nextAutomaticCheck = self.now().addingTimeInterval(15 * 60)
|
|
guard let data, let result = Self.parseRelease(data, bundleIdentifier: self.bundleIdentifier,
|
|
appStoreID: self.appStoreID) else {
|
|
self.status = .failed
|
|
return
|
|
}
|
|
switch result {
|
|
case .unavailable:
|
|
self.clearUpdate()
|
|
self.status = .unavailable
|
|
case .release(let release):
|
|
guard let newer = Self.isNewerVersion(release.version, than: self.currentVersion),
|
|
let requiresNewerOS = Self.isNewerVersion(release.minimumOsVersion, than: self.systemVersion) else {
|
|
self.status = .failed
|
|
return
|
|
}
|
|
self.clearUpdate()
|
|
if newer && requiresNewerOS {
|
|
self.requiredOSVersion = release.minimumOsVersion
|
|
self.status = .requiresNewerOS
|
|
} else if newer {
|
|
self.availableVersion = release.version
|
|
self.appStoreURL = URL(string: "https://apps.apple.com/app/id\(release.trackId)")
|
|
self.status = .available
|
|
} else {
|
|
self.status = .upToDate
|
|
}
|
|
}
|
|
self.nextAutomaticCheck = self.now().addingTimeInterval(6 * 60 * 60)
|
|
}
|
|
}
|
|
|
|
private func clearUpdate() {
|
|
availableVersion = ""
|
|
appStoreURL = nil
|
|
requiredOSVersion = ""
|
|
}
|
|
|
|
struct StoreRelease: Decodable {
|
|
let wrapperType: String
|
|
let bundleId: String
|
|
let trackId: Int64
|
|
let version: String
|
|
let minimumOsVersion: String
|
|
}
|
|
|
|
private struct LookupResponse: Decodable {
|
|
let resultCount: Int
|
|
let results: [StoreRelease]
|
|
}
|
|
|
|
enum LookupResult {
|
|
case unavailable
|
|
case release(StoreRelease)
|
|
}
|
|
|
|
static func parseRelease(_ data: Data, bundleIdentifier: String, appStoreID: String) -> LookupResult? {
|
|
guard !bundleIdentifier.isEmpty, let identifier = numericAppID(appStoreID),
|
|
data.count <= 256 * 1024,
|
|
let response = try? JSONDecoder().decode(LookupResponse.self, from: data),
|
|
response.resultCount == response.results.count else { return nil }
|
|
if response.resultCount == 0 { return .unavailable }
|
|
guard response.resultCount == 1, let release = response.results.first,
|
|
release.wrapperType == "software", release.bundleId == bundleIdentifier,
|
|
release.trackId == identifier,
|
|
versionComponents(release.version) != nil,
|
|
versionComponents(release.minimumOsVersion) != nil else { return nil }
|
|
return .release(release)
|
|
}
|
|
|
|
/// App Store marketing versions are numeric; build numbers and desktop patches are unrelated.
|
|
static func isNewerVersion(_ version: String, than current: String) -> Bool? {
|
|
guard let latest = versionComponents(version), let installed = versionComponents(current) else { return nil }
|
|
return installed.lexicographicallyPrecedes(latest)
|
|
}
|
|
|
|
private static func versionComponents(_ version: String) -> [UInt64]? {
|
|
let parts = version.split(separator: ".", omittingEmptySubsequences: false)
|
|
guard (1...3).contains(parts.count) else { return nil }
|
|
var components: [UInt64] = []
|
|
for part in parts {
|
|
guard !part.isEmpty, part.utf8.allSatisfy({ (48...57).contains($0) }),
|
|
let value = UInt64(part) else { return nil }
|
|
components.append(value)
|
|
}
|
|
return components + Array(repeating: 0, count: 3 - components.count)
|
|
}
|
|
|
|
private static func numericAppID(_ value: String) -> Int64? {
|
|
guard !value.isEmpty, value.utf8.allSatisfy({ (48...57).contains($0) }),
|
|
let identifier = Int64(value), identifier > 0 else { return nil }
|
|
return identifier
|
|
}
|
|
|
|
static var currentSystemVersion: String {
|
|
let version = ProcessInfo.processInfo.operatingSystemVersion
|
|
return "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)"
|
|
}
|
|
|
|
static func lookupCountry(storefrontCountry: String?, fallbackRegion: String?) -> String? {
|
|
// StoreKit uses ISO alpha-3; Lookup expects alpha-2. Foundation canonicalizes aliases.
|
|
guard let source = storefrontCountry ?? fallbackRegion else { return nil }
|
|
let country = Locale(identifier: "und_" + source).region?.identifier.lowercased() ?? ""
|
|
guard country.utf8.count == 2, country.utf8.allSatisfy({ (97...122).contains($0) }) else { return nil }
|
|
return country
|
|
}
|
|
|
|
static func lookupURL(appStoreID: String, country: String) -> URL? {
|
|
guard let identifier = numericAppID(appStoreID),
|
|
country.utf8.count == 2, country.utf8.allSatisfy({ (97...122).contains($0) }) else { return nil }
|
|
var url = URLComponents(string: "https://itunes.apple.com/lookup")!
|
|
url.queryItems = [URLQueryItem(name: "id", value: String(identifier)),
|
|
URLQueryItem(name: "country", value: country),
|
|
URLQueryItem(name: "entity", value: "software")]
|
|
return url.url
|
|
}
|
|
|
|
// Only the public app ID and storefront region are sent; no installed version or credentials.
|
|
private static func fetchRelease(appStoreID: String, completion: @escaping (Data?) -> Void) -> (() -> Void) {
|
|
let configuration = URLSessionConfiguration.ephemeral
|
|
configuration.timeoutIntervalForRequest = 10
|
|
configuration.timeoutIntervalForResource = 15
|
|
let session = URLSession(configuration: configuration)
|
|
let task = Task { @MainActor in
|
|
defer { session.finishTasksAndInvalidate() }
|
|
let storefront = await Storefront.current
|
|
guard !Task.isCancelled else { return }
|
|
guard let country = lookupCountry(storefrontCountry: storefront?.countryCode,
|
|
fallbackRegion: Locale.current.region?.identifier),
|
|
let url = lookupURL(appStoreID: appStoreID, country: country) else {
|
|
completion(nil)
|
|
return
|
|
}
|
|
do {
|
|
let request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData)
|
|
let (data, response) = try await session.data(for: request)
|
|
guard !Task.isCancelled else { return }
|
|
let valid = (response as? HTTPURLResponse)?.statusCode == 200 && data.count <= 256 * 1024
|
|
completion(valid ? data : nil)
|
|
} catch {
|
|
if !Task.isCancelled { completion(nil) }
|
|
}
|
|
}
|
|
return { task.cancel(); session.invalidateAndCancel() }
|
|
}
|
|
}
|