[feat] add macOS and mobile resolution controls with disconnect restore

This commit is contained in:
dijunkun committed 2026-11-11 11:02:41 +08:00
1 parent a3159b198e
commit 34b59de15d
50 files changed
+1086 -36

No files matched your search

@@ -100,6 +100,10 @@ typedef struct {
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didReceivePresence:(NSDictionary<NSString *, NSNumber *> *)presence
snapshot:(BOOL)snapshot;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didReceiveDisplayResolutions:(NSData *)message;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didFailDisplayResolutionRequest:(uint32_t)requestID;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didReceiveAnnouncementMessage:(NSData *)message;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
@@ -163,6 +167,12 @@ typedef struct {
preference:(NSInteger)preference
requestID:(uint32_t)requestID
NS_SWIFT_NAME(sendVideoSettings(quality:frameRate:preference:requestID:));
- (void)sendDisplayResolution:(NSInteger)displayID
displayKey:(NSString *)displayKey
width:(NSInteger)width
height:(NSInteger)height
requestID:(uint32_t)requestID
NS_SWIFT_NAME(sendDisplayResolution(displayID:displayKey:width:height:requestID:));
- (void)requestPresenceForRemoteIDs:(NSArray<NSString *> *)remoteIDs
subscribe:(BOOL)subscribe
NS_SWIFT_NAME(requestPresence(remoteIDs:subscribe:));
@@ -711,6 +711,35 @@ MiniRtcParams MakeParams(const RTCState &state, const std::string &user_id,
});
}
- (void)sendDisplayResolution:(NSInteger)displayID
displayKey:(NSString *)displayKey
width:(NSInteger)width
height:(NSInteger)height
requestID:(uint32_t)requestID {
if (displayID < 0 || displayID >= crossdesk::kMaxResolutionDisplays ||
width <= 0 || width > 32768 || height <= 0 || height > 32768 || !requestID) return;
NSString *key = [displayKey copy];
const uint64_t generation = _activeControllerGeneration.load();
dispatch_async(_rtcQueue, ^{
if (![self isControllerGenerationActive:generation] ||
!self->_state->controller_peer) return;
RemoteAction action{};
action.type = ControlType::display_resolution;
action.resolution_change = {int(displayID), key.UTF8String ?: "",
{int(width), int(height)}, requestID, false};
const auto message = action.to_json();
if (message.empty() || MiniRtcSendReliableDataFrame(self->_state->controller_peer,
message.data(), message.size(), kControlStream) != 0) {
DispatchMain(^{
if (![self isControllerGenerationActive:generation]) return;
id<CrossDeskRTCBridgeDelegate> delegate = self.delegate;
if ([delegate respondsToSelector:@selector(rtcBridge:didFailDisplayResolutionRequest:)])
[delegate rtcBridge:self didFailDisplayResolutionRequest:requestID];
});
}
});
}
- (void)notifyVideoSettings:(crossdesk::VideoSettings)settings
generation:(uint64_t)generation {
DispatchMain(^{
@@ -1738,6 +1767,18 @@ MiniRtcParams MakeParams(const RTCState &state, const std::string &user_id,
});
return;
}
if (action.type == ControlType::display_resolution_status ||
action.type == ControlType::host_infomation) {
const auto message = action.to_json();
NSData *payload = [NSData dataWithBytes:message.data() length:message.size()];
DispatchMain(^{
if (![self isControllerGenerationActive:generation]) return;
id<CrossDeskRTCBridgeDelegate> delegate = self.delegate;
if ([delegate respondsToSelector:@selector(rtcBridge:didReceiveDisplayResolutions:)])
[delegate rtcBridge:self didReceiveDisplayResolutions:payload];
});
if (action.type == ControlType::display_resolution_status) return;
}
if (action.type == ControlType::video_settings_status) {
[self notifyVideoSettings:action.vs generation:generation];
return;
@@ -322,6 +322,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
didSet { UserDefaults.standard.set(autoLandscape, forKey: AppOrientation.autoLandscapeKey) }
}
@Published private(set) var videoSettings = RemoteVideoSettingsState()
@Published private(set) var displayResolutions = RemoteDisplayResolutionState()
@Published var privacyScreen = RemotePrivacyScreen()
@Published private(set) var privacyClock = ProcessInfo.processInfo.systemUptime
private var privacyTimer: Timer?
@@ -384,6 +385,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
private var videoWasBackgrounded = false
private var videoRecoveryTask: Task<Void, Never>?
private var videoSettingsTimeoutTask: Task<Void, Never>?
private var displayResolutionTimeoutTask: Task<Void, Never>?
private var pendingPresenceRemoteID: String?
private var presenceProbeGeneration: UInt64 = 0
@Published private(set) var signalConnected = false
@@ -1040,7 +1042,40 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
}
}
func updateDisplayResolution(_ resolution: RemoteDisplayResolution, displayID: Int, displayKey: String) {
guard hasNetworkConsent, isConnected, selectedDisplay == displayID,
displayResolutions.display(displayID)?.display_key == displayKey,
let request = displayResolutions.begin(displayID: selectedDisplay, resolution: resolution) else { return }
displayResolutionTimeoutTask?.cancel()
bridge.sendDisplayResolution(displayID: request.display_id, displayKey: request.display_key,
width: request.width, height: request.height, requestID: request.request_id)
displayResolutionTimeoutTask = Task { @MainActor [weak self] in
do { try await Task.sleep(nanoseconds: 10_000_000_000) } catch { return }
self?.displayResolutions.expire(requestID: request.request_id)
}
}
func rtcBridge(_ bridge: CrossDeskRTCBridge, didReceiveDisplayResolutions message: Data) {
// Host information can arrive before the connection-state callback.
// The bridge already rejects callbacks from previous sessions.
guard hasNetworkConsent else { return }
displayResolutions.receive(message)
if !displayResolutions.pending {
displayResolutionTimeoutTask?.cancel(); displayResolutionTimeoutTask = nil
}
}
func rtcBridge(_ bridge: CrossDeskRTCBridge, didFailDisplayResolutionRequest requestID: UInt32) {
guard hasNetworkConsent, isConnected else { return }
displayResolutions.expire(requestID: requestID)
if !displayResolutions.pending {
displayResolutionTimeoutTask?.cancel(); displayResolutionTimeoutTask = nil
}
}
private func resetVideoSettings() {
displayResolutionTimeoutTask?.cancel(); displayResolutionTimeoutTask = nil
displayResolutions = RemoteDisplayResolutionState()
privacyTimer?.invalidate(); privacyTimer = nil
privacyScreen = RemotePrivacyScreen()
privacyClock = ProcessInfo.processInfo.systemUptime
@@ -1488,6 +1488,39 @@ private struct FloatingVideoSettings: View {
var body: some View {
VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: 5) {
Text(NSLocalizedString("被控端分辨率", comment: ""))
.font(.caption).foregroundStyle(.secondary)
let display = session.displayResolutions.display(session.selectedDisplay)
Menu {
ForEach(Array(Set(display?.modes ?? [])).sorted {
$0.width == $1.width ? $0.height > $1.height : $0.width > $1.width
}, id: \.self) { mode in
Button {
if let display {
session.updateDisplayResolution(mode, displayID: display.display_id, displayKey: display.display_key)
}
} label: {
if mode == display?.current { Label(mode.label, systemImage: "checkmark") }
else { Text(mode.label) }
}
}
} label: {
HStack {
Text(display?.current.label ?? NSLocalizedString("设备不支持", comment: ""))
Spacer()
Image(systemName: "chevron.up.chevron.down")
}
.font(.subheadline).padding(8)
.background(Color.secondary.opacity(0.1), in: RoundedRectangle(cornerRadius: 7))
}
.disabled(!session.isConnected || session.displayResolutions.pending || (display?.modes.isEmpty ?? true))
if session.displayResolutions.pending {
Text(NSLocalizedString("等待电脑端确认…", comment: "")).font(.caption).foregroundStyle(.secondary)
} else if session.displayResolutions.failed {
Text(NSLocalizedString("调整失败,请重试。", comment: "")).font(.caption).foregroundStyle(.secondary)
}
}
settingRow(NSLocalizedString("画面质量", comment: ""), options: [NSLocalizedString("低", comment: ""), NSLocalizedString("中", comment: ""), NSLocalizedString("高", comment: "")],
values: [0, 1, 2], selection: Binding(
get: { session.videoSettings.selection.quality },
@@ -1,3 +1,5 @@
import Foundation
// Values match crossdesk::VideoSettings on the reliable control stream.
struct RemoteVideoSettings: Equatable {
var quality = 2
@@ -66,3 +68,96 @@ struct RemoteVideoSettingsState {
failed = true
}
}
struct RemoteDisplayResolution: Codable, Equatable, Hashable {
let width: Int
let height: Int
var label: String { "\(width) × \(height)" }
var valid: Bool { (1...32768).contains(width) && (1...32768).contains(height) }
}
struct RemoteDisplayResolutionInfo: Decodable {
let display_id: Int
let display_key: String
let width: Int
let height: Int
let modes: [RemoteDisplayResolution]
var current: RemoteDisplayResolution { RemoteDisplayResolution(width: width, height: height) }
var valid: Bool {
(0..<32).contains(display_id) && !display_key.isEmpty &&
display_key.utf8.count <= 1024 && !display_key.contains("\0") &&
current.valid && modes.count <= 256 && modes.allSatisfy(\.valid)
}
}
struct RemoteDisplayResolutionRequest: Decodable, Equatable {
let display_id: Int
let display_key: String
let width: Int
let height: Int
let request_id: UInt32
var accepted = false
var resolution: RemoteDisplayResolution { RemoteDisplayResolution(width: width, height: height) }
func matches(_ other: Self) -> Bool {
display_id == other.display_id && display_key == other.display_key &&
resolution == other.resolution && request_id == other.request_id
}
}
struct RemoteDisplayResolutionState {
private(set) var displays: [RemoteDisplayResolutionInfo] = []
private(set) var requested: RemoteDisplayResolutionRequest?
private(set) var pending = false
private(set) var failed = false
func display(_ id: Int) -> RemoteDisplayResolutionInfo? {
displays.first { $0.display_id == id }
}
mutating func begin(displayID: Int, resolution: RemoteDisplayResolution) -> RemoteDisplayResolutionRequest? {
guard !pending, let display = display(displayID), display.modes.contains(resolution),
display.current != resolution else { return nil }
let next = (requested?.request_id ?? 0) &+ 1
let request = RemoteDisplayResolutionRequest(display_id: displayID,
display_key: display.display_key, width: resolution.width,
height: resolution.height, request_id: next == 0 ? 1 : next)
requested = request; pending = true; failed = false
return request
}
mutating func expire(requestID: UInt32) {
guard pending, requested?.request_id == requestID else { return }
pending = false; failed = true
}
mutating func receive(_ data: Data) {
struct Host: Decodable { let display_resolutions: [RemoteDisplayResolutionInfo]? }
struct Message: Decodable {
let type: Int
let host_info: Host?
let display_resolution: RemoteDisplayResolutionRequest?
let display_resolutions: [RemoteDisplayResolutionInfo]?
}
guard let message = try? JSONDecoder().decode(Message.self, from: data) else { return }
let actual: [RemoteDisplayResolutionInfo]
if message.type == 3, let host = message.host_info {
actual = host.display_resolutions ?? []
} else if message.type == 16, let response = message.display_resolution,
let requested, response.matches(requested), let values = message.display_resolutions {
actual = values
} else { return }
guard actual.count <= 32, actual.allSatisfy(\.valid),
Set(actual.map(\.display_id)).count == actual.count else { return }
displays = actual
if message.type == 16, let response = message.display_resolution {
pending = false
failed = !response.accepted || !actual.contains {
$0.display_id == response.display_id && $0.display_key == response.display_key &&
$0.current == response.resolution
}
} else if pending, let requested, !actual.contains(where: {
$0.display_id == requested.display_id && $0.display_key == requested.display_key &&
$0.modes.contains(requested.resolution)
}) {
expire(requestID: requested.request_id)
}
}
}
@@ -285,3 +285,6 @@
"App Store 暂无可用更新" = "Im App Store ist derzeit kein Update verfügbar";
"暂时无法在当前地区的 App Store 查询到此应用" = "Diese App wurde im App Store der aktuellen Region nicht gefunden";
"新版本需要 iOS %1$@ 或更高版本" = "Die neue Version erfordert iOS %1$@ oder neuer";
"被控端分辨率" = "Auflösung des entfernten Bildschirms";
"设备不支持" = "Nicht unterstützt";
"等待电脑端确认…" = "Warten auf Bestätigung vom Computer...";
@@ -285,3 +285,6 @@
"App Store 暂无可用更新" = "No update is currently available in the App Store";
"暂时无法在当前地区的 App Store 查询到此应用" = "This app could not be found in the App Store for the current region";
"新版本需要 iOS %1$@ 或更高版本" = "The new version requires iOS %1$@ or later";
"被控端分辨率" = "Remote display resolution";
"设备不支持" = "Unsupported";
"等待电脑端确认…" = "Waiting for confirmation from the computer...";
@@ -285,3 +285,6 @@
"App Store 暂无可用更新" = "No hay actualizaciones disponibles en el App Store por ahora";
"暂时无法在当前地区的 App Store 查询到此应用" = "No se encontró esta app en el App Store de la región actual";
"新版本需要 iOS %1$@ 或更高版本" = "La nueva versión requiere iOS %1$@ o posterior";
"被控端分辨率" = "Resolución de la pantalla remota";
"设备不支持" = "No compatible";
"等待电脑端确认…" = "Esperando confirmación del ordenador...";
@@ -285,3 +285,6 @@
"App Store 暂无可用更新" = "Aucune mise à jour disponible dans l’App Store pour le moment";
"暂时无法在当前地区的 App Store 查询到此应用" = "Cette app est introuvable dans l’App Store de la région actuelle";
"新版本需要 iOS %1$@ 或更高版本" = "La nouvelle version nécessite iOS %1$@ ou une version ultérieure";
"被控端分辨率" = "Résolution de l’écran distant";
"设备不支持" = "Non pris en charge";
"等待电脑端确认…" = "En attente de confirmation de l'ordinateur...";
@@ -285,3 +285,6 @@
"App Store 暂无可用更新" = "現在 App Store に利用可能なアップデートはありません";
"暂时无法在当前地区的 App Store 查询到此应用" = "現在の地域の App Store でこのアプリが見つかりませんでした";
"新版本需要 iOS %1$@ 或更高版本" = "新しいバージョンには iOS %1$@ 以降が必要です";
"被控端分辨率" = "接続先の画面解像度";
"设备不支持" = "サポートされていません";
"等待电脑端确认…" = "コンピューターからの確認を待っています...";
@@ -285,3 +285,6 @@
"App Store 暂无可用更新" = "현재 App Store에 사용 가능한 업데이트가 없습니다";
"暂时无法在当前地区的 App Store 查询到此应用" = "현재 지역의 App Store에서 이 앱을 찾을 수 없습니다";
"新版本需要 iOS %1$@ 或更高版本" = "새 버전에는 iOS %1$@ 이상이 필요합니다";
"被控端分辨率" = "원격 화면 해상도";
"设备不支持" = "지원되지 않음";
"等待电脑端确认…" = "컴퓨터의 확인을 기다리는 중...";
@@ -285,3 +285,6 @@
"App Store 暂无可用更新" = "Nenhuma atualização disponível na App Store no momento";
"暂时无法在当前地区的 App Store 查询到此应用" = "Este app não foi encontrado na App Store da região atual";
"新版本需要 iOS %1$@ 或更高版本" = "A nova versão requer iOS %1$@ ou posterior";
"被控端分辨率" = "Resolução da tela remota";
"设备不支持" = "Não suportado";
"等待电脑端确认…" = "Aguardando confirmação do computador...";
@@ -285,3 +285,6 @@
"App Store 暂无可用更新" = "Nenhuma atualização disponível na App Store no momento";
"暂时无法在当前地区的 App Store 查询到此应用" = "Este app não foi encontrado na App Store da região atual";
"新版本需要 iOS %1$@ 或更高版本" = "A nova versão requer iOS %1$@ ou posterior";
"被控端分辨率" = "Resolução da tela remota";
"设备不支持" = "Não suportado";
"等待电脑端确认…" = "Aguardando confirmação do computador...";
@@ -285,3 +285,6 @@
"App Store 暂无可用更新" = "В App Store пока нет доступных обновлений";
"暂时无法在当前地区的 App Store 查询到此应用" = "Не удалось найти приложение в App Store для текущего региона";
"新版本需要 iOS %1$@ 或更高版本" = "Для новой версии требуется iOS %1$@ или новее";
"被控端分辨率" = "Разрешение удалённого экрана";
"设备不支持" = "Не поддерживается";
"等待电脑端确认…" = "Ожидание подтверждения от компьютера...";
@@ -285,3 +285,6 @@
"App Store 暂无可用更新" = "App Store 暂无可用更新";
"暂时无法在当前地区的 App Store 查询到此应用" = "暂时无法在当前地区的 App Store 查询到此应用";
"新版本需要 iOS %1$@ 或更高版本" = "新版本需要 iOS %1$@ 或更高版本";
"被控端分辨率" = "被控端分辨率";
"设备不支持" = "设备不支持";
"等待电脑端确认…" = "等待电脑端确认…";
@@ -285,3 +285,6 @@
"App Store 暂无可用更新" = "App Store 暫無可用更新";
"暂时无法在当前地区的 App Store 查询到此应用" = "暫時無法在目前地區的 App Store 查詢到此 App";
"新版本需要 iOS %1$@ 或更高版本" = "新版本需要 iOS %1$@ 或以上版本";
"被控端分辨率" = "被控端解析度";
"设备不支持" = "裝置不支援";
"等待电脑端确认…" = "等待電腦端確認…";