mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-21 03:40:40 +08:00
[feat] add mobile update badges and iOS simulator support
This commit is contained in:
@@ -30,6 +30,7 @@
|
||||
100000000000000000000019 /* AnnouncementInbox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000019 /* AnnouncementInbox.swift */; };
|
||||
10000000000000000000001A /* AnnouncementText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000001A /* AnnouncementText.swift */; };
|
||||
10000000000000000000001B /* AnnouncementInboxView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000001B /* AnnouncementInboxView.swift */; };
|
||||
10000000000000000000001C /* AppUpdateChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000001C /* AppUpdateChecker.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
@@ -60,6 +61,7 @@
|
||||
200000000000000000000019 /* AnnouncementInbox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementInbox.swift; sourceTree = "<group>"; };
|
||||
20000000000000000000001A /* AnnouncementText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementText.swift; sourceTree = "<group>"; };
|
||||
20000000000000000000001B /* AnnouncementInboxView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementInboxView.swift; sourceTree = "<group>"; };
|
||||
20000000000000000000001C /* AppUpdateChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdateChecker.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
@@ -107,6 +109,7 @@
|
||||
200000000000000000000019 /* AnnouncementInbox.swift */,
|
||||
20000000000000000000001A /* AnnouncementText.swift */,
|
||||
20000000000000000000001B /* AnnouncementInboxView.swift */,
|
||||
20000000000000000000001C /* AppUpdateChecker.swift */,
|
||||
200000000000000000000018 /* RecentConnection.swift */,
|
||||
200000000000000000000016 /* RemoteVideoSettings.swift */,
|
||||
200000000000000000000017 /* RemoteNetworkStatistics.swift */,
|
||||
@@ -252,7 +255,7 @@
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(PROJECT_DIR)/Vendor/iphoneos/$(CONFIGURATION)/libCrossDeskMiniRTC.a",
|
||||
"$(PROJECT_DIR)/Vendor/$(PLATFORM_NAME)/$(CONFIGURATION)/libCrossDeskMiniRTC.a",
|
||||
"$(BUILT_PRODUCTS_DIR)/OpenSSL.framework/OpenSSL",
|
||||
"$(BUILT_PRODUCTS_DIR)/OpenSSL.framework/Info.plist",
|
||||
"$(BUILT_PRODUCTS_DIR)/OpenSSL.framework/PrivacyInfo.xcprivacy",
|
||||
@@ -262,7 +265,7 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/zsh;
|
||||
shellScript = "set -e\npython3 \"${PROJECT_DIR}/scripts/generate_third_party_licenses.py\"\npython3 \"${PROJECT_DIR}/scripts/prepare_source_metadata.py\" --output \"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SourceMetadata.json\"\n\"${PROJECT_DIR}/scripts/build_minirtc_ios.sh\"\npython3 \"${PROJECT_DIR}/scripts/generate_third_party_licenses.py\" --inputs \"${PROJECT_DIR}/Vendor/iphoneos/${CONFIGURATION}/libCrossDeskMiniRTC.a.inputs.sha256\" --target-info \"${PROJECT_DIR}/Vendor/iphoneos/${CONFIGURATION}/minirtc-target-info.txt\" --xmake-version \"${PROJECT_DIR}/Vendor/iphoneos/${CONFIGURATION}/xmake-version.txt\" --xmake-repository \"$(cat \"${PROJECT_DIR}/Vendor/iphoneos/${CONFIGURATION}/xmake-repository.txt\")\"\npython3 \"${PROJECT_DIR}/scripts/package_openssl_ios.py\" --inputs \"${PROJECT_DIR}/Vendor/iphoneos/${CONFIGURATION}/libCrossDeskMiniRTC.a.inputs.sha256\" --output \"${BUILT_PRODUCTS_DIR}/OpenSSL.framework\"\n";
|
||||
shellScript = "set -e\npython3 \"${PROJECT_DIR}/scripts/generate_third_party_licenses.py\"\npython3 \"${PROJECT_DIR}/scripts/prepare_source_metadata.py\" --output \"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SourceMetadata.json\"\n\"${PROJECT_DIR}/scripts/build_minirtc_ios.sh\"\npython3 \"${PROJECT_DIR}/scripts/generate_third_party_licenses.py\" --inputs \"${PROJECT_DIR}/Vendor/${PLATFORM_NAME}/${CONFIGURATION}/libCrossDeskMiniRTC.a.inputs.sha256\" --target-info \"${PROJECT_DIR}/Vendor/${PLATFORM_NAME}/${CONFIGURATION}/minirtc-target-info.txt\" --xmake-version \"${PROJECT_DIR}/Vendor/${PLATFORM_NAME}/${CONFIGURATION}/xmake-version.txt\" --xmake-repository \"$(cat \"${PROJECT_DIR}/Vendor/${PLATFORM_NAME}/${CONFIGURATION}/xmake-repository.txt\")\"\npython3 \"${PROJECT_DIR}/scripts/package_openssl_ios.py\" --inputs \"${PROJECT_DIR}/Vendor/${PLATFORM_NAME}/${CONFIGURATION}/libCrossDeskMiniRTC.a.inputs.sha256\" --output \"${BUILT_PRODUCTS_DIR}/OpenSSL.framework\" --platform \"${PLATFORM_NAME}\"\n";
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
@@ -276,6 +279,7 @@
|
||||
100000000000000000000019 /* AnnouncementInbox.swift in Sources */,
|
||||
10000000000000000000001A /* AnnouncementText.swift in Sources */,
|
||||
10000000000000000000001B /* AnnouncementInboxView.swift in Sources */,
|
||||
10000000000000000000001C /* AppUpdateChecker.swift in Sources */,
|
||||
100000000000000000000018 /* RecentConnection.swift in Sources */,
|
||||
100000000000000000000016 /* RemoteVideoSettings.swift in Sources */,
|
||||
100000000000000000000017 /* RemoteNetworkStatistics.swift in Sources */,
|
||||
@@ -357,7 +361,7 @@
|
||||
MARKETING_VERSION = 0.0.1;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"$(PROJECT_DIR)/Vendor/iphoneos/$(CONFIGURATION)/libCrossDeskMiniRTC.a",
|
||||
"$(PROJECT_DIR)/Vendor/$(PLATFORM_NAME)/$(CONFIGURATION)/libCrossDeskMiniRTC.a",
|
||||
"-liconv",
|
||||
"-lresolv",
|
||||
"-framework",
|
||||
@@ -377,7 +381,7 @@
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = cn.crossdesk.mobile;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "CrossDeskMobile/CrossDeskMobile-Bridging-Header.h";
|
||||
SWIFT_STRICT_CONCURRENCY = minimal;
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -407,7 +411,7 @@
|
||||
MARKETING_VERSION = 0.0.1;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"$(PROJECT_DIR)/Vendor/iphoneos/$(CONFIGURATION)/libCrossDeskMiniRTC.a",
|
||||
"$(PROJECT_DIR)/Vendor/$(PLATFORM_NAME)/$(CONFIGURATION)/libCrossDeskMiniRTC.a",
|
||||
"-liconv",
|
||||
"-lresolv",
|
||||
"-framework",
|
||||
@@ -427,7 +431,7 @@
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = cn.crossdesk.mobile;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "CrossDeskMobile/CrossDeskMobile-Bridging-Header.h";
|
||||
SWIFT_STRICT_CONCURRENCY = minimal;
|
||||
SWIFT_VERSION = 5.0;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+40
-3
@@ -8,7 +8,7 @@ There is no `WKWebView` or browser runtime.
|
||||
|
||||
- Xcode 16 or newer
|
||||
- Xmake 3.1.1 available on `PATH` (or selected with `XMAKE_BIN`)
|
||||
- A physical arm64 iPhone or iPad running iOS 16 or newer
|
||||
- A physical arm64 iPhone/iPad running iOS 16 or newer, or an iOS simulator on Apple Silicon
|
||||
|
||||
## Build
|
||||
|
||||
@@ -57,8 +57,8 @@ package downloads disabled.
|
||||
The first build phase builds MiniRTC and the CrossDesk wire library, downloads
|
||||
and builds their dependencies, then merges the iPhoneOS archives into a local library under
|
||||
`Vendor/`. OpenSSL is kept separate and packaged as a static framework with SDK
|
||||
privacy resources in the Xcode products directory. This build path targets physical devices; simulator builds are
|
||||
not supported. The exact tool versions used by CI are recorded in the
|
||||
privacy resources in the Xcode products directory. Device and simulator builds
|
||||
use separate native archives and package caches. The exact tool versions used by CI are recorded in the
|
||||
[iOS workflow](../../.github/workflows/build-ios.yml).
|
||||
|
||||
You can also build the target without code signing. The resulting app, like
|
||||
@@ -72,6 +72,25 @@ xcodebuild -project apps/ios/CrossDeskMobile.xcodeproj \
|
||||
CODE_SIGNING_ALLOWED=NO build
|
||||
```
|
||||
|
||||
On an Apple Silicon Mac, select an iOS simulator in Xcode and run the same
|
||||
scheme. To build from the command line:
|
||||
|
||||
```sh
|
||||
xcodebuild -project apps/ios/CrossDeskMobile.xcodeproj \
|
||||
-scheme CrossDeskMobile -configuration Debug \
|
||||
-sdk iphonesimulator -destination 'generic/platform=iOS Simulator' \
|
||||
CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY=- build
|
||||
```
|
||||
|
||||
Keep signing enabled for simulator runs. Xcode uses a local ad-hoc signature
|
||||
and supplies the application identity required by Keychain. Disabling signing
|
||||
causes identity storage to fail with `-34018` before signaling can connect.
|
||||
|
||||
The first simulator build compiles its own MiniRTC and dependencies; later builds
|
||||
reuse `.xmake/packages-simulator` and `Vendor/iphonesimulator`. This runs the
|
||||
native application, including version checks and signaling. Validate hardware
|
||||
video behavior and performance separately on a physical device.
|
||||
|
||||
The first connection to a signaling server provisions and stores an identity
|
||||
for that server. Remote control sessions then log in as `C-<identity>` and use
|
||||
the desktop-compatible `DisplayN`, `control_audio`, `mouse`, `keyboard`,
|
||||
@@ -128,6 +147,24 @@ No Apple signing secrets are needed. Sign the app yourself before installing
|
||||
it on a device. Push any referenced MiniRTC commit before the parent repository
|
||||
so the runner can check out all corresponding sources.
|
||||
|
||||
## 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.
|
||||
|
||||
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.
|
||||
|
||||
## Announcements
|
||||
|
||||
The home toolbar's bell opens **通知公告** and shows the unread count (capped
|
||||
|
||||
@@ -17,7 +17,17 @@ fi
|
||||
# Keep iOS dependencies isolated from desktop Xmake packages. Besides avoiding
|
||||
# cross-project cache collisions, this guarantees every archive is compiled
|
||||
# with the iOS 16 deployment target instead of the active SDK version.
|
||||
export XMAKE_PKG_INSTALLDIR="${IOS_DIR}/.xmake/packages"
|
||||
SDK_NAME="${PLATFORM_NAME:-iphoneos}"
|
||||
APPLE_DEVICE="iphone"
|
||||
CACHE_SUFFIX=""
|
||||
if [[ "${SDK_NAME}" == "iphonesimulator" ]]; then
|
||||
APPLE_DEVICE="simulator"
|
||||
CACHE_SUFFIX="-simulator"
|
||||
elif [[ "${SDK_NAME}" != "iphoneos" ]]; then
|
||||
print -u2 "Unsupported iOS SDK: ${SDK_NAME}"
|
||||
exit 64
|
||||
fi
|
||||
export XMAKE_PKG_INSTALLDIR="${IOS_DIR}/.xmake/packages${CACHE_SUFFIX}"
|
||||
|
||||
if [[ "${MODE}" != "debug" && "${MODE}" != "release" ]]; then
|
||||
MODE="release"
|
||||
@@ -28,7 +38,7 @@ if [[ "${ARCH_NAME}" == "undefined_arch" ]]; then
|
||||
ARCH_NAME="arm64"
|
||||
fi
|
||||
if [[ "${ARCH_NAME}" != "arm64" ]]; then
|
||||
print -u2 "CrossDesk Mobile currently supports physical iOS arm64 builds only."
|
||||
print -u2 "CrossDesk Mobile supports arm64 devices and Apple Silicon simulators."
|
||||
exit 64
|
||||
fi
|
||||
|
||||
@@ -77,16 +87,16 @@ run_xmake() {
|
||||
"${XMAKE_BIN}" "$@"
|
||||
}
|
||||
|
||||
OUTPUT_DIR="${IOS_DIR}/Vendor/iphoneos/${CONFIG_NAME}"
|
||||
OUTPUT_DIR="${IOS_DIR}/Vendor/${SDK_NAME}/${CONFIG_NAME}"
|
||||
OUTPUT_LIBRARY="${OUTPUT_DIR}/libCrossDeskMiniRTC.a"
|
||||
XMAKE_REPOSITORY_DIR="$(run_xmake lua -c 'import("core.base.global"); print(path.join(global.directory(), "repositories", "xmake-repo"))')"
|
||||
mkdir -p "${OUTPUT_DIR}"
|
||||
print -r -- "${XMAKE_REPOSITORY_DIR}" > "${OUTPUT_DIR}/xmake-repository.txt"
|
||||
run_xmake --version > "${OUTPUT_DIR}/xmake-version.txt"
|
||||
MINIRTC_BUILD_DIR="${IOS_DIR}/.xmake/minirtc-build"
|
||||
MINIRTC_BUILD_DIR="${IOS_DIR}/.xmake/minirtc-build${CACHE_SUFFIX}"
|
||||
MINIRTC_LIBRARY="${MINIRTC_BUILD_DIR}/iphoneos/arm64/${MODE}/libminirtc.a"
|
||||
WIRE_BUILD_DIR="${IOS_DIR}/.xmake/wire-build"
|
||||
WIRE_WORK_DIR="${IOS_DIR}/.xmake/wire-work"
|
||||
WIRE_BUILD_DIR="${IOS_DIR}/.xmake/wire-build${CACHE_SUFFIX}"
|
||||
WIRE_WORK_DIR="${IOS_DIR}/.xmake/wire-work${CACHE_SUFFIX}"
|
||||
WIRE_MANIFEST_FILE="${WIRE_WORK_DIR}/xmake.lua"
|
||||
WIRE_LIBRARY="${WIRE_BUILD_DIR}/iphoneos/arm64/${MODE}/libcrossdesk_wire.a"
|
||||
|
||||
@@ -99,11 +109,11 @@ fi
|
||||
# Still configure on every invocation: Xmake must see manifest edits and restore
|
||||
# the iOS configuration if MiniRTC was built standalone for another platform.
|
||||
CONFIG_SIGNATURE="$(print -r -- "${XMAKE_BIN}" "${XMAKE_DEVELOPER_DIR}" \
|
||||
"${MODE}" "${ARCH_NAME}" "16.0" "${XMAKE_PKG_INSTALLDIR}" "${MINIRTC_ENABLE_AOM}"
|
||||
"${MODE}" "${ARCH_NAME}" "${SDK_NAME}" "${APPLE_DEVICE}" "16.0" "${XMAKE_PKG_INSTALLDIR}" "${MINIRTC_ENABLE_AOM}"
|
||||
run_xmake --version | sed -n '1p'
|
||||
DEVELOPER_DIR="${XMAKE_DEVELOPER_DIR}" xcodebuild -version
|
||||
DEVELOPER_DIR="${XMAKE_DEVELOPER_DIR}" xcrun --sdk iphoneos --show-sdk-path
|
||||
DEVELOPER_DIR="${XMAKE_DEVELOPER_DIR}" xcrun --sdk iphoneos --show-sdk-version)"
|
||||
DEVELOPER_DIR="${XMAKE_DEVELOPER_DIR}" xcrun --sdk "${SDK_NAME}" --show-sdk-path
|
||||
DEVELOPER_DIR="${XMAKE_DEVELOPER_DIR}" xcrun --sdk "${SDK_NAME}" --show-sdk-version)"
|
||||
|
||||
configure_xmake() {
|
||||
local project_dir="$1"
|
||||
@@ -115,7 +125,7 @@ configure_xmake() {
|
||||
clean_flags=(-c)
|
||||
fi
|
||||
run_xmake f -P "${project_dir}" "${clean_flags[@]}" -o "${build_dir}" \
|
||||
-p iphoneos -a arm64 -m "${MODE}" --target_minver=16.0 \
|
||||
-p iphoneos -a arm64 --appledev="${APPLE_DEVICE}" -m "${MODE}" --target_minver=16.0 \
|
||||
--policies=package.precompiled:n -y "$@"
|
||||
mkdir -p "${build_dir}"
|
||||
print -r -- "${CONFIG_SIGNATURE}" > "${stamp}"
|
||||
|
||||
@@ -168,7 +168,7 @@ def verify(lock, inputs, target_info, xmake_repository=None, xmake_version=None)
|
||||
seen = {}
|
||||
# Package names/versions come from resolved Xmake link and include paths,
|
||||
# never from an inventory of all packages left in the installation cache.
|
||||
pattern = r"/packages/[^/\s]+/([^/\s]+)/([^/\s]+)/[a-f0-9]+/"
|
||||
pattern = r"/packages(?:-simulator)?/[^/\s]+/([^/\s]+)/([^/\s]+)/[a-f0-9]+/"
|
||||
for name, version in re.findall(pattern, inputs.read_text() + target_info.read_text()):
|
||||
if name not in known or known[name] != version:
|
||||
raise ValueError(f"Unreviewed iOS dependency: {name} {version}; regenerate licenses from its source")
|
||||
|
||||
@@ -22,7 +22,7 @@ FORBIDDEN_APIS = {
|
||||
}
|
||||
|
||||
|
||||
def package(inputs, output):
|
||||
def package(inputs, output, platform="iphoneos"):
|
||||
catalog = json.loads((IOS / "CrossDeskMobile/Resources/ThirdPartyLicenses.json").read_text())
|
||||
component = next(c for c in catalog["components"] if c["id"] == "openssl3")
|
||||
archives = {}
|
||||
@@ -71,7 +71,7 @@ def package(inputs, output):
|
||||
"CFBundleInfoDictionaryVersion": "6.0",
|
||||
"CFBundleVersion": component["version"],
|
||||
"CFBundleShortVersionString": component["version"],
|
||||
"CFBundleSupportedPlatforms": ["iPhoneOS"],
|
||||
"CFBundleSupportedPlatforms": ["iPhoneSimulator" if platform == "iphonesimulator" else "iPhoneOS"],
|
||||
"MinimumOSVersion": "16.0",
|
||||
}),
|
||||
"LICENSE.txt": "\n\n".join(d["text"] for d in component["documents"]).encode(),
|
||||
@@ -94,8 +94,9 @@ def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--inputs", required=True, type=Path)
|
||||
parser.add_argument("--output", required=True, type=Path)
|
||||
parser.add_argument("--platform", choices=("iphoneos", "iphonesimulator"), default="iphoneos")
|
||||
args = parser.parse_args()
|
||||
package(args.inputs, args.output)
|
||||
package(args.inputs, args.output, args.platform)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Run the production event-driven update checker with a deterministic transport."""
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
@unittest.skipUnless(sys.platform == "darwin", "requires the Apple Swift runtime")
|
||||
class AppUpdateCheckerTest(unittest.TestCase):
|
||||
def test_visit_session_end_failures_and_lifecycle(self):
|
||||
source = Path(__file__).resolve().parents[1] / "CrossDeskMobile/AppUpdateChecker.swift"
|
||||
with tempfile.TemporaryDirectory(prefix="crossdesk-app-update-tests-") as directory:
|
||||
harness = Path(directory) / "Tests.swift"
|
||||
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) }
|
||||
}
|
||||
@MainActor
|
||||
final class Fixture {
|
||||
var replies: [(Data?) -> Void] = []
|
||||
var cancellations = 0
|
||||
lazy var checker = AppUpdateChecker(currentVersion: "0.0.1", fetch: { completion in
|
||||
self.replies.append(completion)
|
||||
return { self.cancellations += 1 }
|
||||
})
|
||||
func reply(_ value: String?) { replies.last!(value.map { Data($0.utf8) }) }
|
||||
}
|
||||
@main
|
||||
enum Tests {
|
||||
@MainActor static func main() {
|
||||
let f = Fixture()
|
||||
f.checker.checkNow()
|
||||
expect(f.replies.isEmpty, "No checks without consent and foreground")
|
||||
f.checker.setEnabled(true)
|
||||
f.checker.checkNow()
|
||||
f.checker.setEnabled(true)
|
||||
expect(f.replies.count == 1, "Deduplicate concurrent checks")
|
||||
f.reply("0.0.2")
|
||||
expect(f.checker.updateAvailable && f.checker.status == .available, "Show badge")
|
||||
f.checker.setEnabled(true)
|
||||
expect(f.replies.count == 1, "Duplicate active callbacks do not recheck after completion")
|
||||
f.checker.setEnabled(false)
|
||||
f.checker.setEnabled(true)
|
||||
expect(f.replies.count == 2, "Every new visit checks even immediately after success")
|
||||
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.
|
||||
f.checker.checkNow()
|
||||
f.checker.checkNow()
|
||||
expect(f.replies.count == 3, "Explicit events check again without a cooldown and deduplicate")
|
||||
f.reply("")
|
||||
expect(!f.checker.updateAvailable && f.checker.status == .upToDate,
|
||||
"Successful current-version check clears badge")
|
||||
f.checker.checkNow()
|
||||
let stale = f.replies.last!
|
||||
f.checker.setEnabled(false)
|
||||
expect(f.cancellations == 1, "Background/revocation cancels request")
|
||||
stale(Data("9.0.0".utf8))
|
||||
expect(!f.checker.updateAvailable, "Ignore cancelled response")
|
||||
f.checker.setEnabled(true)
|
||||
stale(Data("9.0.0".utf8))
|
||||
expect(f.checker.status == .checking, "Ignore old response during new request")
|
||||
f.reply("0.0.2")
|
||||
f.checker.setEnabled(false)
|
||||
let beforeForeground = f.replies.count
|
||||
f.checker.checkNow()
|
||||
expect(f.replies.count == beforeForeground, "Session endings in background do not check")
|
||||
f.checker.setEnabled(true)
|
||||
expect(f.replies.count == beforeForeground + 1, "Check once on return from background")
|
||||
expect(f.checker.updateAvailable, "Retain badge while rechecking")
|
||||
f.checker.setEnabled(false)
|
||||
}
|
||||
}
|
||||
''')
|
||||
binary = Path(directory) / "app-update-tests"
|
||||
subprocess.run(["xcrun", "swiftc", str(source), str(harness), "-o", str(binary)], check=True)
|
||||
subprocess.run([str(binary)], check=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -133,6 +133,24 @@ elif args[0] == "show":
|
||||
self.run_build()
|
||||
self.assertTrue(all("-c" in args for args in self.config_calls()))
|
||||
|
||||
def test_simulator_uses_separate_archives_and_switches_back_to_device(self):
|
||||
self.run_build()
|
||||
device_archive = self.output.read_bytes()
|
||||
self.env["PLATFORM_NAME"] = "iphonesimulator"
|
||||
self.run_build()
|
||||
simulator = self.ios / "Vendor/iphonesimulator/Release/libCrossDeskMiniRTC.a"
|
||||
self.assertTrue(simulator.exists())
|
||||
self.assertTrue((self.ios / ".xmake/wire-work-simulator/xmake.lua").exists())
|
||||
signature = self.ios / ".xmake/minirtc-build-simulator/ios-config-signature"
|
||||
self.assertIn("packages-simulator", signature.read_text())
|
||||
for args in self.config_calls()[-2:]:
|
||||
self.assertIn("--appledev=simulator", args)
|
||||
self.assertIn("-simulator", args[args.index("-o") + 1])
|
||||
self.env["PLATFORM_NAME"] = "iphoneos"
|
||||
self.run_build()
|
||||
self.assertEqual(self.output.read_bytes(), device_archive)
|
||||
self.assertTrue(all("--appledev=iphone" in args for args in self.config_calls()[-2:]))
|
||||
|
||||
def test_aom_is_only_required_and_merged_when_enabled(self):
|
||||
self.run_build()
|
||||
manifest = Path(str(self.output) + ".inputs.sha256")
|
||||
|
||||
@@ -129,6 +129,21 @@ class LicenseValidationTest(unittest.TestCase):
|
||||
with self.assertRaisesRegex(ValueError, "License/build dependency mismatch; missing:.*asio"):
|
||||
self.verify()
|
||||
|
||||
def test_simulator_cache_keeps_dependency_and_archive_validation(self):
|
||||
(self.root / "packages").rename(self.root / "packages-simulator")
|
||||
self.archive = Path(str(self.archive).replace("/packages/", "/packages-simulator/"))
|
||||
self.inputs.write_text(self.inputs.read_text().replace("/packages/", "/packages-simulator/"))
|
||||
self.target.write_text(self.target.read_text().replace("/packages/", "/packages-simulator/"))
|
||||
self.verify()
|
||||
valid_target = self.target.read_text()
|
||||
self.target.write_text(valid_target.replace("/asio/1.0/", "/asio/2.0/"))
|
||||
with self.assertRaisesRegex(ValueError, "Unreviewed iOS dependency: asio 2.0"):
|
||||
self.verify()
|
||||
self.target.write_text(valid_target)
|
||||
self.archive.write_bytes(b"changed simulator archive")
|
||||
with self.assertRaisesRegex(ValueError, "Source checksum mismatch: .*libopus.a"):
|
||||
self.verify()
|
||||
|
||||
def test_changed_native_archive_is_rejected(self):
|
||||
self.archive.write_bytes(b"changed archive")
|
||||
with self.assertRaisesRegex(ValueError, "Source checksum mismatch: .*libopus.a"):
|
||||
|
||||
Reference in New Issue
Block a user