diff --git a/apps/android/README.md b/apps/android/README.md index 8e029a26..64c249e2 100644 --- a/apps/android/README.md +++ b/apps/android/README.md @@ -151,7 +151,7 @@ gh secret set ANDROID_KEY_PASSWORD --repo kunkundi/crossdesk 身份按主机名和端口分开保存,备份与设备迁移不包含应用数据。密码不写入连接记录,仅在明确开启“保存密码”后写入独立加密存储;未开启时仅用于当前会话。连接成功通知只处理一次,重复的传输就绪回调不会再次保存密码或重建会话页面;密码存储拒绝空值覆盖。旧版本已被空值覆盖的密码无法恢复,遇到此记录时会提示重新输入一次,并保留“保存密码”开启状态。由于 MiniRTC 的诊断日志包含登录身份,Android 桥接层关闭其会话日志。身份存储损坏会显示错误,不会静默重新注册。 -会话中的“剪贴板”发送用户输入的文本;收到的远端文本先留在会话内,点击“复制远端文本”才写入手机剪贴板。退出会话后清除会话内容。每次发送最多 128 KB。电脑键盘的 Ctrl / Win / Alt / Shift 可锁定组合键;macOS 的 Command 对应 Win。系统键盘通过 `supports_text_input` 检测远端能力,支持 UTF-8 的 macOS 被控端可直接输入中文、表情和听写文本;其他端或旧版使用 ASCII 按键回退,不支持的文本会提示使用剪贴板,不会先删除远端内容。输入法尚未确认的组字不会发送,自动纠错仅替换已发送文本的变化后缀;点击远端、收起、切换键盘或进入后台会清除本地输入上下文。 +会话中的“剪贴板”发送用户输入的文本;收到的远端文本先留在会话内,点击“复制远端文本”才写入手机剪贴板。退出会话后清除会话内容。每次发送最多 128 KB。电脑键盘的 Ctrl / Win / Alt / Shift 可锁定组合键;macOS 的 Command 对应 Win。系统键盘通过 `supports_text_input` 检测远端能力,支持 UTF-8 的 Windows 和 macOS 被控端可直接输入中文、表情和听写文本;其他端或旧版使用 ASCII 按键回退,不支持的文本会提示使用剪贴板,不会先删除远端内容。Windows 通过当前已解锁用户桌面的 Unicode 键盘事件输入文本,无需切换电脑输入法或覆盖剪贴板;锁屏和 UAC 安全桌面不接受此文本通道。Windows 被控端也必须更新到包含文本输入支持的版本,仅更新 Android 不会启用该能力。输入法尚未确认的组字不会发送,自动纠错仅替换已发送文本的变化后缀;点击远端、收起、切换键盘或进入后台会清除本地输入上下文。 “发送文件”调用系统文件选择器,后台读取所选文件后使用桌面端相同的 `file` / `file_feedback` 协议传输,支持空文件和中文文件名。每次发送一个文件,以 64 KiB 分块、最多 1 MiB 未确认数据限制发送队列;进度来自接收端确认,30 秒没有有效进展则报告失败。接收时校验大小并追踪完整字节范围,允许跨线程回调乱序,未完成的文件不会提供保存入口。同名文件分开存储,不覆盖已有文件;可通过“保存收到的文件”导出最近收到的文件,连续接收多个文件时应逐个保存。 diff --git a/apps/android/app/src/main/java/cn/crossdesk/mobile/MainActivity.java b/apps/android/app/src/main/java/cn/crossdesk/mobile/MainActivity.java index 8b1cb77b..88b12544 100644 --- a/apps/android/app/src/main/java/cn/crossdesk/mobile/MainActivity.java +++ b/apps/android/app/src/main/java/cn/crossdesk/mobile/MainActivity.java @@ -477,7 +477,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen private boolean sendTextEdit(int count,String value){ if(session==null)return false; if(session.textEdit(count,value))return true; - toast("当前远端不支持此文本,中文或表情需更新 macOS 被控端,或通过剪贴板发送");return false; + toast("当前远端不支持直接输入此文本,请使用支持文本输入的被控端版本,或通过剪贴板发送");return false; } private void tapKey(int code){if(session!=null){session.key(code,true);session.key(code,false);}} private void shortcut(int code){if(session!=null){int modifier=hostPlatform.equals("macos")?0x5B:0x11;session.key(modifier,true);tapKey(code);session.key(modifier,false);}} diff --git a/apps/desktop/src/platform/windows/input/keyboard/keyboard_capturer.cpp b/apps/desktop/src/platform/windows/input/keyboard/keyboard_capturer.cpp index 2824b6b9..2520ffc5 100644 --- a/apps/desktop/src/platform/windows/input/keyboard/keyboard_capturer.cpp +++ b/apps/desktop/src/platform/windows/input/keyboard/keyboard_capturer.cpp @@ -1,5 +1,8 @@ #include "keyboard_capturer.h" +#include +#include + #include "rd_log.h" #include "windows_input_injector.h" #include "windows_input_marker.h" @@ -388,4 +391,60 @@ int PlatformKeyboardCapturer::SendKeyboardCommand(int key_code, bool is_down, return 0; } + +int PlatformKeyboardCapturer::SendTextCommand(const std::string& text) { + if (text.empty() || text.size() > kMaxTextInputBytes || + text.find('\0') != std::string::npos) { + return -1; + } + // Validate the entire UTF-8 commit before injecting any characters. + const int byte_count = static_cast(text.size()); + const int length = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, + text.data(), byte_count, nullptr, 0); + if (length == 0) return -1; + std::wstring characters(length, L'\0'); + if (MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, text.data(), + byte_count, characters.data(), length) != length) { + return -1; + } + + std::vector inputs; + inputs.reserve(characters.size() * 2); + for (size_t index = 0; index < characters.size(); ++index) { + const wchar_t character = characters[index]; + INPUT input{}; + input.type = INPUT_KEYBOARD; + input.ki.dwExtraInfo = static_cast(kInjectedKeyboardInputMarker); + if (character == L'\r' || character == L'\n' || character == L'\t') { + input.ki.wVk = character == L'\t' ? VK_TAB : VK_RETURN; + if (character == L'\r' && index + 1 < characters.size() && + characters[index + 1] == L'\n') { + ++index; + } + } else { + // VK_PACKET carries UTF-16 units, including both halves of emoji. + input.ki.wScan = static_cast(character); + input.ki.dwFlags = KEYEVENTF_UNICODE; + } + inputs.push_back(input); + input.ki.dwFlags |= KEYEVENTF_KEYUP; + inputs.push_back(input); + } + + // One batch keeps surrogate pairs and key releases together with the text. + const UINT count = static_cast(inputs.size()); + const UINT sent = SendInputOnUserDesktop(inputs.data(), count); + if (sent != count) { + const DWORD error = GetLastError(); + if (sent < count && sent % 2 != 0) { + SendInputOnUserDesktop(inputs[sent]); // Best-effort release. + } + LOG_WARN( + "SendInput failed for committed text, sent={}, expected={}, err={}", + sent, count, error); + SetLastError(error); + return -1; + } + return 0; +} } // namespace crossdesk diff --git a/apps/desktop/src/platform/windows/input/keyboard/keyboard_capturer.h b/apps/desktop/src/platform/windows/input/keyboard/keyboard_capturer.h index efb35efd..d74fea41 100644 --- a/apps/desktop/src/platform/windows/input/keyboard/keyboard_capturer.h +++ b/apps/desktop/src/platform/windows/input/keyboard/keyboard_capturer.h @@ -1,7 +1,7 @@ /* * @Author: DI JUNKUN * @Date: 2024-11-22 - * Copyright (c) 2024 by DI JUNKUN, All Rights Reserved. + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. */ #ifndef _KEYBOARD_CAPTURER_H_ @@ -29,6 +29,8 @@ class PlatformKeyboardCapturer final : public KeyboardCapturer { int Hook(OnKeyAction on_key_action, void* user_ptr) override; int Unhook() override; bool IsHookActive() const override; + bool SupportsTextInput() const override { return true; } + int SendTextCommand(const std::string& text) override; int SendKeyboardCommand(int key_code, bool is_down, uint32_t scan_code = 0, bool extended = false) override; diff --git a/apps/desktop/src/platform/windows/input/windows_input_injector.cpp b/apps/desktop/src/platform/windows/input/windows_input_injector.cpp index 10b8c2b6..b86cf40c 100644 --- a/apps/desktop/src/platform/windows/input/windows_input_injector.cpp +++ b/apps/desktop/src/platform/windows/input/windows_input_injector.cpp @@ -13,26 +13,32 @@ namespace crossdesk { namespace { -DWORD InjectOnUserDesktop(INPUT input) { +struct InputResult { + UINT sent; + DWORD error; +}; + +InputResult InjectOnUserDesktop(INPUT* inputs, UINT count) { if (!IsCurrentSessionUnlocked()) { - return ERROR_REQUIRES_INTERACTIVE_WINDOWSTATION; + return {0, ERROR_REQUIRES_INTERACTIVE_WINDOWSTATION}; } ScopedInteractiveDesktop desktop; if (!desktop.Bind(L"")) { const DWORD error = GetLastError(); - return error != ERROR_SUCCESS ? error : ERROR_GEN_FAILURE; + return {0, error != ERROR_SUCCESS ? error : ERROR_GEN_FAILURE}; } if (!desktop.IsReceivingInput()) { - return ERROR_REQUIRES_INTERACTIVE_WINDOWSTATION; + return {0, ERROR_REQUIRES_INTERACTIVE_WINDOWSTATION}; } // Lock screens and UAC still require the session-checked service path. if (_wcsicmp(desktop.name().c_str(), L"Default") != 0) { - return ERROR_ACCESS_DENIED; + return {0, ERROR_ACCESS_DENIED}; } SetLastError(ERROR_SUCCESS); - if (SendInput(1, &input, sizeof(INPUT)) == 1) return ERROR_SUCCESS; + const UINT sent = SendInput(count, inputs, sizeof(INPUT)); + if (sent == count) return {sent, ERROR_SUCCESS}; const DWORD error = GetLastError(); - return error != ERROR_SUCCESS ? error : ERROR_GEN_FAILURE; + return {sent, error != ERROR_SUCCESS ? error : ERROR_GEN_FAILURE}; } class UserDesktopInputWorker { @@ -48,9 +54,9 @@ class UserDesktopInputWorker { thread_.join(); } - DWORD Send(INPUT input) { - std::packaged_task task( - [input] { return InjectOnUserDesktop(input); }); + InputResult Send(INPUT* inputs, UINT count) { + std::packaged_task task( + [inputs, count] { return InjectOnUserDesktop(inputs, count); }); auto result = task.get_future(); { std::lock_guard lock(mutex_); @@ -76,7 +82,7 @@ class UserDesktopInputWorker { std::mutex mutex_; std::condition_variable wake_; - std::queue> pending_; + std::queue> pending_; bool stopping_ = false; std::thread thread_; }; @@ -84,8 +90,18 @@ class UserDesktopInputWorker { } // namespace UINT SendInputOnUserDesktop(const INPUT& input) { + INPUT copy = input; + return SendInputOnUserDesktop(©, 1); +} + +UINT SendInputOnUserDesktop(INPUT* inputs, UINT count) { + if (!inputs || count == 0) { + SetLastError(ERROR_INVALID_PARAMETER); + return 0; + } static UserDesktopInputWorker worker; - const DWORD error = worker.Send(input); + const InputResult result = worker.Send(inputs, count); + const DWORD error = result.error; if (error != ERROR_SUCCESS) { // Include session identity even when Windows reports no useful SendInput // error (for example UIPI). Keep repeated mouse-move failures bounded. @@ -102,7 +118,7 @@ UINT SendInputOnUserDesktop(const INPUT& input) { } } SetLastError(error); - return error == ERROR_SUCCESS ? 1 : 0; + return result.sent; } } // namespace crossdesk diff --git a/apps/desktop/src/platform/windows/input/windows_input_injector.h b/apps/desktop/src/platform/windows/input/windows_input_injector.h index c36ac234..735f2790 100644 --- a/apps/desktop/src/platform/windows/input/windows_input_injector.h +++ b/apps/desktop/src/platform/windows/input/windows_input_injector.h @@ -16,6 +16,10 @@ namespace crossdesk { // worker's failure code in the calling thread's last-error value. UINT SendInputOnUserDesktop(const INPUT& input); +// Synchronously injects one ordered batch. The caller owns the buffer until +// this function returns, including while the worker is using it. +UINT SendInputOnUserDesktop(INPUT* inputs, UINT count); + } // namespace crossdesk -#endif \ No newline at end of file +#endif