diff --git a/apps/ios/CrossDeskMobile/AppUpdateChecker.swift b/apps/ios/CrossDeskMobile/AppUpdateChecker.swift index 070bf048..bec18495 100644 --- a/apps/ios/CrossDeskMobile/AppUpdateChecker.swift +++ b/apps/ios/CrossDeskMobile/AppUpdateChecker.swift @@ -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() } } } diff --git a/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.h b/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.h index b0cd7a4b..94119340 100644 --- a/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.h +++ b/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.h @@ -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; diff --git a/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.mm b/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.mm index e6d06c6a..91bfb5ec 100644 --- a/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.mm +++ b/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.mm @@ -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(releaseJSON.bytes), releaseJSON.length), - "ios-arm64"); - return result ? [NSString stringWithUTF8String:result->c_str()] : nil; -} - - (instancetype)init { self = [super init]; if (self) { diff --git a/apps/ios/CrossDeskMobile/Info.plist b/apps/ios/CrossDeskMobile/Info.plist index 7e761df7..ec47a2a5 100644 --- a/apps/ios/CrossDeskMobile/Info.plist +++ b/apps/ios/CrossDeskMobile/Info.plist @@ -20,6 +20,8 @@ $(MARKETING_VERSION) CFBundleVersion $(CURRENT_PROJECT_VERSION) + CrossDeskAppStoreID + 6828859211 LSRequiresIPhoneOS LSSupportsOpeningDocumentsInPlace diff --git a/apps/ios/CrossDeskMobile/RemoteSessionModel.swift b/apps/ios/CrossDeskMobile/RemoteSessionModel.swift index a174a354..e278cb58 100644 --- a/apps/ios/CrossDeskMobile/RemoteSessionModel.swift +++ b/apps/ios/CrossDeskMobile/RemoteSessionModel.swift @@ -733,7 +733,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe connectionStatus = NSLocalizedString("未连接", comment: "") audioPlayer.endSession() AppOrientation.endSession() - appUpdates.checkNow() + appUpdates.checkIfNeeded() } func retry() { diff --git a/apps/ios/CrossDeskMobile/Resources/de.lproj/Localizable.strings b/apps/ios/CrossDeskMobile/Resources/de.lproj/Localizable.strings index 8c849277..6055f152 100644 --- a/apps/ios/CrossDeskMobile/Resources/de.lproj/Localizable.strings +++ b/apps/ios/CrossDeskMobile/Resources/de.lproj/Localizable.strings @@ -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"; diff --git a/apps/ios/CrossDeskMobile/Resources/en.lproj/Localizable.strings b/apps/ios/CrossDeskMobile/Resources/en.lproj/Localizable.strings index a601612d..91edd115 100644 --- a/apps/ios/CrossDeskMobile/Resources/en.lproj/Localizable.strings +++ b/apps/ios/CrossDeskMobile/Resources/en.lproj/Localizable.strings @@ -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"; diff --git a/apps/ios/CrossDeskMobile/Resources/es.lproj/Localizable.strings b/apps/ios/CrossDeskMobile/Resources/es.lproj/Localizable.strings index 125d527a..abf9a753 100644 --- a/apps/ios/CrossDeskMobile/Resources/es.lproj/Localizable.strings +++ b/apps/ios/CrossDeskMobile/Resources/es.lproj/Localizable.strings @@ -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"; diff --git a/apps/ios/CrossDeskMobile/Resources/fr.lproj/Localizable.strings b/apps/ios/CrossDeskMobile/Resources/fr.lproj/Localizable.strings index 52ad0d34..6e8f3848 100644 --- a/apps/ios/CrossDeskMobile/Resources/fr.lproj/Localizable.strings +++ b/apps/ios/CrossDeskMobile/Resources/fr.lproj/Localizable.strings @@ -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"; diff --git a/apps/ios/CrossDeskMobile/Resources/ja.lproj/Localizable.strings b/apps/ios/CrossDeskMobile/Resources/ja.lproj/Localizable.strings index c8d28339..5c8b4c7e 100644 --- a/apps/ios/CrossDeskMobile/Resources/ja.lproj/Localizable.strings +++ b/apps/ios/CrossDeskMobile/Resources/ja.lproj/Localizable.strings @@ -281,3 +281,7 @@ "鼠标左键" = "マウスの左ボタン"; "鼠标控制" = "マウスコントロール"; "鼠标滚轮" = "マウスホイール"; +"前往 App Store 更新" = "App Store でアップデート"; +"App Store 暂无可用更新" = "現在 App Store に利用可能なアップデートはありません"; +"暂时无法在当前地区的 App Store 查询到此应用" = "現在の地域の App Store でこのアプリが見つかりませんでした"; +"新版本需要 iOS %1$@ 或更高版本" = "新しいバージョンには iOS %1$@ 以降が必要です"; diff --git a/apps/ios/CrossDeskMobile/Resources/ko.lproj/Localizable.strings b/apps/ios/CrossDeskMobile/Resources/ko.lproj/Localizable.strings index d246a7b6..0d38f72e 100644 --- a/apps/ios/CrossDeskMobile/Resources/ko.lproj/Localizable.strings +++ b/apps/ios/CrossDeskMobile/Resources/ko.lproj/Localizable.strings @@ -281,3 +281,7 @@ "鼠标左键" = "마우스 왼쪽 버튼"; "鼠标控制" = "마우스 제어"; "鼠标滚轮" = "마우스 휠"; +"前往 App Store 更新" = "App Store에서 업데이트"; +"App Store 暂无可用更新" = "현재 App Store에 사용 가능한 업데이트가 없습니다"; +"暂时无法在当前地区的 App Store 查询到此应用" = "현재 지역의 App Store에서 이 앱을 찾을 수 없습니다"; +"新版本需要 iOS %1$@ 或更高版本" = "새 버전에는 iOS %1$@ 이상이 필요합니다"; diff --git a/apps/ios/CrossDeskMobile/Resources/pt-BR.lproj/Localizable.strings b/apps/ios/CrossDeskMobile/Resources/pt-BR.lproj/Localizable.strings index 3faa1b63..d24274bb 100644 --- a/apps/ios/CrossDeskMobile/Resources/pt-BR.lproj/Localizable.strings +++ b/apps/ios/CrossDeskMobile/Resources/pt-BR.lproj/Localizable.strings @@ -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"; diff --git a/apps/ios/CrossDeskMobile/Resources/pt.lproj/Localizable.strings b/apps/ios/CrossDeskMobile/Resources/pt.lproj/Localizable.strings index 3faa1b63..d24274bb 100644 --- a/apps/ios/CrossDeskMobile/Resources/pt.lproj/Localizable.strings +++ b/apps/ios/CrossDeskMobile/Resources/pt.lproj/Localizable.strings @@ -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"; diff --git a/apps/ios/CrossDeskMobile/Resources/ru.lproj/Localizable.strings b/apps/ios/CrossDeskMobile/Resources/ru.lproj/Localizable.strings index 3b129c74..140620e4 100644 --- a/apps/ios/CrossDeskMobile/Resources/ru.lproj/Localizable.strings +++ b/apps/ios/CrossDeskMobile/Resources/ru.lproj/Localizable.strings @@ -281,3 +281,7 @@ "鼠标左键" = "Левая кнопка мыши"; "鼠标控制" = "Управление мышью"; "鼠标滚轮" = "Колесо мыши"; +"前往 App Store 更新" = "Обновить в App Store"; +"App Store 暂无可用更新" = "В App Store пока нет доступных обновлений"; +"暂时无法在当前地区的 App Store 查询到此应用" = "Не удалось найти приложение в App Store для текущего региона"; +"新版本需要 iOS %1$@ 或更高版本" = "Для новой версии требуется iOS %1$@ или новее"; diff --git a/apps/ios/CrossDeskMobile/Resources/zh-Hans.lproj/Localizable.strings b/apps/ios/CrossDeskMobile/Resources/zh-Hans.lproj/Localizable.strings index 71d20c62..748ee027 100644 --- a/apps/ios/CrossDeskMobile/Resources/zh-Hans.lproj/Localizable.strings +++ b/apps/ios/CrossDeskMobile/Resources/zh-Hans.lproj/Localizable.strings @@ -281,3 +281,7 @@ "鼠标左键" = "鼠标左键"; "鼠标控制" = "鼠标控制"; "鼠标滚轮" = "鼠标滚轮"; +"前往 App Store 更新" = "前往 App Store 更新"; +"App Store 暂无可用更新" = "App Store 暂无可用更新"; +"暂时无法在当前地区的 App Store 查询到此应用" = "暂时无法在当前地区的 App Store 查询到此应用"; +"新版本需要 iOS %1$@ 或更高版本" = "新版本需要 iOS %1$@ 或更高版本"; diff --git a/apps/ios/CrossDeskMobile/Resources/zh-Hant.lproj/Localizable.strings b/apps/ios/CrossDeskMobile/Resources/zh-Hant.lproj/Localizable.strings index 44c680be..440e4200 100644 --- a/apps/ios/CrossDeskMobile/Resources/zh-Hant.lproj/Localizable.strings +++ b/apps/ios/CrossDeskMobile/Resources/zh-Hant.lproj/Localizable.strings @@ -281,3 +281,7 @@ "鼠标左键" = "滑鼠左鍵"; "鼠标控制" = "滑鼠控制"; "鼠标滚轮" = "滑鼠滾輪"; +"前往 App Store 更新" = "前往 App Store 更新"; +"App Store 暂无可用更新" = "App Store 暫無可用更新"; +"暂时无法在当前地区的 App Store 查询到此应用" = "暫時無法在目前地區的 App Store 查詢到此 App"; +"新版本需要 iOS %1$@ 或更高版本" = "新版本需要 iOS %1$@ 或以上版本"; diff --git a/apps/ios/CrossDeskMobile/ThirdPartyLicensesView.swift b/apps/ios/CrossDeskMobile/ThirdPartyLicensesView.swift index 7bed2b75..4f967873 100644 --- a/apps/ios/CrossDeskMobile/ThirdPartyLicensesView.swift +++ b/apps/ios/CrossDeskMobile/ThirdPartyLicensesView.swift @@ -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() } } } } diff --git a/apps/ios/README.md b/apps/ios/README.md index 69ab15eb..78da4a3c 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -224,6 +224,8 @@ Mobile releases keep the desktop `latest` tag and GitHub's latest release selection unchanged. After uploading the ZIP, the workflow merges its server URL, version and release date into `downloads.ios-arm64` in the shared `https://version.crossdesk.cn/version.json`. +This entry describes the unsigned download artifact; the iOS app checks the +App Store listing for its own updates instead. Top-level desktop version information and other platforms' entries are retained. Uploads preserve other platforms' files and use the existing `SERVER_HOST`, `SERVER_USER` and `SERVER_KEY` repository secrets. Regular desktop version tags @@ -333,21 +335,33 @@ forwarding. Actual password entry still requires validation on updated Windows. ## App updates -After network consent, the app checks the public version manifest once on each -launch or return from the background, and after a remote connection ends while -active. Concurrent triggers share the pending request. There are no periodic -checks or automatic retries; failures retain any known update until the next -trigger or manual check. Backgrounding or withdrawing consent cancels requests -and ignores stale replies. +After network consent, the app queries Apple's public Lookup API on launch, +on return from the background, and after a remote connection ends while active. +Successful results are reused in memory for six hours; failed checks defer the +next automatic attempt for 15 minutes. There is no timer or automatic retry. +**检查更新** bypasses this cooldown. Concurrent triggers share the pending +request. Backgrounding or withdrawing consent cancels requests and ignores +stale replies; failures retain an already discovered update. -Local updates read only `downloads.ios-arm64`, using the same numeric version -and patch comparison as desktop. Build dates alone do not indicate an update; -missing platform metadata is a failed check. New releases show a red badge with -a white exclamation mark at the top-right edge of the home Settings button, -matching the announcement badge's size and position. -Settings → About shows “新版本” and a red dot. About displays the new version, -links to the official download page, and offers **检查更新** for manual checks. -This is independent of the remote desktop's upgrade notice. +`CrossDeskAppStoreID` in `CrossDeskMobile/Info.plist` identifies the App Store +listing (`6828859211`). Forks must set their own App ID and bundle identifier; +responses are accepted only when both identities match. Lookup uses the current +StoreKit storefront, converting its three-letter country code to two letters. +When StoreKit has no storefront, the device's region is used; an unknown region +fails the check instead of silently querying the US store. + +Only the published App Store `version` is compared numerically with +`CFBundleShortVersionString`. Desktop patches, build dates, `CFBundleVersion`, +TestFlight builds and unsigned GitHub artifacts do not trigger App Store update +notices. An empty Lookup result has its own unavailable message, not an +up-to-date message. A newer release requiring a newer iOS version shows that +requirement without an installable-update badge. + +Installable new releases show the existing red badge on the home Settings +button and “新版本” with a red dot in Settings → About. About shows the version +and **前往 App Store 更新**, linking directly to the application's App Store +page. The remote desktop's separate upgrade check still uses the CrossDesk +version manifest. ## Announcements diff --git a/apps/ios/tests/app_update_checker_test.py b/apps/ios/tests/app_update_checker_test.py index 99794b91..ad75032c 100644 --- a/apps/ios/tests/app_update_checker_test.py +++ b/apps/ios/tests/app_update_checker_test.py @@ -15,12 +15,6 @@ class AppUpdateCheckerTest(unittest.TestCase): harness.write_text(r''' import Foundation -// The actual shared C++ parser is covered by version_checker_test.cpp. -enum CrossDeskRTCBridge { - static func checkMobileUpdate(appVersion: String, releaseJSON: Data) -> String? { - String(data: releaseJSON, encoding: .utf8) - } -} func expect(_ condition: @autoclosure () -> Bool, _ message: String) { if !condition() { fatalError(message) } } @@ -28,11 +22,21 @@ func expect(_ condition: @autoclosure () -> Bool, _ message: String) { final class Fixture { var replies: [(Data?) -> Void] = [] var cancellations = 0 - lazy var checker = AppUpdateChecker(currentVersion: "0.0.1", fetch: { completion in + var time = Date(timeIntervalSince1970: 0) + lazy var checker = AppUpdateChecker(currentVersion: "0.0.1", + bundleIdentifier: "com.chunkdash.crossdesk", appStoreID: "6828859211", systemVersion: "17.0", + fetch: { completion in self.replies.append(completion) return { self.cancellations += 1 } - }) - func reply(_ value: String?) { replies.last!(value.map { Data($0.utf8) }) } + }, now: { self.time }) + func reply(_ value: String?) { + replies.last!(value.map { version in + try! JSONSerialization.data(withJSONObject: ["resultCount": 1, "results": [[ + "wrapperType": "software", "bundleId": "com.chunkdash.crossdesk", + "trackId": 6828859211, "version": version, "minimumOsVersion": "16.0" + ]]]) + }) + } } @main enum Tests { @@ -49,18 +53,19 @@ enum Tests { f.checker.setEnabled(true) expect(f.replies.count == 1, "Duplicate active callbacks do not recheck after completion") f.checker.setEnabled(false) + f.time.addTimeInterval(6 * 60 * 60) f.checker.setEnabled(true) - expect(f.replies.count == 2, "Every new visit checks even immediately after success") + expect(f.replies.count == 2, "A new visit checks after the automatic cooldown") f.reply(nil) expect(f.checker.status == .failed && f.checker.updateAvailable, "Failed checks retain discovered update") f.checker.setEnabled(true) expect(f.replies.count == 2, "A failure does not restart the check") - // Session teardown and the About action both explicitly request a check. + // The About action explicitly bypasses the automatic cooldown. f.checker.checkNow() f.checker.checkNow() expect(f.replies.count == 3, "Explicit events check again without a cooldown and deduplicate") - f.reply("") + f.reply("0.0.1") expect(!f.checker.updateAvailable && f.checker.status == .upToDate, "Successful current-version check clears badge") f.checker.checkNow() @@ -69,6 +74,7 @@ enum Tests { expect(f.cancellations == 1, "Background/revocation cancels request") stale(Data("9.0.0".utf8)) expect(!f.checker.updateAvailable, "Ignore cancelled response") + f.time.addTimeInterval(6 * 60 * 60) f.checker.setEnabled(true) stale(Data("9.0.0".utf8)) expect(f.checker.status == .checking, "Ignore old response during new request") @@ -77,6 +83,7 @@ enum Tests { let beforeForeground = f.replies.count f.checker.checkNow() expect(f.replies.count == beforeForeground, "Session endings in background do not check") + f.time.addTimeInterval(6 * 60 * 60) f.checker.setEnabled(true) expect(f.replies.count == beforeForeground + 1, "Check once on return from background") expect(f.checker.updateAvailable, "Retain badge while rechecking") diff --git a/localization/catalog.json b/localization/catalog.json index 27b9c7f0..6613ecbb 100644 --- a/localization/catalog.json +++ b/localization/catalog.json @@ -10847,6 +10847,54 @@ "fr": "Connectez-vous après le démarrage sans ouvrir de session. Après connexion, conservez les mêmes ID et mot de passe et toutes les fonctions.", "es": "Conéctese tras arrancar sin iniciar sesión. Después de iniciar sesión, conserve el mismo ID, contraseña y todas las funciones.", "pt-BR": "Conecte-se após a inicialização sem fazer login. Após o login, mantenha o mesmo ID, senha e todas as funções." + }, + "前往 App Store 更新": { + "zh-Hans": "前往 App Store 更新", + "en": "Update in the App Store", + "ru": "Обновить в App Store", + "zh-Hant": "前往 App Store 更新", + "ja": "App Store でアップデート", + "ko": "App Store에서 업데이트", + "de": "Im App Store aktualisieren", + "fr": "Mettre à jour dans l’App Store", + "es": "Actualizar en el App Store", + "pt-BR": "Atualizar na App Store" + }, + "App Store 暂无可用更新": { + "zh-Hans": "App Store 暂无可用更新", + "en": "No update is currently available in the App Store", + "ru": "В App Store пока нет доступных обновлений", + "zh-Hant": "App Store 暫無可用更新", + "ja": "現在 App Store に利用可能なアップデートはありません", + "ko": "현재 App Store에 사용 가능한 업데이트가 없습니다", + "de": "Im App Store ist derzeit kein Update verfügbar", + "fr": "Aucune mise à jour disponible dans l’App Store pour le moment", + "es": "No hay actualizaciones disponibles en el App Store por ahora", + "pt-BR": "Nenhuma atualização disponível na App Store no momento" + }, + "暂时无法在当前地区的 App Store 查询到此应用": { + "zh-Hans": "暂时无法在当前地区的 App Store 查询到此应用", + "en": "This app could not be found in the App Store for the current region", + "ru": "Не удалось найти приложение в App Store для текущего региона", + "zh-Hant": "暫時無法在目前地區的 App Store 查詢到此 App", + "ja": "現在の地域の App Store でこのアプリが見つかりませんでした", + "ko": "현재 지역의 App Store에서 이 앱을 찾을 수 없습니다", + "de": "Diese App wurde im App Store der aktuellen Region nicht gefunden", + "fr": "Cette app est introuvable dans l’App Store de la région actuelle", + "es": "No se encontró esta app en el App Store de la región actual", + "pt-BR": "Este app não foi encontrado na App Store da região atual" + }, + "新版本需要 iOS %1$@ 或更高版本": { + "zh-Hans": "新版本需要 iOS %1$@ 或更高版本", + "en": "The new version requires iOS %1$@ or later", + "ru": "Для новой версии требуется iOS %1$@ или новее", + "zh-Hant": "新版本需要 iOS %1$@ 或以上版本", + "ja": "新しいバージョンには iOS %1$@ 以降が必要です", + "ko": "새 버전에는 iOS %1$@ 이상이 필요합니다", + "de": "Die neue Version erfordert iOS %1$@ oder neuer", + "fr": "La nouvelle version nécessite iOS %1$@ ou une version ultérieure", + "es": "La nueva versión requiere iOS %1$@ o posterior", + "pt-BR": "A nova versão requer iOS %1$@ ou posterior" } }, "desktop": { @@ -12479,7 +12527,11 @@ "鼠标右键", "鼠标左键", "鼠标控制", - "鼠标滚轮" + "鼠标滚轮", + "前往 App Store 更新", + "App Store 暂无可用更新", + "暂时无法在当前地区的 App Store 查询到此应用", + "新版本需要 iOS %1$@ 或更高版本" ], "harmony": [ "OpenFEC 告知",