[fix] check iOS updates against App Store releases

This commit is contained in:
dijunkun
2026-11-11 01:42:59 +08:00
parent f0f0a68ec6
commit 67afb25c75
20 changed files with 327 additions and 71 deletions
+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() }
}
}
}
+28 -14
View File
@@ -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
+19 -12
View File
@@ -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")
+53 -1
View File
@@ -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 告知",