[feat] add mobile update badges and iOS simulator support

This commit is contained in:
dijunkun
2026-10-04 01:38:08 +08:00
parent 115533cd62
commit b8f704abb9
25 changed files with 766 additions and 61 deletions
@@ -0,0 +1,78 @@
import Foundation
import Combine
/// Main-thread local-app checks triggered by visits, session endings and manual requests.
final class AppUpdateChecker: ObservableObject {
enum Status { case idle, checking, upToDate, available, 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 }
let currentVersion: String
private let fetch: Fetch
private let compare: (String, Data) -> String?
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) {
self.currentVersion = currentVersion
self.fetch = fetch
self.compare = compare
}
deinit {
cancelRequest?()
}
func setEnabled(_ value: Bool) {
guard enabled != value else { return }
enabled = value
if enabled { checkNow(); return }
generation = UUID()
cancelRequest?()
cancelRequest = nil
if status == .checking {
status = updateAvailable ? .available : .idle
}
}
func checkNow() {
guard enabled, status != .checking else { return }
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 {
self.status = .failed
}
}
}
// Public metadata only: no identity, installed version or credentials sent.
private static func fetchRelease(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()
}
task.resume()
return { task.cancel(); session.invalidateAndCancel() }
}
}
@@ -142,6 +142,10 @@ 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,6 +508,16 @@ Params 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) {
+30 -4
View File
@@ -41,6 +41,7 @@ struct ContentView: View {
}
}
.animation(.easeInOut(duration: 0.2), value: session.sessionVisible)
.environmentObject(session.appUpdates)
.preferredColorScheme(session.sessionVisible ? .dark : .light)
.sheet(isPresented: Binding(
get: { session.privacyNoticeVisible },
@@ -158,6 +159,7 @@ private struct KeyboardDismissTapView: UIViewRepresentable {
}
private struct ConnectionHomeView: View {
@EnvironmentObject private var appUpdates: AppUpdateChecker
@ObservedObject var session: RemoteSessionModel
@FocusState private var remoteIDFocused: Bool
@State private var passwordPromptVisible = false
@@ -187,6 +189,7 @@ private struct ConnectionHomeView: View {
private var signalHasError: Bool {
session.signalStatus.contains("失败") ||
session.signalStatus.contains("无法") ||
session.signalStatus.contains("无效") ||
session.signalStatus.contains("关闭") ||
session.signalStatus.contains("未知")
@@ -318,10 +321,21 @@ private struct ConnectionHomeView: View {
.background(Color(.secondarySystemGroupedBackground),
in: RoundedRectangle(cornerRadius: 10,
style: .continuous))
.overlay(alignment: .topTrailing) {
if appUpdates.updateAvailable {
Text("!")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(.white)
.frame(width: 16, height: 16)
.background(.red, in: Circle())
.offset(x: 5, y: -3)
.accessibilityHidden(true)
}
}
}
.foregroundStyle(.primary)
.disabled(session.isConnecting)
.accessibilityLabel("设置")
.accessibilityLabel(appUpdates.updateAvailable ? "设置,有新版本" : "设置")
}
}
@@ -337,7 +351,8 @@ private struct ConnectionHomeView: View {
}
.foregroundStyle(signalTint)
.padding(.horizontal, 11)
.frame(height: 34)
// Leave room for both toolbar actions even when the status is an error.
.frame(maxWidth: 160, minHeight: 34, maxHeight: 34, alignment: .leading)
.background(signalTint.opacity(0.11), in: Capsule())
.overlay {
Capsule()
@@ -752,6 +767,7 @@ private struct PasswordPromptView: View {
}
private struct ServerSettingsView: View {
@EnvironmentObject private var appUpdates: AppUpdateChecker
@ObservedObject var session: RemoteSessionModel
@State private var serverConfigurationExpanded = false
@State private var usesCustomServer: Bool
@@ -855,9 +871,19 @@ private struct ServerSettingsView: View {
}
PrivacySettingsSection(session: session)
Section {
NavigationLink("关于") {
ThirdPartyLicensesView()
NavigationLink {
ThirdPartyLicensesView(session: session)
} label: {
HStack {
Text("关于")
Spacer()
if appUpdates.updateAvailable {
Text("新版本").foregroundStyle(.secondary)
UpdateDot()
}
}
}
.accessibilityLabel(appUpdates.updateAvailable ? "关于,有新版本" : "关于")
}
}
.navigationTitle("设置")
@@ -339,6 +339,8 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
let bridge = CrossDeskRTCBridge()
let announcements = AnnouncementInbox()
let appUpdates = AppUpdateChecker()
private var appIsActive = false
private let audioPlayer = RemoteAudioPlayer()
private var activeRemoteID = ""
private var remoteAppVersion = ""
@@ -403,6 +405,8 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
var privacyNoticeVisible: Bool { privacyNotice.isVisible }
func applicationDidBecomeActive() {
appIsActive = true
appUpdates.setEnabled(hasNetworkConsent)
privacyNotice.didBecomeActive(hasNetworkConsent: hasNetworkConsent)
refreshRecentConnectionPresenceAfterForeground()
refreshAnnouncements()
@@ -410,6 +414,8 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
}
func applicationDidEnterBackground() {
appIsActive = false
appUpdates.setEnabled(false)
privacyNotice.didEnterBackground()
suspendVideoForBackground()
suspendPresenceMonitoring()
@@ -426,6 +432,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
func acceptNetworkConsent() {
privacyPreferences.setNetworkConsent(true)
hasNetworkConsent = true
appUpdates.setEnabled(appIsActive)
privacyNotice.dismiss()
configureBridge()
}
@@ -438,6 +445,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
func revokeNetworkConsent() {
privacyPreferences.setNetworkConsent(false)
hasNetworkConsent = false
appUpdates.setEnabled(false)
signalConnected = false
stopPresenceMonitoring()
invalidatePresence()
@@ -669,6 +677,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
connectionStatus = "未连接"
audioPlayer.setEnabled(false)
AppOrientation.update(to: .portrait)
appUpdates.checkNow()
}
func retry() {
@@ -77,28 +77,52 @@ struct SourceMetadata: Decodable {
}
struct ThirdPartyLicensesView: View {
@ObservedObject var session: RemoteSessionModel
@EnvironmentObject private var appUpdates: AppUpdateChecker
var body: some View {
Group {
List {
Section {
LabeledContent("版本", value: appUpdates.currentVersion)
if appUpdates.updateAvailable {
Link(destination: URL(string: "https://crossdesk.cn")!) {
HStack {
UpdateDot()
Text("新版本可用:v\(appUpdates.availableVersion)")
Spacer()
Image(systemName: "arrow.up.right")
}
}
}
if !session.hasNetworkConsent {
Text("完成隐私授权后可检查更新").foregroundStyle(.secondary)
} else if appUpdates.status == .checking {
Text("正在检查更新…").foregroundStyle(.secondary)
} else if appUpdates.status == .upToDate {
Text("当前已是最新版本").foregroundStyle(.secondary)
} else if appUpdates.status == .failed {
Text("检查失败,请稍后重试").foregroundStyle(.red)
}
Button("检查更新") {
if session.hasNetworkConsent { appUpdates.checkNow() }
else { session.showPrivacyNotice() }
}
.disabled(appUpdates.status == .checking)
}
switch LicenseCatalog.bundled {
case .success(let catalog):
List {
if let application = catalog.components.first(where: { $0.id == "crossdesk" }) {
Section {
LabeledContent("版本", value: application.displayVersion)
if let application = catalog.components.first(where: { $0.id == "crossdesk" }) {
Section {
NavigationLink("软件许可") {
ComponentLicenseView(component: application)
}
Section {
NavigationLink("软件许可") {
ComponentLicenseView(component: application)
}
NavigationLink("开源组件") {
OpenSourceComponentsView(catalog: catalog)
}
NavigationLink("源码与构建说明") {
SourceCodeView(application: application)
}
} footer: {
Text("许可与版权声明可离线查阅。")
NavigationLink("开源组件") {
OpenSourceComponentsView(catalog: catalog)
}
NavigationLink("源码与构建说明") {
SourceCodeView(application: application)
}
} footer: {
Text("许可与版权声明可离线查阅。")
}
}
case .failure:
@@ -116,6 +140,18 @@ struct ThirdPartyLicensesView: View {
}
.navigationTitle("关于 CrossDesk")
.navigationBarTitleDisplayMode(.inline)
.onChange(of: session.hasNetworkConsent) { allowed in
if allowed { appUpdates.checkNow() }
}
}
}
struct UpdateDot: View {
var body: some View {
Circle()
.fill(.red)
.frame(width: 8, height: 8)
.accessibilityHidden(true)
}
}