[feat] complete mobile session controls and file transfer

This commit is contained in:
dijunkun
2026-10-09 12:57:23 +08:00
parent 080b9760e3
commit 41aed0984f
16 changed files with 641 additions and 76 deletions
@@ -118,6 +118,10 @@ typedef struct {
didReceiveAudioPCM:(NSData *)pcmData;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didReceiveClipboardText:(NSString *)text;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didReceiveServiceAvailable:(BOOL)available;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didSendSecureAttention:(BOOL)success;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didUpdateFileTransfer:(NSString *)fileName
progress:(double)progress
@@ -942,68 +942,61 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
- (void)sendFileAtURL:(NSURL *)fileURL {
if (!fileURL.isFileURL) return;
NSURL *url = [fileURL copy];
NSString *fileName = url.lastPathComponent.length > 0 ? url.lastPathComponent : @"file";
const uint64_t generation = _activeControllerGeneration.load();
void (^reportProgress)(double) = ^(double progress) {
DispatchMain(^{
if (![self isControllerGenerationActive:generation]) return;
id<CrossDeskRTCBridgeDelegate> delegate = self.delegate;
if ([delegate respondsToSelector:@selector(rtcBridge:didUpdateFileTransfer:progress:sending:)]) {
[delegate rtcBridge:self didUpdateFileTransfer:fileName progress:progress sending:YES];
}
});
};
// Acquire the document picker's sandbox extension before its completion
// callback returns; the actual reads happen on the serialized RTC queue.
const BOOL scoped = [url startAccessingSecurityScopedResource];
dispatch_async(_rtcQueue, ^{
if (!self->_state->controller_peer) {
FILE *input = nullptr;
const auto finish = [&](bool failed) {
if (input) std::fclose(input);
if (scoped) [url stopAccessingSecurityScopedResource];
if (failed) reportProgress(-1);
};
if (![self isControllerGenerationActive:generation] || !self->_state->controller_peer) {
finish(false);
return;
}
const char *path_bytes = url.path.fileSystemRepresentation;
FILE *input = path_bytes ? std::fopen(path_bytes, "rb") : nullptr;
if (!input) {
if (scoped) [url stopAccessingSecurityScopedResource];
return;
}
if (fseeko(input, 0, SEEK_END) != 0) {
std::fclose(input);
if (scoped) [url stopAccessingSecurityScopedResource];
input = path_bytes ? std::fopen(path_bytes, "rb") : nullptr;
if (!input || fseeko(input, 0, SEEK_END) != 0) {
finish(true);
return;
}
const off_t end = ftello(input);
rewind(input);
if (end < 0) {
std::fclose(input);
if (scoped) [url stopAccessingSecurityScopedResource];
return;
}
NSString *last_component = url.lastPathComponent.length > 0
? url.lastPathComponent
: @"file";
NSData *name_data = [last_component dataUsingEncoding:NSUTF8StringEncoding];
if (name_data.length == 0 ||
NSData *name_data = [fileName dataUsingEncoding:NSUTF8StringEncoding];
if (end < 0 || name_data.length == 0 ||
name_data.length > std::numeric_limits<uint16_t>::max()) {
std::fclose(input);
if (scoped) [url stopAccessingSecurityScopedResource];
finish(true);
return;
}
const std::string file_name(static_cast<const char *>(name_data.bytes), name_data.length);
const uint32_t file_id = g_next_file_id.fetch_add(1);
const uint64_t total_size = static_cast<uint64_t>(end);
{
std::lock_guard<std::mutex> lock(self->_fileMutex);
self->_state->outgoing_files[file_id] = {
std::string(last_component.UTF8String ?: "file"), total_size};
self->_state->outgoing_files[file_id] = {file_name, total_size};
}
DispatchMain(^{
id<CrossDeskRTCBridgeDelegate> delegate = self.delegate;
if ([delegate respondsToSelector:
@selector(rtcBridge:didUpdateFileTransfer:progress:sending:)]) {
[delegate rtcBridge:self
didUpdateFileTransfer:last_component
progress:0
sending:YES];
}
});
reportProgress(0);
std::vector<char> payload(kFileChunkSize);
uint64_t offset = 0;
bool first = true;
int send_result = 0;
do {
if (![self isControllerGenerationActive:generation]) { send_result = -1; break; }
const size_t to_read = static_cast<size_t>(
std::min<uint64_t>(kFileChunkSize, total_size - offset));
const size_t bytes_read = to_read > 0
@@ -1014,7 +1007,6 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
break;
}
const bool last = offset + bytes_read >= total_size;
const std::string file_name(last_component.UTF8String ?: "file");
const std::string* file_name_pointer = first ? &file_name : nullptr;
std::vector<char> chunk = crossdesk::EncodeFileChunk(
file_id, offset, total_size, payload.data(),
@@ -1031,32 +1023,32 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
if (send_result != 0) break;
} while (offset < total_size);
std::fclose(input);
if (scoped) [url stopAccessingSecurityScopedResource];
if (send_result != 0) {
{
std::lock_guard<std::mutex> lock(self->_fileMutex);
self->_state->outgoing_files.erase(file_id);
}
DispatchMain(^{
id<CrossDeskRTCBridgeDelegate> delegate = self.delegate;
if ([delegate respondsToSelector:
@selector(rtcBridge:didUpdateFileTransfer:progress:sending:)]) {
[delegate rtcBridge:self
didUpdateFileTransfer:last_component
progress:-1
sending:YES];
}
});
std::lock_guard<std::mutex> lock(self->_fileMutex);
self->_state->outgoing_files.erase(file_id);
}
finish(send_result != 0);
});
}
- (void)sendSecureAttentionSequence {
RemoteAction action{};
action.type = ControlType::service_command;
action.c.flag = ServiceCommandFlag::send_sas;
[self sendMessage:action.to_json() reliable:YES stream:kControlStream];
const uint64_t generation = _activeControllerGeneration.load();
dispatch_async(_rtcQueue, ^{
if (![self isControllerGenerationActive:generation]) return;
RemoteAction action{};
action.type = ControlType::service_command;
action.c.flag = ServiceCommandFlag::send_sas;
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:didSendSecureAttention:)]) {
[delegate rtcBridge:self didSendSecureAttention:success];
}
});
});
}
- (void)sendMessage:(const std::string &)message
@@ -1692,6 +1684,17 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
[self notifyVideoSettings:action.vs generation:generation];
return;
}
if (action.type == ControlType::service_status) {
const BOOL available = action.ss.available;
DispatchMain(^{
if (![self isControllerGenerationActive:generation]) return;
id<CrossDeskRTCBridgeDelegate> delegate = self.delegate;
if ([delegate respondsToSelector:@selector(rtcBridge:didReceiveServiceAvailable:)]) {
[delegate rtcBridge:self didReceiveServiceAvailable:available];
}
});
return;
}
if (action.type != ControlType::host_infomation) return;
const BOOL supports_video_settings = action.i.supports_video_settings;
NSString *platform = [NSString stringWithUTF8String:
@@ -1785,6 +1788,7 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
encoding:NSUTF8StringEncoding];
if (!text) return;
DispatchMain(^{
if (![self isControllerGenerationActive:generation]) return;
id<CrossDeskRTCBridgeDelegate> delegate = self.delegate;
if ([delegate respondsToSelector:
@selector(rtcBridge:didReceiveClipboardText:)]) {
@@ -1804,6 +1808,7 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
std::lock_guard<std::mutex> lock(_fileMutex);
auto it = _state->outgoing_files.find(ack.file_id);
if (it == _state->outgoing_files.end()) return;
if (ack.total_size != it->second.total_size) return;
name = [NSString stringWithUTF8String:it->second.name.c_str()];
progress = ack.total_size == 0
? 1
@@ -1816,6 +1821,7 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
}
}
DispatchMain(^{
if (![self isControllerGenerationActive:generation]) return;
id<CrossDeskRTCBridgeDelegate> delegate = self.delegate;
if ([delegate respondsToSelector:
@selector(rtcBridge:didUpdateFileTransfer:progress:sending:)]) {
@@ -1929,7 +1935,7 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
}
dispatch_async(_rtcQueue, ^{
if (self->_state->controller_peer) {
if ([self isControllerGenerationActive:generation] && self->_state->controller_peer) {
const auto encoded_ack =
crossdesk::EncodeFileTransferAck(ack);
SendReliableDataFrame(self->_state->controller_peer,
@@ -1939,6 +1945,7 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
});
if (progress_name) {
DispatchMain(^{
if (![self isControllerGenerationActive:generation]) return;
id<CrossDeskRTCBridgeDelegate> delegate = self.delegate;
if ([delegate respondsToSelector:
@selector(rtcBridge:didUpdateFileTransfer:progress:sending:)]) {
@@ -326,6 +326,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
@Published var selectedDisplay = 0
@Published var audioEnabled = true
@Published private(set) var clipboardStatus = ""
@Published private(set) var controlStatus = ""
@Published private(set) var transferStatus = ""
@Published private(set) var transferProgress = 0.0
@Published private(set) var receivedFileURL: URL?
@@ -354,6 +355,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
private var shouldCaptureThumbnail = false
private var activeDisplayName = ""
private var remoteCursorSequence: UInt32?
private var remoteServiceAvailable = false
private var videoWasBackgrounded = false
private var videoRecoveryTask: Task<Void, Never>?
private var videoSettingsTimeoutTask: Task<Void, Never>?
@@ -654,6 +656,11 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
}
private func resetConnection() {
clipboardStatus = ""
controlStatus = ""
transferStatus = ""
transferProgress = 0
receivedFileURL = nil
resetRemoteHostInfo()
resetVideoSettings()
connectionFailureMessage = nil
@@ -692,6 +699,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
}
private func resetRemoteHostInfo() {
remoteServiceAvailable = false
remoteVersionCheckTask?.cancel()
remoteVersionCheckTask = nil
remoteVersionCheckGeneration = UUID()
@@ -1020,6 +1028,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
}
func sendClipboard() {
guard hasNetworkConsent, isConnected else { return }
guard let text = UIPasteboard.general.string, !text.isEmpty else {
clipboardStatus = "剪贴板中没有文本"
return
@@ -1033,11 +1042,27 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
}
func sendFile(_ url: URL) {
guard hasNetworkConsent, isConnected else { return }
transferStatus = "正在发送 \(url.lastPathComponent)"
transferProgress = 0
bridge.sendFile(at: url)
}
func sendSecureAttention() {
guard hasNetworkConsent, isConnected else { return }
guard remoteServiceAvailable else {
controlStatus = "Ctrl+Alt+Del 需要远端启用 Windows 系统服务"
return
}
bridge.sendSecureAttentionSequence()
controlStatus = "正在发送 Ctrl+Alt+Del…"
}
func fileImportFailed() {
transferStatus = "无法读取所选文件,请重试"
transferProgress = 0
}
func sendKeyStroke(_ keyCode: UInt) {
bridge.sendWindowsKeyCode(keyCode, isDown: true)
bridge.sendWindowsKeyCode(keyCode, isDown: false)
@@ -1296,10 +1321,21 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
clipboardStatus = "已接收远端剪贴板"
}
func rtcBridge(_ bridge: CrossDeskRTCBridge, didReceiveServiceAvailable available: Bool) {
guard hasNetworkConsent, isConnecting || isConnected else { return }
remoteServiceAvailable = available
}
func rtcBridge(_ bridge: CrossDeskRTCBridge, didSendSecureAttention success: Bool) {
guard hasNetworkConsent, isConnected else { return }
controlStatus = success ? "已发送 Ctrl+Alt+Del" : "指令发送失败,请重试"
}
func rtcBridge(_ bridge: CrossDeskRTCBridge,
didUpdateFileTransfer fileName: String,
progress: Double,
sending: Bool) {
guard hasNetworkConsent, isConnected else { return }
if progress < 0 {
transferStatus = "\(fileName) 传输失败"
transferProgress = 0
@@ -1311,6 +1347,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
}
func rtcBridge(_ bridge: CrossDeskRTCBridge, didReceiveFileAt fileURL: URL) {
guard hasNetworkConsent, isConnected else { return }
receivedFileURL = fileURL
transferStatus = "已接收 \(fileURL.lastPathComponent)"
transferProgress = 1
@@ -184,6 +184,8 @@ struct RemoteSessionView: View {
allowsMultipleSelection: false) { result in
if case let .success(urls) = result, let url = urls.first {
session.sendFile(url)
} else if case .failure = result {
session.fileImportFailed()
}
}
}
@@ -1205,13 +1207,21 @@ private struct FloatingSessionMenu: View {
}
FloatingControlButton(title: "Ctrl+Alt+Del",
symbol: "lock.trianglebadge.exclamationmark",
action: session.bridge.sendSecureAttentionSequence)
action: session.sendSecureAttention)
FloatingControlButton(title: "剪贴板", symbol: "doc.on.clipboard",
action: session.sendClipboard)
}
if !session.transferStatus.isEmpty || !session.clipboardStatus.isEmpty {
if !session.controlStatus.isEmpty {
Text(session.controlStatus).font(.caption2).foregroundStyle(.secondary)
}
if !session.clipboardStatus.isEmpty {
Text(session.clipboardStatus).font(.caption2).foregroundStyle(.secondary)
}
if !session.transferStatus.isEmpty {
HStack(spacing: 8) {
Text(!session.transferStatus.isEmpty
? session.transferStatus : session.clipboardStatus)
Text(session.transferStatus)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
+12 -2
View File
@@ -86,6 +86,12 @@ Keep signing enabled for simulator runs. Xcode uses a local ad-hoc signature
and supplies the application identity required by Keychain. Disabling signing
causes identity storage to fail with `-34018` before signaling can connect.
Simulator H.264 decoding uses VideoToolbox's available decoder, including its
software path. A simulator reporting no hardware H.264 support must still be
allowed to create a decoder; otherwise ICE can connect and receive video packets
while the app remains on the waiting-for-picture screen. Physical iOS devices
retain the hardware capability check.
The first simulator build compiles its own MiniRTC and dependencies; later builds
reuse `.xmake/packages-simulator` and `Vendor/iphonesimulator`. This runs the
native application, including version checks and signaling. Validate hardware
@@ -336,8 +342,8 @@ a data-channel problem:
16-bit PCM through `AVAudioEngine`.
3. **Clipboard:** copy a short text value on the remote desktop, then paste it
into a text field on the phone to verify reception. Text is limited to
128 KiB. The bridge supports sending local text, but the current UI has no
**Send local clipboard** action.
128 KiB. Tap **剪贴板** in the floating menu to send local clipboard text,
then paste it on the remote desktop. Empty or oversized text displays a hint.
4. **Displays:** open the display menu, switch every listed monitor, and check
that the selected monitor appears and the displayed resolution updates
after a new key frame.
@@ -346,6 +352,10 @@ a data-channel problem:
from the desktop. Progress is ACK-driven. Received files are stored in the
app's `Documents/Received` directory and can be exported with the share
button beside the transfer status or Finder's Files tab.
6. **Ctrl+Alt+Del:** the floating menu sends the existing Windows service
command when the remote advertises an available system service. Other hosts
display a service requirement hint. Clipboard, control and transfer feedback
are shown separately and cleared when the session ends.
Network status samples and caches displayed measurements once per second while
the panel is open; video-frame redraws reuse that snapshot. Traffic and connection RTT