mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-11-11 10:59:06 +08:00
[fix] check iOS updates against App Store releases
This commit is contained in:
@@ -1,28 +1,42 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
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 {
|
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.
|
// Fetchers complete asynchronously on the main thread.
|
||||||
typealias Fetch = (@escaping (Data?) -> Void) -> (() -> Void)
|
typealias Fetch = (@escaping (Data?) -> Void) -> (() -> Void)
|
||||||
@Published private(set) var status: Status = .idle
|
@Published private(set) var status: Status = .idle
|
||||||
@Published private(set) var availableVersion = ""
|
@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
|
let currentVersion: String
|
||||||
|
|
||||||
|
private let bundleIdentifier: String
|
||||||
|
private let appStoreID: String
|
||||||
|
private let systemVersion: String
|
||||||
private let fetch: Fetch
|
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 enabled = false
|
||||||
private var generation = UUID()
|
private var generation = UUID()
|
||||||
private var cancelRequest: (() -> Void)?
|
private var cancelRequest: (() -> Void)?
|
||||||
|
|
||||||
init(currentVersion: String = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "",
|
init(currentVersion: String = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "",
|
||||||
fetch: @escaping Fetch = AppUpdateChecker.fetchRelease,
|
bundleIdentifier: String = Bundle.main.bundleIdentifier ?? "",
|
||||||
compare: @escaping (String, Data) -> String? = CrossDeskRTCBridge.checkMobileUpdate) {
|
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.currentVersion = currentVersion
|
||||||
self.fetch = fetch
|
self.bundleIdentifier = bundleIdentifier
|
||||||
self.compare = compare
|
self.appStoreID = appStoreID
|
||||||
|
self.systemVersion = systemVersion
|
||||||
|
self.fetch = fetch ?? { AppUpdateChecker.fetchRelease(appStoreID: appStoreID, completion: $0) }
|
||||||
|
self.now = now
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
@@ -32,47 +46,175 @@ final class AppUpdateChecker: ObservableObject {
|
|||||||
func setEnabled(_ value: Bool) {
|
func setEnabled(_ value: Bool) {
|
||||||
guard enabled != value else { return }
|
guard enabled != value else { return }
|
||||||
enabled = value
|
enabled = value
|
||||||
if enabled { checkNow(); return }
|
if enabled { checkIfNeeded(); return }
|
||||||
generation = UUID()
|
generation = UUID()
|
||||||
cancelRequest?()
|
cancelRequest?()
|
||||||
cancelRequest = nil
|
cancelRequest = nil
|
||||||
if status == .checking {
|
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() {
|
func checkNow() {
|
||||||
guard enabled, status != .checking else { return }
|
guard enabled, status != .checking else { return }
|
||||||
|
statusBeforeCheck = status
|
||||||
status = .checking
|
status = .checking
|
||||||
let token = UUID()
|
let token = UUID()
|
||||||
generation = token
|
generation = token
|
||||||
cancelRequest = fetch { [weak self] data in
|
cancelRequest = fetch { [weak self] data in
|
||||||
guard let self, self.enabled, self.generation == token else { return }
|
guard let self, self.enabled, self.generation == token else { return }
|
||||||
self.cancelRequest = nil
|
self.cancelRequest = nil
|
||||||
if let data, let result = self.compare(self.currentVersion, data) {
|
// A cancelled request does not consume the automatic-check interval.
|
||||||
self.availableVersion = result
|
self.nextAutomaticCheck = self.now().addingTimeInterval(15 * 60)
|
||||||
self.status = self.updateAvailable ? .available : .upToDate
|
guard let data, let result = Self.parseRelease(data, bundleIdentifier: self.bundleIdentifier,
|
||||||
} else {
|
appStoreID: self.appStoreID) else {
|
||||||
self.status = .failed
|
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 func clearUpdate() {
|
||||||
private static func fetchRelease(completion: @escaping (Data?) -> Void) -> (() -> Void) {
|
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
|
let configuration = URLSessionConfiguration.ephemeral
|
||||||
configuration.timeoutIntervalForRequest = 10
|
configuration.timeoutIntervalForRequest = 10
|
||||||
configuration.timeoutIntervalForResource = 15
|
configuration.timeoutIntervalForResource = 15
|
||||||
let session = URLSession(configuration: configuration)
|
let session = URLSession(configuration: configuration)
|
||||||
let request = URLRequest(url: URL(string: "https://version.crossdesk.cn/version.json")!,
|
let task = Task { @MainActor in
|
||||||
cachePolicy: .reloadIgnoringLocalCacheData)
|
defer { session.finishTasksAndInvalidate() }
|
||||||
let task = session.dataTask(with: request) { data, response, error in
|
let storefront = await Storefront.current
|
||||||
let valid = error == nil && (response as? HTTPURLResponse)?.statusCode == 200 &&
|
guard !Task.isCancelled else { return }
|
||||||
(data?.count ?? 0) <= 256 * 1024
|
guard let country = lookupCountry(storefrontCountry: storefront?.countryCode,
|
||||||
DispatchQueue.main.async { completion(valid ? data : nil) }
|
fallbackRegion: Locale.current.region?.identifier),
|
||||||
session.finishTasksAndInvalidate()
|
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() }
|
return { task.cancel(); session.invalidateAndCancel() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,10 +155,6 @@ typedef struct {
|
|||||||
releaseJSON:(NSData *)releaseJSON
|
releaseJSON:(NSData *)releaseJSON
|
||||||
NS_SWIFT_NAME(availableUpdate(appVersion: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
|
- (void)configureWithSignalHost:(NSString *)host
|
||||||
signalPort:(NSInteger)signalPort;
|
signalPort:(NSInteger)signalPort;
|
||||||
- (void)setVideoAdaptationPolicy:(CrossDeskVideoAdaptationPolicy)policy;
|
- (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;
|
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 {
|
- (instancetype)init {
|
||||||
self = [super init];
|
self = [super init];
|
||||||
if (self) {
|
if (self) {
|
||||||
|
|||||||
@@ -20,6 +20,8 @@
|
|||||||
<string>$(MARKETING_VERSION)</string>
|
<string>$(MARKETING_VERSION)</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
|
<key>CrossDeskAppStoreID</key>
|
||||||
|
<string>6828859211</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||||
|
|||||||
@@ -733,7 +733,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
|
|||||||
connectionStatus = NSLocalizedString("未连接", comment: "")
|
connectionStatus = NSLocalizedString("未连接", comment: "")
|
||||||
audioPlayer.endSession()
|
audioPlayer.endSession()
|
||||||
AppOrientation.endSession()
|
AppOrientation.endSession()
|
||||||
appUpdates.checkNow()
|
appUpdates.checkIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
func retry() {
|
func retry() {
|
||||||
|
|||||||
@@ -281,3 +281,7 @@
|
|||||||
"鼠标左键" = "Linke Maustaste";
|
"鼠标左键" = "Linke Maustaste";
|
||||||
"鼠标控制" = "Maussteuerung";
|
"鼠标控制" = "Maussteuerung";
|
||||||
"鼠标滚轮" = "Mausrad";
|
"鼠标滚轮" = "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";
|
"鼠标左键" = "Left mouse button";
|
||||||
"鼠标控制" = "Mouse control";
|
"鼠标控制" = "Mouse control";
|
||||||
"鼠标滚轮" = "Mouse wheel";
|
"鼠标滚轮" = "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";
|
"鼠标左键" = "Botón izquierdo del ratón";
|
||||||
"鼠标控制" = "Control del ratón";
|
"鼠标控制" = "Control del ratón";
|
||||||
"鼠标滚轮" = "Rueda 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";
|
"鼠标左键" = "Bouton gauche de la souris";
|
||||||
"鼠标控制" = "Contrôle de la souris";
|
"鼠标控制" = "Contrôle de la souris";
|
||||||
"鼠标滚轮" = "Molette 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";
|
"鼠标左键" = "Botão esquerdo do mouse";
|
||||||
"鼠标控制" = "Controle do mouse";
|
"鼠标控制" = "Controle do mouse";
|
||||||
"鼠标滚轮" = "Roda 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";
|
"鼠标左键" = "Botão esquerdo do mouse";
|
||||||
"鼠标控制" = "Controle do mouse";
|
"鼠标控制" = "Controle do mouse";
|
||||||
"鼠标滚轮" = "Roda 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 {
|
List {
|
||||||
Section {
|
Section {
|
||||||
LabeledContent(NSLocalizedString("版本", comment: ""), value: appUpdates.currentVersion)
|
LabeledContent(NSLocalizedString("版本", comment: ""), value: appUpdates.currentVersion)
|
||||||
if appUpdates.updateAvailable {
|
if appUpdates.updateAvailable, let appStoreURL = appUpdates.appStoreURL {
|
||||||
Link(destination: URL(string: "https://crossdesk.cn")!) {
|
Link(destination: appStoreURL) {
|
||||||
HStack {
|
HStack {
|
||||||
UpdateDot()
|
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()
|
Spacer()
|
||||||
Image(systemName: "arrow.up.right")
|
Image(systemName: "arrow.up.right")
|
||||||
}
|
}
|
||||||
@@ -98,7 +102,12 @@ struct ThirdPartyLicensesView: View {
|
|||||||
} else if appUpdates.status == .checking {
|
} else if appUpdates.status == .checking {
|
||||||
Text(NSLocalizedString("正在检查更新…", comment: "")).foregroundStyle(.secondary)
|
Text(NSLocalizedString("正在检查更新…", comment: "")).foregroundStyle(.secondary)
|
||||||
} else if appUpdates.status == .upToDate {
|
} 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 {
|
} else if appUpdates.status == .failed {
|
||||||
Text(NSLocalizedString("检查失败,请稍后重试", comment: "")).foregroundStyle(.red)
|
Text(NSLocalizedString("检查失败,请稍后重试", comment: "")).foregroundStyle(.red)
|
||||||
}
|
}
|
||||||
@@ -143,7 +152,7 @@ struct ThirdPartyLicensesView: View {
|
|||||||
.navigationTitle(NSLocalizedString("关于 CrossDesk", comment: ""))
|
.navigationTitle(NSLocalizedString("关于 CrossDesk", comment: ""))
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.onValueChange(of: session.hasNetworkConsent) { allowed in
|
.onValueChange(of: session.hasNetworkConsent) { allowed in
|
||||||
if allowed { appUpdates.checkNow() }
|
if allowed { appUpdates.checkIfNeeded() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-14
@@ -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
|
selection unchanged. After uploading the ZIP, the workflow merges its server
|
||||||
URL, version and release date into `downloads.ios-arm64` in the shared
|
URL, version and release date into `downloads.ios-arm64` in the shared
|
||||||
`https://version.crossdesk.cn/version.json`.
|
`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.
|
Top-level desktop version information and other platforms' entries are retained.
|
||||||
Uploads preserve other platforms' files and use the existing `SERVER_HOST`,
|
Uploads preserve other platforms' files and use the existing `SERVER_HOST`,
|
||||||
`SERVER_USER` and `SERVER_KEY` repository secrets. Regular desktop version tags
|
`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
|
## App updates
|
||||||
|
|
||||||
After network consent, the app checks the public version manifest once on each
|
After network consent, the app queries Apple's public Lookup API on launch,
|
||||||
launch or return from the background, and after a remote connection ends while
|
on return from the background, and after a remote connection ends while active.
|
||||||
active. Concurrent triggers share the pending request. There are no periodic
|
Successful results are reused in memory for six hours; failed checks defer the
|
||||||
checks or automatic retries; failures retain any known update until the next
|
next automatic attempt for 15 minutes. There is no timer or automatic retry.
|
||||||
trigger or manual check. Backgrounding or withdrawing consent cancels requests
|
**检查更新** bypasses this cooldown. Concurrent triggers share the pending
|
||||||
and ignores stale replies.
|
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
|
`CrossDeskAppStoreID` in `CrossDeskMobile/Info.plist` identifies the App Store
|
||||||
and patch comparison as desktop. Build dates alone do not indicate an update;
|
listing (`6828859211`). Forks must set their own App ID and bundle identifier;
|
||||||
missing platform metadata is a failed check. New releases show a red badge with
|
responses are accepted only when both identities match. Lookup uses the current
|
||||||
a white exclamation mark at the top-right edge of the home Settings button,
|
StoreKit storefront, converting its three-letter country code to two letters.
|
||||||
matching the announcement badge's size and position.
|
When StoreKit has no storefront, the device's region is used; an unknown region
|
||||||
Settings → About shows “新版本” and a red dot. About displays the new version,
|
fails the check instead of silently querying the US store.
|
||||||
links to the official download page, and offers **检查更新** for manual checks.
|
|
||||||
This is independent of the remote desktop's upgrade notice.
|
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
|
## Announcements
|
||||||
|
|
||||||
|
|||||||
@@ -15,12 +15,6 @@ class AppUpdateCheckerTest(unittest.TestCase):
|
|||||||
harness.write_text(r'''
|
harness.write_text(r'''
|
||||||
import Foundation
|
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) {
|
func expect(_ condition: @autoclosure () -> Bool, _ message: String) {
|
||||||
if !condition() { fatalError(message) }
|
if !condition() { fatalError(message) }
|
||||||
}
|
}
|
||||||
@@ -28,11 +22,21 @@ func expect(_ condition: @autoclosure () -> Bool, _ message: String) {
|
|||||||
final class Fixture {
|
final class Fixture {
|
||||||
var replies: [(Data?) -> Void] = []
|
var replies: [(Data?) -> Void] = []
|
||||||
var cancellations = 0
|
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)
|
self.replies.append(completion)
|
||||||
return { self.cancellations += 1 }
|
return { self.cancellations += 1 }
|
||||||
})
|
}, now: { self.time })
|
||||||
func reply(_ value: String?) { replies.last!(value.map { Data($0.utf8) }) }
|
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
|
@main
|
||||||
enum Tests {
|
enum Tests {
|
||||||
@@ -49,18 +53,19 @@ enum Tests {
|
|||||||
f.checker.setEnabled(true)
|
f.checker.setEnabled(true)
|
||||||
expect(f.replies.count == 1, "Duplicate active callbacks do not recheck after completion")
|
expect(f.replies.count == 1, "Duplicate active callbacks do not recheck after completion")
|
||||||
f.checker.setEnabled(false)
|
f.checker.setEnabled(false)
|
||||||
|
f.time.addTimeInterval(6 * 60 * 60)
|
||||||
f.checker.setEnabled(true)
|
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)
|
f.reply(nil)
|
||||||
expect(f.checker.status == .failed && f.checker.updateAvailable,
|
expect(f.checker.status == .failed && f.checker.updateAvailable,
|
||||||
"Failed checks retain discovered update")
|
"Failed checks retain discovered update")
|
||||||
f.checker.setEnabled(true)
|
f.checker.setEnabled(true)
|
||||||
expect(f.replies.count == 2, "A failure does not restart the check")
|
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()
|
||||||
f.checker.checkNow()
|
f.checker.checkNow()
|
||||||
expect(f.replies.count == 3, "Explicit events check again without a cooldown and deduplicate")
|
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,
|
expect(!f.checker.updateAvailable && f.checker.status == .upToDate,
|
||||||
"Successful current-version check clears badge")
|
"Successful current-version check clears badge")
|
||||||
f.checker.checkNow()
|
f.checker.checkNow()
|
||||||
@@ -69,6 +74,7 @@ enum Tests {
|
|||||||
expect(f.cancellations == 1, "Background/revocation cancels request")
|
expect(f.cancellations == 1, "Background/revocation cancels request")
|
||||||
stale(Data("9.0.0".utf8))
|
stale(Data("9.0.0".utf8))
|
||||||
expect(!f.checker.updateAvailable, "Ignore cancelled response")
|
expect(!f.checker.updateAvailable, "Ignore cancelled response")
|
||||||
|
f.time.addTimeInterval(6 * 60 * 60)
|
||||||
f.checker.setEnabled(true)
|
f.checker.setEnabled(true)
|
||||||
stale(Data("9.0.0".utf8))
|
stale(Data("9.0.0".utf8))
|
||||||
expect(f.checker.status == .checking, "Ignore old response during new request")
|
expect(f.checker.status == .checking, "Ignore old response during new request")
|
||||||
@@ -77,6 +83,7 @@ enum Tests {
|
|||||||
let beforeForeground = f.replies.count
|
let beforeForeground = f.replies.count
|
||||||
f.checker.checkNow()
|
f.checker.checkNow()
|
||||||
expect(f.replies.count == beforeForeground, "Session endings in background do not check")
|
expect(f.replies.count == beforeForeground, "Session endings in background do not check")
|
||||||
|
f.time.addTimeInterval(6 * 60 * 60)
|
||||||
f.checker.setEnabled(true)
|
f.checker.setEnabled(true)
|
||||||
expect(f.replies.count == beforeForeground + 1, "Check once on return from background")
|
expect(f.replies.count == beforeForeground + 1, "Check once on return from background")
|
||||||
expect(f.checker.updateAvailable, "Retain badge while rechecking")
|
expect(f.checker.updateAvailable, "Retain badge while rechecking")
|
||||||
|
|||||||
@@ -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.",
|
"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.",
|
"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."
|
"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": {
|
"desktop": {
|
||||||
@@ -12479,7 +12527,11 @@
|
|||||||
"鼠标右键",
|
"鼠标右键",
|
||||||
"鼠标左键",
|
"鼠标左键",
|
||||||
"鼠标控制",
|
"鼠标控制",
|
||||||
"鼠标滚轮"
|
"鼠标滚轮",
|
||||||
|
"前往 App Store 更新",
|
||||||
|
"App Store 暂无可用更新",
|
||||||
|
"暂时无法在当前地区的 App Store 查询到此应用",
|
||||||
|
"新版本需要 iOS %1$@ 或更高版本"
|
||||||
],
|
],
|
||||||
"harmony": [
|
"harmony": [
|
||||||
"OpenFEC 告知",
|
"OpenFEC 告知",
|
||||||
|
|||||||
Reference in New Issue
Block a user