[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
@@ -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) {
+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)
}
}
+40 -3
View File
@@ -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
+20 -10
View File
@@ -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")
+4 -3
View File
@@ -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__":
+93
View File
@@ -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()
+18
View File
@@ -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"):