[fix] check iOS updates against App Store releases

This commit is contained in:
dijunkun committed 2026-11-11 01:42:59 +08:00
1 parent f0f0a68ec6
commit 67afb25c75
20 files changed
+327 -71

No files matched your search

+166 -24
View File
@@ -1,28 +1,42 @@
import Foundation
import Combine
import StoreKit
/// Main-thread local-app checks triggered by visits, session endings and manual requests.
/// Main-thread checks of the version actually published on the App Store.
final class AppUpdateChecker: ObservableObject {
enum Status { case idle, checking, upToDate, available, failed }
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 = ""
var updateAvailable: Bool { !availableVersion.isEmpty }
@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 compare: (String, Data) -> String?
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 ?? "",
fetch: @escaping Fetch = AppUpdateChecker.fetchRelease,
compare: @escaping (String, Data) -> String? = CrossDeskRTCBridge.checkMobileUpdate) {
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.fetch = fetch
self.compare = compare
self.bundleIdentifier = bundleIdentifier
self.appStoreID = appStoreID
self.systemVersion = systemVersion
self.fetch = fetch ?? { AppUpdateChecker.fetchRelease(appStoreID: appStoreID, completion: $0) }
self.now = now
}
deinit {
@@ -32,47 +46,175 @@ final class AppUpdateChecker: ObservableObject {
func setEnabled(_ value: Bool) {
guard enabled != value else { return }
enabled = value
if enabled { checkNow(); return }
if enabled { checkIfNeeded(); return }
generation = UUID()
cancelRequest?()
cancelRequest = nil
if status == .checking {
status = updateAvailable ? .available : .idle
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
if let data, let result = self.compare(self.currentVersion, data) {
self.availableVersion = result
self.status = self.updateAvailable ? .available : .upToDate
} else {
// 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)
}
}
// Public metadata only: no identity, installed version or credentials sent.
private static func fetchRelease(completion: @escaping (Data?) -> Void) -> (() -> Void) {
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 request = URLRequest(url: URL(string: "https://version.crossdesk.cn/version.json")!,
cachePolicy: .reloadIgnoringLocalCacheData)
let task = session.dataTask(with: request) { data, response, error in
let valid = error == nil && (response as? HTTPURLResponse)?.statusCode == 200 &&
(data?.count ?? 0) <= 256 * 1024
DispatchQueue.main.async { completion(valid ? data : nil) }
session.finishTasksAndInvalidate()
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) }
}
}
task.resume()
return { task.cancel(); session.invalidateAndCancel() }
}
}
@@ -155,10 +155,6 @@ typedef struct {
releaseJSON:(NSData *)releaseJSON
NS_SWIFT_NAME(availableUpdate(appVersion:releaseJSON:));
+ (nullable NSString *)checkMobileUpdateForAppVersion:(NSString *)appVersion
releaseJSON:(NSData *)releaseJSON
NS_SWIFT_NAME(checkMobileUpdate(appVersion:releaseJSON:));
- (void)configureWithSignalHost:(NSString *)host
signalPort:(NSInteger)signalPort;
- (void)setVideoAdaptationPolicy:(CrossDeskVideoAdaptationPolicy)policy;
@@ -508,16 +508,6 @@ MiniRtcParams MakeParams(const RTCState &state, const std::string &user_id,
return update ? [NSString stringWithUTF8String:update->c_str()] : nil;
}
+ (nullable NSString *)checkMobileUpdateForAppVersion:(NSString *)appVersion
releaseJSON:(NSData *)releaseJSON {
if (releaseJSON.length == 0 || releaseJSON.length > 256 * 1024) return nil;
const auto result = crossdesk::CheckPlatformAppUpdate(
appVersion.UTF8String ?: "", std::string_view(
static_cast<const char *>(releaseJSON.bytes), releaseJSON.length),
"ios-arm64");
return result ? [NSString stringWithUTF8String:result->c_str()] : nil;
}
- (instancetype)init {
self = [super init];
if (self) {
+2
View File
@@ -20,6 +20,8 @@
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>CrossDeskAppStoreID</key>
<string>6828859211</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
@@ -733,7 +733,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
connectionStatus = NSLocalizedString("未连接", comment: "")
audioPlayer.endSession()
AppOrientation.endSession()
appUpdates.checkNow()
appUpdates.checkIfNeeded()
}
func retry() {
@@ -281,3 +281,7 @@
"鼠标左键" = "Linke Maustaste";
"鼠标控制" = "Maussteuerung";
"鼠标滚轮" = "Mausrad";
"前往 App Store 更新" = "Im App Store aktualisieren";
"App Store 暂无可用更新" = "Im App Store ist derzeit kein Update verfügbar";
"暂时无法在当前地区的 App Store 查询到此应用" = "Diese App wurde im App Store der aktuellen Region nicht gefunden";
"新版本需要 iOS %1$@ 或更高版本" = "Die neue Version erfordert iOS %1$@ oder neuer";
@@ -281,3 +281,7 @@
"鼠标左键" = "Left mouse button";
"鼠标控制" = "Mouse control";
"鼠标滚轮" = "Mouse wheel";
"前往 App Store 更新" = "Update in the App Store";
"App Store 暂无可用更新" = "No update is currently available in the App Store";
"暂时无法在当前地区的 App Store 查询到此应用" = "This app could not be found in the App Store for the current region";
"新版本需要 iOS %1$@ 或更高版本" = "The new version requires iOS %1$@ or later";
@@ -281,3 +281,7 @@
"鼠标左键" = "Botón izquierdo del ratón";
"鼠标控制" = "Control del ratón";
"鼠标滚轮" = "Rueda del ratón";
"前往 App Store 更新" = "Actualizar en el App Store";
"App Store 暂无可用更新" = "No hay actualizaciones disponibles en el App Store por ahora";
"暂时无法在当前地区的 App Store 查询到此应用" = "No se encontró esta app en el App Store de la región actual";
"新版本需要 iOS %1$@ 或更高版本" = "La nueva versión requiere iOS %1$@ o posterior";
@@ -281,3 +281,7 @@
"鼠标左键" = "Bouton gauche de la souris";
"鼠标控制" = "Contrôle de la souris";
"鼠标滚轮" = "Molette de la souris";
"前往 App Store 更新" = "Mettre à jour dans l’App Store";
"App Store 暂无可用更新" = "Aucune mise à jour disponible dans l’App Store pour le moment";
"暂时无法在当前地区的 App Store 查询到此应用" = "Cette app est introuvable dans l’App Store de la région actuelle";
"新版本需要 iOS %1$@ 或更高版本" = "La nouvelle version nécessite iOS %1$@ ou une version ultérieure";
@@ -281,3 +281,7 @@
"鼠标左键" = "マウスの左ボタン";
"鼠标控制" = "マウスコントロール";
"鼠标滚轮" = "マウスホイール";
"前往 App Store 更新" = "App Store でアップデート";
"App Store 暂无可用更新" = "現在 App Store に利用可能なアップデートはありません";
"暂时无法在当前地区的 App Store 查询到此应用" = "現在の地域の App Store でこのアプリが見つかりませんでした";
"新版本需要 iOS %1$@ 或更高版本" = "新しいバージョンには iOS %1$@ 以降が必要です";
@@ -281,3 +281,7 @@
"鼠标左键" = "마우스 왼쪽 버튼";
"鼠标控制" = "마우스 제어";
"鼠标滚轮" = "마우스 휠";
"前往 App Store 更新" = "App Store에서 업데이트";
"App Store 暂无可用更新" = "현재 App Store에 사용 가능한 업데이트가 없습니다";
"暂时无法在当前地区的 App Store 查询到此应用" = "현재 지역의 App Store에서 이 앱을 찾을 수 없습니다";
"新版本需要 iOS %1$@ 或更高版本" = "새 버전에는 iOS %1$@ 이상이 필요합니다";
@@ -281,3 +281,7 @@
"鼠标左键" = "Botão esquerdo do mouse";
"鼠标控制" = "Controle do mouse";
"鼠标滚轮" = "Roda do mouse";
"前往 App Store 更新" = "Atualizar na App Store";
"App Store 暂无可用更新" = "Nenhuma atualização disponível na App Store no momento";
"暂时无法在当前地区的 App Store 查询到此应用" = "Este app não foi encontrado na App Store da região atual";
"新版本需要 iOS %1$@ 或更高版本" = "A nova versão requer iOS %1$@ ou posterior";
@@ -281,3 +281,7 @@
"鼠标左键" = "Botão esquerdo do mouse";
"鼠标控制" = "Controle do mouse";
"鼠标滚轮" = "Roda do mouse";
"前往 App Store 更新" = "Atualizar na App Store";
"App Store 暂无可用更新" = "Nenhuma atualização disponível na App Store no momento";
"暂时无法在当前地区的 App Store 查询到此应用" = "Este app não foi encontrado na App Store da região atual";
"新版本需要 iOS %1$@ 或更高版本" = "A nova versão requer iOS %1$@ ou posterior";
@@ -281,3 +281,7 @@
"鼠标左键" = "Левая кнопка мыши";
"鼠标控制" = "Управление мышью";
"鼠标滚轮" = "Колесо мыши";
"前往 App Store 更新" = "Обновить в App Store";
"App Store 暂无可用更新" = "В App Store пока нет доступных обновлений";
"暂时无法在当前地区的 App Store 查询到此应用" = "Не удалось найти приложение в App Store для текущего региона";
"新版本需要 iOS %1$@ 或更高版本" = "Для новой версии требуется iOS %1$@ или новее";
@@ -281,3 +281,7 @@
"鼠标左键" = "鼠标左键";
"鼠标控制" = "鼠标控制";
"鼠标滚轮" = "鼠标滚轮";
"前往 App Store 更新" = "前往 App Store 更新";
"App Store 暂无可用更新" = "App Store 暂无可用更新";
"暂时无法在当前地区的 App Store 查询到此应用" = "暂时无法在当前地区的 App Store 查询到此应用";
"新版本需要 iOS %1$@ 或更高版本" = "新版本需要 iOS %1$@ 或更高版本";
@@ -281,3 +281,7 @@
"鼠标左键" = "滑鼠左鍵";
"鼠标控制" = "滑鼠控制";
"鼠标滚轮" = "滑鼠滾輪";
"前往 App Store 更新" = "前往 App Store 更新";
"App Store 暂无可用更新" = "App Store 暫無可用更新";
"暂时无法在当前地区的 App Store 查询到此应用" = "暫時無法在目前地區的 App Store 查詢到此 App";
"新版本需要 iOS %1$@ 或更高版本" = "新版本需要 iOS %1$@ 或以上版本";
@@ -83,11 +83,15 @@ struct ThirdPartyLicensesView: View {
List {
Section {
LabeledContent(NSLocalizedString("版本", comment: ""), value: appUpdates.currentVersion)
if appUpdates.updateAvailable {
Link(destination: URL(string: "https://crossdesk.cn")!) {
if appUpdates.updateAvailable, let appStoreURL = appUpdates.appStoreURL {
Link(destination: appStoreURL) {
HStack {
UpdateDot()
Text(String(format: NSLocalizedString("新版本可用:v%1$@", comment: ""), String(describing: appUpdates.availableVersion)))
VStack(alignment: .leading, spacing: 4) {
Text(String(format: NSLocalizedString("新版本可用:v%1$@", comment: ""), appUpdates.availableVersion))
Text(NSLocalizedString("前往 App Store 更新", comment: ""))
.font(.caption).foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "arrow.up.right")
}
@@ -98,7 +102,12 @@ struct ThirdPartyLicensesView: View {
} else if appUpdates.status == .checking {
Text(NSLocalizedString("正在检查更新…", comment: "")).foregroundStyle(.secondary)
} else if appUpdates.status == .upToDate {
Text(NSLocalizedString("当前已是最新版本", comment: "")).foregroundStyle(.secondary)
Text(NSLocalizedString("App Store 暂无可用更新", comment: "")).foregroundStyle(.secondary)
} else if appUpdates.status == .unavailable {
Text(NSLocalizedString("暂时无法在当前地区的 App Store 查询到此应用", comment: "")).foregroundStyle(.secondary)
} else if appUpdates.status == .requiresNewerOS {
Text(String(format: NSLocalizedString("新版本需要 iOS %1$@ 或更高版本", comment: ""), appUpdates.requiredOSVersion))
.foregroundStyle(.secondary)
} else if appUpdates.status == .failed {
Text(NSLocalizedString("检查失败,请稍后重试", comment: "")).foregroundStyle(.red)
}
@@ -143,7 +152,7 @@ struct ThirdPartyLicensesView: View {
.navigationTitle(NSLocalizedString("关于 CrossDesk", comment: ""))
.navigationBarTitleDisplayMode(.inline)
.onValueChange(of: session.hasNetworkConsent) { allowed in
if allowed { appUpdates.checkNow() }
if allowed { appUpdates.checkIfNeeded() }
}
}
}