[fix] support Unicode text input on Windows hosts, refs #120

This commit is contained in:
dijunkun
2026-10-20 11:22:14 +08:00
parent 086da155f0
commit 4e5bf66734
6 changed files with 98 additions and 17 deletions
+1 -1
View File
@@ -151,7 +151,7 @@ gh secret set ANDROID_KEY_PASSWORD --repo kunkundi/crossdesk
身份按主机名和端口分开保存,备份与设备迁移不包含应用数据。密码不写入连接记录,仅在明确开启“保存密码”后写入独立加密存储;未开启时仅用于当前会话。连接成功通知只处理一次,重复的传输就绪回调不会再次保存密码或重建会话页面;密码存储拒绝空值覆盖。旧版本已被空值覆盖的密码无法恢复,遇到此记录时会提示重新输入一次,并保留“保存密码”开启状态。由于 MiniRTC 的诊断日志包含登录身份,Android 桥接层关闭其会话日志。身份存储损坏会显示错误,不会静默重新注册。 身份按主机名和端口分开保存,备份与设备迁移不包含应用数据。密码不写入连接记录,仅在明确开启“保存密码”后写入独立加密存储;未开启时仅用于当前会话。连接成功通知只处理一次,重复的传输就绪回调不会再次保存密码或重建会话页面;密码存储拒绝空值覆盖。旧版本已被空值覆盖的密码无法恢复,遇到此记录时会提示重新输入一次,并保留“保存密码”开启状态。由于 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 秒没有有效进展则报告失败。接收时校验大小并追踪完整字节范围,允许跨线程回调乱序,未完成的文件不会提供保存入口。同名文件分开存储,不覆盖已有文件;可通过“保存收到的文件”导出最近收到的文件,连续接收多个文件时应逐个保存。 “发送文件”调用系统文件选择器,后台读取所选文件后使用桌面端相同的 `file` / `file_feedback` 协议传输,支持空文件和中文文件名。每次发送一个文件,以 64 KiB 分块、最多 1 MiB 未确认数据限制发送队列;进度来自接收端确认,30 秒没有有效进展则报告失败。接收时校验大小并追踪完整字节范围,允许跨线程回调乱序,未完成的文件不会提供保存入口。同名文件分开存储,不覆盖已有文件;可通过“保存收到的文件”导出最近收到的文件,连续接收多个文件时应逐个保存。
@@ -477,7 +477,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
private boolean sendTextEdit(int count,String value){ private boolean sendTextEdit(int count,String value){
if(session==null)return false; if(session==null)return false;
if(session.textEdit(count,value))return true; 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 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);}} 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);}}
@@ -1,5 +1,8 @@
#include "keyboard_capturer.h" #include "keyboard_capturer.h"
#include <string>
#include <vector>
#include "rd_log.h" #include "rd_log.h"
#include "windows_input_injector.h" #include "windows_input_injector.h"
#include "windows_input_marker.h" #include "windows_input_marker.h"
@@ -388,4 +391,60 @@ int PlatformKeyboardCapturer::SendKeyboardCommand(int key_code, bool is_down,
return 0; 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<int>(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<INPUT> 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<ULONG_PTR>(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<WORD>(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<UINT>(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 } // namespace crossdesk
@@ -1,7 +1,7 @@
/* /*
* @Author: DI JUNKUN * @Author: DI JUNKUN
* @Date: 2024-11-22 * @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_ #ifndef _KEYBOARD_CAPTURER_H_
@@ -29,6 +29,8 @@ class PlatformKeyboardCapturer final : public KeyboardCapturer {
int Hook(OnKeyAction on_key_action, void* user_ptr) override; int Hook(OnKeyAction on_key_action, void* user_ptr) override;
int Unhook() override; int Unhook() override;
bool IsHookActive() const 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, int SendKeyboardCommand(int key_code, bool is_down, uint32_t scan_code = 0,
bool extended = false) override; bool extended = false) override;
@@ -13,26 +13,32 @@
namespace crossdesk { namespace crossdesk {
namespace { namespace {
DWORD InjectOnUserDesktop(INPUT input) { struct InputResult {
UINT sent;
DWORD error;
};
InputResult InjectOnUserDesktop(INPUT* inputs, UINT count) {
if (!IsCurrentSessionUnlocked()) { if (!IsCurrentSessionUnlocked()) {
return ERROR_REQUIRES_INTERACTIVE_WINDOWSTATION; return {0, ERROR_REQUIRES_INTERACTIVE_WINDOWSTATION};
} }
ScopedInteractiveDesktop desktop; ScopedInteractiveDesktop desktop;
if (!desktop.Bind(L"")) { if (!desktop.Bind(L"")) {
const DWORD error = GetLastError(); const DWORD error = GetLastError();
return error != ERROR_SUCCESS ? error : ERROR_GEN_FAILURE; return {0, error != ERROR_SUCCESS ? error : ERROR_GEN_FAILURE};
} }
if (!desktop.IsReceivingInput()) { 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. // Lock screens and UAC still require the session-checked service path.
if (_wcsicmp(desktop.name().c_str(), L"Default") != 0) { if (_wcsicmp(desktop.name().c_str(), L"Default") != 0) {
return ERROR_ACCESS_DENIED; return {0, ERROR_ACCESS_DENIED};
} }
SetLastError(ERROR_SUCCESS); 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(); const DWORD error = GetLastError();
return error != ERROR_SUCCESS ? error : ERROR_GEN_FAILURE; return {sent, error != ERROR_SUCCESS ? error : ERROR_GEN_FAILURE};
} }
class UserDesktopInputWorker { class UserDesktopInputWorker {
@@ -48,9 +54,9 @@ class UserDesktopInputWorker {
thread_.join(); thread_.join();
} }
DWORD Send(INPUT input) { InputResult Send(INPUT* inputs, UINT count) {
std::packaged_task<DWORD()> task( std::packaged_task<InputResult()> task(
[input] { return InjectOnUserDesktop(input); }); [inputs, count] { return InjectOnUserDesktop(inputs, count); });
auto result = task.get_future(); auto result = task.get_future();
{ {
std::lock_guard<std::mutex> lock(mutex_); std::lock_guard<std::mutex> lock(mutex_);
@@ -76,7 +82,7 @@ class UserDesktopInputWorker {
std::mutex mutex_; std::mutex mutex_;
std::condition_variable wake_; std::condition_variable wake_;
std::queue<std::packaged_task<DWORD()>> pending_; std::queue<std::packaged_task<InputResult()>> pending_;
bool stopping_ = false; bool stopping_ = false;
std::thread thread_; std::thread thread_;
}; };
@@ -84,8 +90,18 @@ class UserDesktopInputWorker {
} // namespace } // namespace
UINT SendInputOnUserDesktop(const INPUT& input) { UINT SendInputOnUserDesktop(const INPUT& input) {
INPUT copy = input;
return SendInputOnUserDesktop(&copy, 1);
}
UINT SendInputOnUserDesktop(INPUT* inputs, UINT count) {
if (!inputs || count == 0) {
SetLastError(ERROR_INVALID_PARAMETER);
return 0;
}
static UserDesktopInputWorker worker; 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) { if (error != ERROR_SUCCESS) {
// Include session identity even when Windows reports no useful SendInput // Include session identity even when Windows reports no useful SendInput
// error (for example UIPI). Keep repeated mouse-move failures bounded. // error (for example UIPI). Keep repeated mouse-move failures bounded.
@@ -102,7 +118,7 @@ UINT SendInputOnUserDesktop(const INPUT& input) {
} }
} }
SetLastError(error); SetLastError(error);
return error == ERROR_SUCCESS ? 1 : 0; return result.sent;
} }
} // namespace crossdesk } // namespace crossdesk
@@ -16,6 +16,10 @@ namespace crossdesk {
// worker's failure code in the calling thread's last-error value. // worker's failure code in the calling thread's last-error value.
UINT SendInputOnUserDesktop(const INPUT& input); 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 } // namespace crossdesk
#endif #endif