[feat] add mobile privacy controls and Windows secure text input

This commit is contained in:
dijunkun
2026-10-27 02:37:23 +08:00
parent 1e25ec15e1
commit b15925f857
37 changed files with 795 additions and 80 deletions
@@ -7,6 +7,7 @@
objects = {
/* Begin PBXBuildFile section */
C10F10000000000000000001 /* RemotePrivacyScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10F20000000000000000001 /* RemotePrivacyScreen.swift */; };
100000000000000000000030 /* RemoteSystemKeyboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000030 /* RemoteSystemKeyboard.swift */; };
10000000000000000000001D /* RemoteMouseInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000001D /* RemoteMouseInput.swift */; };
10000000000000000000001E /* VirtualMouseBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000001E /* VirtualMouseBar.swift */; };
@@ -37,6 +38,7 @@
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
C10F20000000000000000001 /* RemotePrivacyScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemotePrivacyScreen.swift; sourceTree = "<group>"; };
200000000000000000000030 /* RemoteSystemKeyboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSystemKeyboard.swift; sourceTree = "<group>"; };
20000000000000000000001D /* RemoteMouseInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteMouseInput.swift; sourceTree = "<group>"; };
20000000000000000000001E /* VirtualMouseBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VirtualMouseBar.swift; sourceTree = "<group>"; };
@@ -118,6 +120,7 @@
20000000000000000000001C /* AppUpdateChecker.swift */,
200000000000000000000018 /* RecentConnection.swift */,
200000000000000000000016 /* RemoteVideoSettings.swift */,
C10F20000000000000000001 /* RemotePrivacyScreen.swift */,
200000000000000000000017 /* RemoteNetworkStatistics.swift */,
200000000000000000000003 /* ContentView.swift */,
20000000000000000000000E /* ThirdPartyLicensesView.swift */,
@@ -291,6 +294,7 @@
10000000000000000000001C /* AppUpdateChecker.swift in Sources */,
100000000000000000000018 /* RecentConnection.swift in Sources */,
100000000000000000000016 /* RemoteVideoSettings.swift in Sources */,
C10F10000000000000000001 /* RemotePrivacyScreen.swift in Sources */,
100000000000000000000017 /* RemoteNetworkStatistics.swift in Sources */,
100000000000000000000003 /* ContentView.swift in Sources */,
100000000000000000000009 /* ThirdPartyLicensesView.swift in Sources */,
@@ -123,6 +123,10 @@ typedef struct {
didReceiveServiceAvailable:(BOOL)available;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didSendSecureAttention:(BOOL)success;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didReceivePrivacyStatus:(NSData *)data;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didSendPrivacyRequest:(uint32_t)requestID success:(BOOL)success;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didUpdateFileTransfer:(NSString *)fileName
progress:(double)progress
@@ -186,6 +190,9 @@ typedef struct {
- (void)sendClipboardText:(NSString *)text;
- (void)sendFileAtURL:(NSURL *)fileURL;
- (void)sendSecureAttentionSequence;
- (void)sendPrivacyCommand:(BOOL)enable
blockLocalInput:(BOOL)blockLocalInput
requestID:(uint32_t)requestID;
@end
@@ -1043,6 +1043,30 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
});
}
- (void)sendPrivacyCommand:(BOOL)enable
blockLocalInput:(BOOL)blockLocalInput
requestID:(uint32_t)requestID {
const uint64_t generation = _activeControllerGeneration.load();
dispatch_async(_rtcQueue, ^{
if (![self isControllerGenerationActive:generation]) return;
RemoteAction action{};
action.type = ControlType::privacy_command;
action.pc = {enable ? crossdesk::PrivacyCommandFlag::enable
: crossdesk::PrivacyCommandFlag::disable,
static_cast<bool>(blockLocalInput)};
const auto message = action.to_json();
const BOOL success = self->_state->controller_peer &&
SendReliableDataFrame(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:didSendPrivacyRequest:success:)])
[delegate rtcBridge:self didSendPrivacyRequest:requestID success:success];
});
});
}
- (void)sendSecureAttentionSequence {
const uint64_t generation = _activeControllerGeneration.load();
dispatch_async(_rtcQueue, ^{
@@ -1409,6 +1433,7 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
RemoteAction action{};
action.type = ControlType::host_infomation;
action.i.platform = crossdesk::HostPlatform::IOS;
action.i.supports_privacy_screen = true;
CopyCString(action.i.host_name, sizeof(action.i.host_name),
controller_name);
action.i.host_name_size = std::strlen(action.i.host_name);
@@ -1694,6 +1719,17 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
if (source == kControlStream) {
RemoteAction action{};
if (!action.from_json(std::string(data, size))) return;
if (action.type == ControlType::privacy_status) {
const auto message = action.to_json();
NSData *status = [NSData dataWithBytes:message.data() length:message.size()];
DispatchMain(^{
if (![self isControllerGenerationActive:generation]) return;
id<CrossDeskRTCBridgeDelegate> delegate = self.delegate;
if ([delegate respondsToSelector:@selector(rtcBridge:didReceivePrivacyStatus:)])
[delegate rtcBridge:self didReceivePrivacyStatus:status];
});
return;
}
if (action.type == ControlType::video_settings_status) {
[self notifyVideoSettings:action.vs generation:generation];
return;
+3 -6
View File
@@ -431,10 +431,7 @@ private struct ConnectionHomeView: View {
Button {
presentPasswordPrompt(for: trimmedRemoteID)
} label: {
HStack(spacing: 6) {
Text("连接")
Image(systemName: "arrow.right")
}
Text("连接")
.font(.system(size: 16, weight: .bold))
.frame(width: 96, height: 54)
.foregroundStyle(.white)
@@ -701,7 +698,7 @@ private struct RecentConnectionCard: View {
.buttonStyle(.plain)
.contextMenu {
Button(action: connect) {
Label("连接", systemImage: "arrow.right.circle")
Text("连接")
}
Button(role: .destructive, action: delete) {
Label("删除记录", systemImage: "trash")
@@ -785,7 +782,7 @@ private struct PasswordPromptView: View {
}
Button(action: connect) {
Label("连接", systemImage: "arrow.right")
Text("连接")
.font(.headline)
.frame(maxWidth: .infinity)
.frame(height: 48)
@@ -0,0 +1,99 @@
import Foundation
struct RemotePrivacyStatus: Decodable {
let state: Int
let supported: Bool
let inputBlockSupported: Bool
let overlayActive: Bool
let inputBlocked: Bool
let remotePaused: Bool
let revision: UInt32
let reason: String
}
struct RemotePrivacyScreen {
private(set) var snapshot: RemotePrivacyStatus?
private(set) var pending = false
private(set) var requestID: UInt32 = 0
private var requestRevision: UInt32 = 0
private var receivedAt: TimeInterval = 0
private var requestedAt: TimeInterval = 0
private var failure = ""
var active: Bool {
guard let s = snapshot else { return false }
return s.overlayActive || s.remotePaused || [2, 3, 4].contains(s.state)
}
func fresh(at now: TimeInterval) -> Bool { snapshot != nil && now - receivedAt <= 5 }
func enabled(at now: TimeInterval) -> Bool {
guard let s = snapshot else { return false }
return fresh(at: now) && s.state == 3 && s.overlayActive && !s.remotePaused
}
func buttonTitle(at now: TimeInterval) -> String {
if enabled(at: now) { return "隐私屏已开启" }
if fresh(at: now), let s = snapshot, s.supported && s.state == 1 && !active {
return "隐私屏已关闭"
}
return "隐私屏"
}
func feedback(at now: TimeInterval) -> String {
if failure.isEmpty && (pending || fresh(at: now) && [2, 4].contains(snapshot?.state ?? -1)) {
return ""
}
if failure.isEmpty && !pending && fresh(at: now), let s = snapshot, s.supported,
enabled(at: now) || (s.state == 1 && !active) { return "" }
let reason = detail(at: now)
return status(at: now) + (reason.isEmpty ? "" : ":" + reason)
}
func canToggle(at now: TimeInterval) -> Bool {
guard let s = snapshot else { return false }
return !pending && fresh(at: now) && s.state != 4 && (active || s.supported && s.state == 1)
}
mutating func receive(_ data: Data, at now: TimeInterval) {
struct Envelope: Decodable { let type: Int; let privacyStatus: RemotePrivacyStatus }
let decoder = JSONDecoder(); decoder.keyDecodingStrategy = .convertFromSnakeCase
guard let message = try? decoder.decode(Envelope.self, from: data), message.type == 10 else { return }
let s = message.privacyStatus
guard (0...5).contains(s.state), s.reason.utf8.count < 256,
!s.reason.contains("\0"),
s.state != 3 || (s.supported && s.overlayActive && !s.remotePaused) else { return }
if let previous = snapshot, Int32(bitPattern: s.revision &- previous.revision) < 0 { return }
snapshot = s; receivedAt = now; failure = ""
if pending && Int32(bitPattern: s.revision &- requestRevision) > 0 && ![2, 4].contains(s.state) {
pending = false
}
}
mutating func begin(at now: TimeInterval) -> UInt32? {
tick(at: now)
guard canToggle(at: now), let s = snapshot else { return nil }
pending = true; requestedAt = now; requestRevision = s.revision; failure = ""
requestID &+= 1
return requestID
}
mutating func failed(request: UInt32) {
if pending && requestID == request { pending = false; failure = "指令发送失败,请重试" }
}
mutating func tick(at now: TimeInterval) {
if pending && now - requestedAt >= 15 {
pending = false; failure = "请求超时,尚未确认电脑的隐私屏状态"
}
}
func status(at now: TimeInterval) -> String {
if !failure.isEmpty { return failure }
guard let s = snapshot else { return "等待电脑返回隐私屏状态;旧版电脑端可能不支持" }
if !fresh(at: now) { return "隐私屏状态已过期,等待电脑确认" }
if s.state == 2 { return "正在开启隐私屏…" }
if s.state == 4 { return "正在关闭隐私屏…" }
if pending { return "指令已发送,等待电脑确认…" }
if s.state == 5 { return "隐私屏异常" }
if !s.supported || s.state == 0 { return "隐私屏暂不可用" }
if s.state == 3 && s.overlayActive && !s.remotePaused {
return "隐私屏已开启"
}
return s.state == 1 && !active ? "隐私屏已关闭" : "隐私屏状态待确认"
}
func detail(at now: TimeInterval) -> String {
guard let s = snapshot, fresh(at: now), s.state == 0 || s.state == 5 || !s.supported else { return "" }
return s.reason
}
}
@@ -316,6 +316,9 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
didSet { UserDefaults.standard.set(autoLandscape, forKey: AppOrientation.autoLandscapeKey) }
}
@Published private(set) var videoSettings = RemoteVideoSettingsState()
@Published var privacyScreen = RemotePrivacyScreen()
@Published private(set) var privacyClock = ProcessInfo.processInfo.systemUptime
private var privacyTimer: Timer?
@Published var remoteID = ""
@Published var password = ""
@Published private(set) var signalStatus = "等待隐私授权"
@@ -432,6 +435,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
}
deinit {
privacyTimer?.invalidate()
presenceTimer?.invalidate()
remoteVersionCheckTask?.cancel()
}
@@ -439,6 +443,8 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
var privacyNoticeVisible: Bool { privacyNotice.isVisible }
func applicationDidBecomeActive() {
privacyClock = ProcessInfo.processInfo.systemUptime
privacyScreen.tick(at: privacyClock)
appIsActive = true
AppOrientation.resume()
pictureInPicture.didBecomeActive()
@@ -1029,6 +1035,9 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
}
private func resetVideoSettings() {
privacyTimer?.invalidate(); privacyTimer = nil
privacyScreen = RemotePrivacyScreen()
privacyClock = ProcessInfo.processInfo.systemUptime
videoSettingsTimeoutTask?.cancel()
videoSettingsTimeoutTask = nil
videoSettings = RemoteVideoSettingsState(
@@ -1087,6 +1096,35 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
bridge.sendFile(at: url)
}
private func startPrivacyMonitoring() {
guard privacyTimer == nil else { return }
let timer = Timer(timeInterval: 1, repeats: true) { [weak self] _ in
guard let self else { return }
self.privacyClock = ProcessInfo.processInfo.systemUptime
self.privacyScreen.tick(at: self.privacyClock)
}
RunLoop.main.add(timer, forMode: .common)
privacyTimer = timer
}
func togglePrivacyScreen() {
guard hasNetworkConsent, isConnected else { return }
privacyClock = ProcessInfo.processInfo.systemUptime
let enable = !privacyScreen.active
guard let request = privacyScreen.begin(at: privacyClock) else { return }
bridge.sendPrivacyCommand(enable, blockLocalInput: true, requestID: request)
}
func rtcBridge(_ bridge: CrossDeskRTCBridge, didReceivePrivacyStatus data: Data) {
guard isConnected || isConnecting else { return }
privacyClock = ProcessInfo.processInfo.systemUptime
privacyScreen.receive(data, at: privacyClock)
}
func rtcBridge(_ bridge: CrossDeskRTCBridge, didSendPrivacyRequest requestID: UInt32, success: Bool) {
if !success { privacyScreen.failed(request: requestID) }
}
func sendSecureAttention() {
guard hasNetworkConsent, isConnected else { return }
guard remoteServiceAvailable else {
@@ -1190,6 +1228,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
case 0: connectionStatus = "正在建立远程连接"
case 1:
connectionStatus = "已连接"
startPrivacyMonitoring()
sessionVisible = true
pictureInPicture.beginSession()
if videoSettings.requestID == 0 { updateVideoSettings() }
@@ -1250,8 +1250,24 @@ private struct FloatingSessionMenu: View {
action: session.sendSecureAttention)
FloatingControlButton(title: "剪贴板", symbol: "doc.on.clipboard",
action: session.sendClipboard)
FloatingControlButton(
title: session.privacyScreen.buttonTitle(at: session.privacyClock),
symbol: "eye.slash",
selected: session.privacyScreen.enabled(at: session.privacyClock),
action: session.togglePrivacyScreen
)
.disabled(!session.isConnected || !session.privacyScreen.canToggle(at: session.privacyClock))
.opacity(session.isConnected && session.privacyScreen.canToggle(at: session.privacyClock) ? 1 : 0.45)
.accessibilityLabel("隐私屏")
.accessibilityValue(session.privacyScreen.status(at: session.privacyClock))
.accessibilityHint(session.privacyScreen.active ? "关闭隐私屏" : "开启隐私屏")
}
let privacyFeedback = session.privacyScreen.feedback(at: session.privacyClock)
if !privacyFeedback.isEmpty {
Text(privacyFeedback).font(.caption2).foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
if !session.controlStatus.isEmpty {
Text(session.controlStatus).font(.caption2).foregroundStyle(.secondary)
}
@@ -1435,6 +1451,7 @@ private struct FloatingVideoSettings: View {
private struct FloatingControlButton: View {
let title: String
let symbol: String
var selected = false
let action: () -> Void
var body: some View {
@@ -1442,6 +1459,7 @@ private struct FloatingControlButton: View {
FloatingControlLabel(title: title, symbol: symbol)
}
.buttonStyle(.plain)
.accessibilityAddTraits(selected ? .isSelected : [])
}
}
+23 -4
View File
@@ -8,6 +8,17 @@ UPnP discovery and router port mapping are disabled in iOS builds, matching
Android. Local connections, ICE/STUN hole punching, and TURN UDP/TCP relay
remain available. The native archive excludes GUPnP and its HTTP/XML dependencies.
## Remote privacy screen
The floating menu includes a **Privacy screen** button that toggles the host's
local cover directly, without opening a detail page. Its label reports the confirmed
on/off state without changing colors; normal toggling shows no extra text; errors and unavailable status still appear inline. It supports enabling/disabling
the local cover, blocks local keyboard/mouse input when enabled like the desktop
controller, and advertises support
for the host’s automatic privacy-on-connect setting. Status older than five
seconds is shown as stale; commands require confirmation within fifteen seconds.
PiP/background transitions preserve the session state; disconnecting clears it.
## Background connection and Picture in Picture
An established remote desktop session supports native Picture in Picture (PiP).
@@ -270,16 +281,24 @@ Closing the keyboard, switching keyboards, disconnecting or entering the
background discards unfinished composition and pending callbacks. Already sent
text remains on the remote computer.
Direct UTF-8 text requires a host advertising `supports_text_input`. The macOS
host implements this with native Unicode keyboard events and does not modify
Direct UTF-8 text requires a host advertising `supports_text_input`. Windows and
macOS hosts implement this with native Unicode keyboard events and do not modify
the clipboard. Each replacement is validated before remote Backspace events are
sent; text is limited to 16 KiB and deletions to 16,384 characters per update.
Hosts without this capability retain ASCII key input. Unsupported input is
rejected with an explanation and is not queued for later replay. For Windows,
Linux or older macOS hosts, copy text from another app and send it with
rejected with an explanation and is not queued for later replay. For hosts
without text input support on an ordinary desktop, send text with
**剪贴板** in the remote session menu, then use the computer keyboard's paste
shortcut.
This development build forwards complete UTF-8 commits through the Windows
service on lock screens, sign-in screens and secure desktops. Digits, mixed-case
letters, punctuation, spaces and non-ASCII characters stay on the text path.
The Windows client, service and session helper must all be updated; updating
iOS alone cannot enable this fix. Older Windows hosts may advertise
`supports_text_input` for ordinary desktops while lacking secure desktop text
forwarding. Actual password entry still requires validation on updated Windows.
## App updates
After network consent, the app checks the public version manifest once on each