mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-31 02:49:35 +08:00
[feat] add mobile update badges and iOS simulator support
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user