diff --git a/apps/ios/CrossDeskMobile.xcodeproj/project.pbxproj b/apps/ios/CrossDeskMobile.xcodeproj/project.pbxproj index 54e87f3f..b30ff30f 100644 --- a/apps/ios/CrossDeskMobile.xcodeproj/project.pbxproj +++ b/apps/ios/CrossDeskMobile.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 100000000000000000000016 /* RemoteVideoSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000016 /* RemoteVideoSettings.swift */; }; 100000000000000000000001 /* CrossDeskMobileApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000001 /* CrossDeskMobileApp.swift */; }; 100000000000000000000002 /* RemoteSessionModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000002 /* RemoteSessionModel.swift */; }; 100000000000000000000003 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000003 /* ContentView.swift */; }; @@ -27,6 +28,7 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 200000000000000000000016 /* RemoteVideoSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteVideoSettings.swift; sourceTree = ""; }; 200000000000000000000001 /* CrossDeskMobileApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrossDeskMobileApp.swift; sourceTree = ""; }; 200000000000000000000002 /* RemoteSessionModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSessionModel.swift; sourceTree = ""; }; 200000000000000000000003 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; @@ -92,6 +94,7 @@ 200000000000000000000013 /* ThumbnailFileStore.swift */, 200000000000000000000012 /* PrivacyPreferences.swift */, 200000000000000000000002 /* RemoteSessionModel.swift */, + 200000000000000000000016 /* RemoteVideoSettings.swift */, 200000000000000000000003 /* ContentView.swift */, 20000000000000000000000E /* ThirdPartyLicensesView.swift */, 20000000000000000000000F /* ThirdPartyLicenses.json */, @@ -255,6 +258,7 @@ files = ( 100000000000000000000001 /* CrossDeskMobileApp.swift in Sources */, 100000000000000000000002 /* RemoteSessionModel.swift in Sources */, + 100000000000000000000016 /* RemoteVideoSettings.swift in Sources */, 100000000000000000000003 /* ContentView.swift in Sources */, 100000000000000000000009 /* ThirdPartyLicensesView.swift in Sources */, 100000000000000000000004 /* RemoteSessionView.swift in Sources */, diff --git a/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.h b/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.h index 0cd39e1c..993dae3b 100644 --- a/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.h +++ b/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.h @@ -63,7 +63,14 @@ typedef NS_ENUM(NSInteger, CrossDeskVideoAdaptationPolicy) { didReceiveHostName:(NSString *)hostName appVersion:(NSString *)appVersion displayNames:(NSArray *)displayNames - displaySizes:(NSArray *)displaySizes; + displaySizes:(NSArray *)displaySizes + supportsVideoSettings:(BOOL)supportsVideoSettings; +- (void)rtcBridge:(CrossDeskRTCBridge *)bridge + didReceiveVideoSettingsQuality:(NSInteger)quality + frameRate:(NSInteger)frameRate + preference:(NSInteger)preference + requestID:(uint32_t)requestID + accepted:(BOOL)accepted; - (void)rtcBridge:(CrossDeskRTCBridge *)bridge didReceivePresence:(NSDictionary *)presence snapshot:(BOOL)snapshot; @@ -110,6 +117,11 @@ typedef NS_ENUM(NSInteger, CrossDeskVideoAdaptationPolicy) { - (void)configureWithSignalHost:(NSString *)host signalPort:(NSInteger)signalPort; - (void)setVideoAdaptationPolicy:(CrossDeskVideoAdaptationPolicy)policy; +- (void)sendVideoSettingsWithQuality:(NSInteger)quality + frameRate:(NSInteger)frameRate + preference:(NSInteger)preference + requestID:(uint32_t)requestID + NS_SWIFT_NAME(sendVideoSettings(quality:frameRate:preference:requestID:)); - (void)requestPresenceForRemoteIDs:(NSArray *)remoteIDs subscribe:(BOOL)subscribe NS_SWIFT_NAME(requestPresence(remoteIDs:subscribe:)); diff --git a/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.mm b/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.mm index adf45af5..efb6d2b6 100644 --- a/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.mm +++ b/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.mm @@ -327,6 +327,8 @@ void DispatchMain(dispatch_block_t block) { width:(NSInteger)width height:(NSInteger)height; - (void)resetVideoDelivery; +- (void)notifyVideoSettings:(crossdesk::VideoSettings)settings + generation:(uint64_t)generation; - (void)handleAudio:(const char *)data size:(size_t)size; - (void)handleData:(const char *)data size:(size_t)size @@ -651,6 +653,46 @@ Params MakeParams(const RTCState &state, const std::string &user_id, _presenceGeneration.fetch_add(1); } +- (void)sendVideoSettingsWithQuality:(NSInteger)quality + frameRate:(NSInteger)frameRate + preference:(NSInteger)preference + requestID:(uint32_t)requestID { + if (quality < 0 || quality > 2 || (frameRate != 30 && frameRate != 60) || + preference < 0 || preference > 2) return; + const uint64_t generation = _activeControllerGeneration.load(); + dispatch_async(_rtcQueue, ^{ + if (![self isControllerGenerationActive:generation] || + !self->_state->controller_peer) return; + RemoteAction action{}; + action.type = ControlType::video_settings; + action.vs = {static_cast(quality), static_cast(frameRate), + static_cast(preference), requestID, false}; + const auto message = action.to_json(); + if (SendReliableDataFrame(self->_state->controller_peer, message.data(), + message.size(), kControlStream) != 0) { + [self notifyVideoSettings:action.vs generation:generation]; + } + }); +} + +- (void)notifyVideoSettings:(crossdesk::VideoSettings)settings + generation:(uint64_t)generation { + DispatchMain(^{ + if (![self isControllerGenerationActive:generation]) return; + id delegate = self.delegate; + if ([delegate respondsToSelector: + @selector(rtcBridge:didReceiveVideoSettingsQuality:frameRate: + preference:requestID:accepted:)]) { + [delegate rtcBridge:self + didReceiveVideoSettingsQuality:settings.quality + frameRate:settings.frame_rate + preference:settings.preference + requestID:settings.request_id + accepted:settings.accepted]; + } + }); +} + - (void)requestPresenceForRemoteIDs:(NSArray *)remoteIDs subscribe:(BOOL)subscribe { NSArray *owned_ids = [remoteIDs copy]; @@ -1560,10 +1602,13 @@ Params MakeParams(const RTCState &state, const std::string &user_id, const std::string source(sourceID, sourceIDSize); if (source == kControlStream) { RemoteAction action{}; - if (!action.from_json(std::string(data, size)) || - action.type != ControlType::host_infomation) { + if (!action.from_json(std::string(data, size))) return; + if (action.type == ControlType::video_settings_status) { + [self notifyVideoSettings:action.vs generation:generation]; return; } + if (action.type != ControlType::host_infomation) return; + const BOOL supports_video_settings = action.i.supports_video_settings; NSString *host_name = [[NSString alloc] initWithBytes:action.i.host_name length:action.i.host_name_size @@ -1594,12 +1639,13 @@ Params MakeParams(const RTCState &state, const std::string &user_id, id delegate = self.delegate; if ([delegate respondsToSelector: @selector(rtcBridge:didReceiveHostName:appVersion:displayNames: - displaySizes:)]) { + displaySizes:supportsVideoSettings:)]) { [delegate rtcBridge:self didReceiveHostName:host_name ?: @"" appVersion:app_version ?: @"" displayNames:display_names - displaySizes:display_sizes]; + displaySizes:display_sizes + supportsVideoSettings:supports_video_settings]; } }); return; diff --git a/apps/ios/CrossDeskMobile/RemoteSessionModel.swift b/apps/ios/CrossDeskMobile/RemoteSessionModel.swift index 44086f85..28a905a5 100644 --- a/apps/ios/CrossDeskMobile/RemoteSessionModel.swift +++ b/apps/ios/CrossDeskMobile/RemoteSessionModel.swift @@ -308,6 +308,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe @Published var videoAdaptationPolicy = VideoAdaptationPolicy.saved { didSet { videoAdaptationPolicy.save() } } + @Published private(set) var videoSettings = RemoteVideoSettingsState() @Published var remoteID = "" @Published var password = "" @Published private(set) var signalStatus = "尚未同意联网" @@ -358,6 +359,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe private var remoteCursorSequence: UInt32? private var videoWasBackgrounded = false private var videoRecoveryTask: Task? + private var videoSettingsTimeoutTask: Task? private var pendingPresenceRemoteID: String? private var presenceProbeGeneration: UInt64 = 0 private var signalConnected = false @@ -529,6 +531,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe private func beginRemoteConnection(_ identifier: String) { resetRemoteVersionCheck() cancelVideoRecovery() + resetVideoSettings() cancelPresenceProbe() activeRemoteID = identifier activeDisplayName = "" @@ -621,6 +624,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe private func resetConnection() { resetRemoteVersionCheck() + resetVideoSettings() connectionFailureMessage = nil cancelVideoRecovery() let wasCheckingPresence = pendingPresenceRemoteID != nil @@ -908,6 +912,61 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe bridge.switch(toDisplay: index) } + var canChangeVideoSettings: Bool { + isConnected && videoSettings.supported + } + + var videoSettingsFeedback: String { + if !canChangeVideoSettings { + return "连接建立后可调整,需被控端支持。" + } + if videoSettings.failed { return "调整失败,请重试。" } + return videoSettings.pending ? "正在应用…" : "" + } + + func updateVideoSettings(quality: Int? = nil, frameRate: Int? = nil, + preference: Int? = nil) { + guard hasNetworkConsent, canChangeVideoSettings else { return } + var settings = videoSettings.selection + if let quality { settings.quality = quality } + if let frameRate { settings.frameRate = frameRate } + if let preference { settings.preference = preference } + guard let requestID = videoSettings.begin(settings) else { return } + videoSettingsTimeoutTask?.cancel() + bridge.sendVideoSettings(quality: settings.quality, + frameRate: settings.frameRate, + preference: settings.preference, + requestID: requestID) + videoSettingsTimeoutTask = Task { @MainActor [weak self] in + do { + try await Task.sleep(nanoseconds: 5_000_000_000) + } catch { return } + self?.videoSettings.expire(requestID: requestID) + } + } + + private func resetVideoSettings() { + videoSettingsTimeoutTask?.cancel() + videoSettingsTimeoutTask = nil + videoSettings = RemoteVideoSettingsState( + preference: videoAdaptationPolicy.bridgeValue.rawValue) + } + + func rtcBridge(_ bridge: CrossDeskRTCBridge, + didReceiveVideoSettingsQuality quality: Int, + frameRate: Int, preference: Int, + requestID: UInt32, accepted: Bool) { + guard hasNetworkConsent, isConnected else { return } + videoSettings.receive(RemoteVideoSettings(quality: quality, + frameRate: frameRate, + preference: preference), + requestID: requestID, accepted: accepted) + if !videoSettings.pending { + videoSettingsTimeoutTask?.cancel() + videoSettingsTimeoutTask = nil + } + } + private func resetRemoteCursorState() { remoteCursorVisible = true remoteCursorShape = 0 @@ -1003,6 +1062,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe case 1: connectionStatus = "已连接" sessionVisible = true + if videoSettings.requestID == 0 { updateVideoSettings() } checkRemoteVersionIfNeeded() recordSuccessfulConnectionIfNeeded() audioPlayer.setEnabled(audioEnabled) @@ -1094,7 +1154,16 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe didReceiveHostName hostName: String, appVersion: String, displayNames remoteDisplayNames: [String], - displaySizes remoteDisplaySizes: [NSValue]) { + displaySizes remoteDisplaySizes: [NSValue], + supportsVideoSettings: Bool) { + let previouslySupported = videoSettings.supported + videoSettings.setSupported(supportsVideoSettings) + if !supportsVideoSettings { + videoSettingsTimeoutTask?.cancel() + videoSettingsTimeoutTask = nil + } else if !previouslySupported { + updateVideoSettings() + } remoteHostInfoReceived = true remoteAppVersion = appVersion checkRemoteVersionIfNeeded() diff --git a/apps/ios/CrossDeskMobile/RemoteSessionView.swift b/apps/ios/CrossDeskMobile/RemoteSessionView.swift index a6a52bc3..f88d4938 100644 --- a/apps/ios/CrossDeskMobile/RemoteSessionView.swift +++ b/apps/ios/CrossDeskMobile/RemoteSessionView.swift @@ -277,7 +277,7 @@ struct RemoteSessionView: View { ) let panelSize = CGSize( width: min(340, max(280, containerSize.width - 160)), - height: min(250, max(220, containerSize.height - 48)) + height: min(310, max(0, containerSize.height - 16)) ) let panelCenter = floatingPanelCenter( orbCenter: orbCenter, @@ -1075,6 +1075,7 @@ private struct CrossDeskStatusOrb: View { private struct FloatingSessionMenu: View { @ObservedObject var session: RemoteSessionModel + @State private var showingVideoSettings = false let showKeyboard: () -> Void let chooseFile: () -> Void let close: () -> Void @@ -1086,15 +1087,28 @@ private struct FloatingSessionMenu: View { var body: some View { VStack(spacing: 9) { HStack(spacing: 8) { - Circle() - .fill(session.isConnected ? Color.green : Color.orange) - .frame(width: 9, height: 9) - VStack(alignment: .leading, spacing: 1) { - Text(session.connectionStatus) + if showingVideoSettings { + Button { + showingVideoSettings = false + } label: { + Image(systemName: "chevron.left") + .frame(width: 28, height: 32) + } + .buttonStyle(.plain) + .accessibilityLabel("返回控制栏") + Text("画面设置") .font(.subheadline.weight(.semibold)) - Text(statusDetail) - .font(.caption2.monospacedDigit()) - .foregroundStyle(.secondary) + } else { + Circle() + .fill(session.isConnected ? Color.green : Color.orange) + .frame(width: 9, height: 9) + VStack(alignment: .leading, spacing: 1) { + Text(session.connectionStatus) + .font(.subheadline.weight(.semibold)) + Text(statusDetail) + .font(.caption2.monospacedDigit()) + .foregroundStyle(.secondary) + } } Spacer() Button(action: close) { @@ -1103,65 +1117,84 @@ private struct FloatingSessionMenu: View { .foregroundStyle(.secondary) } .buttonStyle(.plain) + .accessibilityLabel("关闭控制栏") } - LazyVGrid(columns: columns, spacing: 7) { - FloatingControlButton(title: "键盘", symbol: "keyboard", - action: showKeyboard) + if showingVideoSettings, !session.videoSettingsFeedback.isEmpty { + Text(session.videoSettingsFeedback) + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } - Menu { - ForEach(Array(session.displays.enumerated()), id: \.offset) { index, name in - Button { - session.selectDisplay(index) - } label: { - if index == session.selectedDisplay { - Label(name, systemImage: "checkmark") - } else { - Text(name) + ScrollView { + if showingVideoSettings { + FloatingVideoSettings(session: session) + } else { + VStack(spacing: 9) { + LazyVGrid(columns: columns, spacing: 7) { + FloatingControlButton(title: "键盘", symbol: "keyboard", + action: showKeyboard) + + Menu { + ForEach(Array(session.displays.enumerated()), id: \.offset) { index, name in + Button { + session.selectDisplay(index) + } label: { + if index == session.selectedDisplay { + Label(name, systemImage: "checkmark") + } else { + Text(name) + } + } + } + } label: { + FloatingControlLabel(title: "显示器", symbol: "display") } + .buttonStyle(.plain) + + FloatingControlButton(title: "画面设置", symbol: "slider.horizontal.3") { + showingVideoSettings = true + } + FloatingControlButton( + title: session.audioEnabled ? "声音" : "静音", + symbol: session.audioEnabled ? "speaker.wave.2" : "speaker.slash", + action: session.toggleAudio + ) + FloatingControlButton( + title: session.mouseControlMode == .relative ? "相对鼠标" : "绝对鼠标", + symbol: "computermouse", + action: { + session.mouseControlMode = session.mouseControlMode == .relative + ? .absolute : .relative + } + ) + FloatingControlButton(title: "发送文件", + symbol: "folder.badge.plus", + action: chooseFile) + FloatingControlButton(title: "Ctrl+Alt+Del", + symbol: "lock.trianglebadge.exclamationmark", + action: session.bridge.sendSecureAttentionSequence) } - } - } label: { - FloatingControlLabel(title: "显示器", symbol: "display") - } - .buttonStyle(.plain) - FloatingControlButton( - title: session.audioEnabled ? "声音" : "静音", - symbol: session.audioEnabled ? "speaker.wave.2" : "speaker.slash", - action: session.toggleAudio - ) - FloatingControlButton( - title: session.mouseControlMode == .relative ? "相对鼠标" : "绝对鼠标", - symbol: "computermouse", - action: { - session.mouseControlMode = session.mouseControlMode == .relative - ? .absolute : .relative - } - ) - FloatingControlButton(title: "发送文件", - symbol: "folder.badge.plus", - action: chooseFile) - FloatingControlButton(title: "Ctrl+Alt+Del", - symbol: "lock.trianglebadge.exclamationmark", - action: session.bridge.sendSecureAttentionSequence) - } - - if !session.transferStatus.isEmpty || !session.clipboardStatus.isEmpty { - HStack(spacing: 8) { - Text(!session.transferStatus.isEmpty - ? session.transferStatus : session.clipboardStatus) - .font(.caption2) - .foregroundStyle(.secondary) - .lineLimit(1) - Spacer(minLength: 0) - if session.transferProgress > 0 && session.transferProgress < 1 { - ProgressView(value: session.transferProgress) - .frame(width: 60) - } - if let file = session.receivedFileURL { - ShareLink(item: file) { - Image(systemName: "square.and.arrow.up") + if !session.transferStatus.isEmpty || !session.clipboardStatus.isEmpty { + HStack(spacing: 8) { + Text(!session.transferStatus.isEmpty + ? session.transferStatus : session.clipboardStatus) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + Spacer(minLength: 0) + if session.transferProgress > 0 && session.transferProgress < 1 { + ProgressView(value: session.transferProgress) + .frame(width: 60) + } + if let file = session.receivedFileURL { + ShareLink(item: file) { + Image(systemName: "square.and.arrow.up") + } + } + } } } } @@ -1201,6 +1234,48 @@ private struct FloatingSessionMenu: View { } } +private struct FloatingVideoSettings: View { + @ObservedObject var session: RemoteSessionModel + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + settingRow("画面质量", options: ["低", "中", "高"], + values: [0, 1, 2], selection: Binding( + get: { session.videoSettings.selection.quality }, + set: { session.updateVideoSettings(quality: $0) } + )) + settingRow("画面采集帧率", options: ["30 fps", "60 fps"], + values: [30, 60], selection: Binding( + get: { session.videoSettings.selection.frameRate }, + set: { session.updateVideoSettings(frameRate: $0) } + )) + settingRow("画面偏好", options: VideoAdaptationPolicy.allCases.map(\.title), + values: VideoAdaptationPolicy.allCases.map { $0.bridgeValue.rawValue }, + selection: Binding( + get: { session.videoSettings.selection.preference }, + set: { session.updateVideoSettings(preference: $0) } + )) + } + .padding(.vertical, 2) + } + + private func settingRow(_ title: String, options: [String], values: [Int], + selection: Binding) -> some View { + VStack(alignment: .leading, spacing: 5) { + Text(title) + .font(.caption) + .foregroundStyle(.secondary) + Picker(title, selection: selection) { + ForEach(options.indices, id: \.self) { index in + Text(options[index]).tag(values[index]) + } + } + .pickerStyle(.segmented) + .disabled(!session.canChangeVideoSettings) + } + } +} + private struct FloatingControlButton: View { let title: String let symbol: String diff --git a/apps/ios/CrossDeskMobile/RemoteVideoSettings.swift b/apps/ios/CrossDeskMobile/RemoteVideoSettings.swift new file mode 100644 index 00000000..673702af --- /dev/null +++ b/apps/ios/CrossDeskMobile/RemoteVideoSettings.swift @@ -0,0 +1,68 @@ +// Values match crossdesk::VideoSettings on the reliable control stream. +struct RemoteVideoSettings: Equatable { + var quality = 2 + var frameRate = 60 + var preference = 1 + + var isValid: Bool { + (0...2).contains(quality) && [30, 60].contains(frameRate) && + (0...2).contains(preference) + } +} + +struct RemoteVideoSettingsState { + private(set) var supported = false + private(set) var selection: RemoteVideoSettings + private(set) var applied: RemoteVideoSettings + private(set) var requestID: UInt32 = 0 + private(set) var pending = false + private(set) var failed = false + private var appliedRequestID: UInt32 = 0 + + init(preference: Int = 1) { + let settings = RemoteVideoSettings(preference: preference) + selection = settings + applied = settings + } + + mutating func setSupported(_ value: Bool) { + supported = value + if !value { + selection = applied + pending = false + failed = false + } + } + + mutating func begin(_ settings: RemoteVideoSettings) -> UInt32? { + guard supported, settings.isValid else { return nil } + requestID &+= 1 + selection = settings + pending = true + failed = false + return requestID + } + + mutating func receive(_ settings: RemoteVideoSettings, + requestID responseID: UInt32, accepted: Bool) { + guard supported, settings.isValid, + Int32(bitPattern: requestID &- responseID) >= 0 else { return } + // A superseded success still establishes the rollback value for a + // newer request. A stale response must never replace its selection. + if accepted, Int32(bitPattern: responseID &- appliedRequestID) >= 0 { + applied = settings + appliedRequestID = responseID + } + guard responseID == requestID else { return } + pending = false + failed = !accepted + selection = applied + } + + mutating func expire(requestID expiredID: UInt32) { + guard pending, expiredID == requestID else { return } + selection = applied + pending = false + failed = true + } +} diff --git a/apps/ios/tests/video_settings_test.py b/apps/ios/tests/video_settings_test.py new file mode 100644 index 00000000..6eb7e8b7 --- /dev/null +++ b/apps/ios/tests/video_settings_test.py @@ -0,0 +1,139 @@ +"""Exercise the production live-video settings state with the Apple Swift runtime.""" + +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + + +@unittest.skipUnless(sys.platform == "darwin", "requires the Apple Swift runtime") +class VideoSettingsTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.temp = tempfile.TemporaryDirectory(prefix="crossdesk-video-settings-tests-") + cls.addClassCleanup(cls.temp.cleanup) + root = Path(cls.temp.name) + harness = root / "VideoSettingsTests.swift" + harness.write_text(r''' +func expect(_ condition: @autoclosure () -> Bool, _ message: String) { + if !condition() { fatalError(message) } +} + +@main +enum VideoSettingsTests { + static func main() { + let test = CommandLine.arguments[1] + let initial = RemoteVideoSettings(preference: 0) + let first = RemoteVideoSettings(quality: 1, frameRate: 30, preference: 2) + let second = RemoteVideoSettings(quality: 0, frameRate: 60, preference: 1) + var state = RemoteVideoSettingsState(preference: 0) + expect(state.selection == initial, "Session starts with the saved preference") + expect(state.begin(first) == nil, "Old hosts cannot receive live settings") + state.setSupported(true) + + if test == "invalid" { + for quality in -1...3 { + for frameRate in [0, 24, 30, 60, 120] { + for preference in -1...3 { + let settings = RemoteVideoSettings(quality: quality, + frameRate: frameRate, preference: preference) + let valid = (0...2).contains(quality) && + [30, 60].contains(frameRate) && (0...2).contains(preference) + expect((state.begin(settings) != nil) == valid, + "Only desktop protocol values can be submitted") + } + } + } + return + } + + let firstID = state.begin(first)! + expect(state.pending && state.selection == first, "Selection is optimistic") + expect(state.applied == initial, "Submission is not confirmation") + + if test == "accepted" { + state.receive(first, requestID: firstID, accepted: true) + expect(!state.pending && !state.failed && state.selection == first, + "An accepted response commits the selection") + state.expire(requestID: firstID) + expect(!state.failed, "A completed request cannot time out") + } else if test == "rejected" { + state.receive(first, requestID: firstID, accepted: false) + expect(state.failed && !state.pending && state.selection == initial, + "Rejection rolls back the optimistic selection") + expect(state.begin(first) != nil && !state.failed && state.pending, + "A failed selection can be retried") + } else if test == "superseded" { + let secondID = state.begin(second)! + state.receive(first, requestID: firstID, accepted: true) + state.expire(requestID: firstID) + expect(state.selection == second && state.pending, + "An old reply or timeout cannot overwrite a newer selection") + state.receive(second, requestID: secondID, accepted: false) + expect(state.selection == first && state.failed, + "Rollback uses the most recently acknowledged settings") + } else if test == "out_of_order" { + let secondID = state.begin(second)! + state.receive(second, requestID: secondID, accepted: true) + state.receive(first, requestID: firstID, accepted: true) + expect(state.selection == second && state.applied == second && !state.failed, + "A stale success cannot revert a newer success") + state.receive(first, requestID: secondID + 1, accepted: true) + expect(state.applied == second, "Unsent request IDs are ignored") + } else if test == "timeout" { + state.expire(requestID: firstID) + expect(state.failed && !state.pending && state.selection == initial, + "Timeout restores the last applied settings") + state.receive(first, requestID: firstID, accepted: true) + expect(state.selection == first && !state.failed, + "A late confirmation reconciles the actual remote state") + } else if test == "unsupported" { + state.setSupported(false) + state.receive(first, requestID: firstID, accepted: true) + state.expire(requestID: firstID) + expect(!state.pending && !state.failed && state.selection == initial, + "Losing capability clears pending feedback and ignores replies") + expect(state.begin(second) == nil, "Unavailable controls cannot send") + state = RemoteVideoSettingsState(preference: 2) + expect(!state.supported && state.requestID == 0 && + state.selection == RemoteVideoSettings(preference: 2), + "A new session resets capability, request IDs and settings") + } else { + fatalError("Unknown test") + } + } +} +''') + source = Path(__file__).resolve().parents[1] / "CrossDeskMobile/RemoteVideoSettings.swift" + cls.binary = root / "video-settings-tests" + subprocess.run(["xcrun", "swiftc", str(source), str(harness), + "-o", str(cls.binary)], check=True, capture_output=True, text=True) + + def test_protocol_values(self): + self.run_case("invalid") + + def test_acceptance(self): + self.run_case("accepted") + + def test_rejection_and_retry(self): + self.run_case("rejected") + + def test_rapid_changes(self): + self.run_case("superseded") + + def test_out_of_order_responses(self): + self.run_case("out_of_order") + + def test_timeout_and_late_confirmation(self): + self.run_case("timeout") + + def test_unsupported_and_new_session(self): + self.run_case("unsupported") + + def run_case(self, name): + subprocess.run([str(self.binary), name], check=True, capture_output=True, text=True) + + +if __name__ == "__main__": + unittest.main()