[feat] add live video settings to iOS controls

This commit is contained in:
dijunkun
2026-09-28 17:12:04 +08:00
parent a4baca222c
commit 163506f81e
7 changed files with 481 additions and 68 deletions
@@ -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 = "<group>"; };
200000000000000000000001 /* CrossDeskMobileApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrossDeskMobileApp.swift; sourceTree = "<group>"; };
200000000000000000000002 /* RemoteSessionModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSessionModel.swift; sourceTree = "<group>"; };
200000000000000000000003 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
@@ -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 */,
@@ -63,7 +63,14 @@ typedef NS_ENUM(NSInteger, CrossDeskVideoAdaptationPolicy) {
didReceiveHostName:(NSString *)hostName
appVersion:(NSString *)appVersion
displayNames:(NSArray<NSString *> *)displayNames
displaySizes:(NSArray<NSValue *> *)displaySizes;
displaySizes:(NSArray<NSValue *> *)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<NSString *, NSNumber *> *)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<NSString *> *)remoteIDs
subscribe:(BOOL)subscribe
NS_SWIFT_NAME(requestPresence(remoteIDs:subscribe:));
@@ -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<int>(quality), static_cast<int>(frameRate),
static_cast<int>(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<CrossDeskRTCBridgeDelegate> 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<NSString *> *)remoteIDs
subscribe:(BOOL)subscribe {
NSArray<NSString *> *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<CrossDeskRTCBridgeDelegate> 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;
@@ -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<Void, Never>?
private var videoSettingsTimeoutTask: Task<Void, Never>?
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()
+137 -62
View File
@@ -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<Int>) -> 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
@@ -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
}
}
+139
View File
@@ -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()