mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-28 08:42:43 +08:00
[feat] add mobile privacy controls and Windows secure text input
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user