mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-22 04:23:06 +08:00
[feat] complete mobile session controls and file transfer
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user