7 Commits
Author SHA1 Message Date
郑博文 8ffe19cd5f test(agent-core): v3 分发路径单测——默认后端不 panic、截断契约、空输入
新功能必须带测试,agent-core 此前无测试模块,v3 新增的 text 默认实现与
截断分支正好落在盲区里:

- 后端不实现 text() 时(Linux/X11 的真实形态)必须被正常调用且不 panic;
- 超 MAX_TEXT_UNITS 必须以截断后的长度到注入侧,未超限原样;
- 空 Text 仍会下发到后端——被控端不假设对端守规矩,线格式输入按不可信处理。
2026-10-30 12:11:28 +08:00
郑博文 936986a940 test(android-2b): 文本注入真边界工具——viewer-core 直发 Text,靶窗自报收到内容
text_check 走的是产品同一条路径(Session::send_text → v3 线格式 → agent
SendInput KEYEVENTF_UNICODE),手机掉线也能先把 Windows 半段验实。
参数是十进制 code unit 列表而不是自然语言字符串:命令行到 Rust 的编码往返
会引入与本轮无关的变量,由 PowerShell 从 .NET 字符串取 char[] 更忠实。

proof 脚本刻意用自有 WinForms 靶窗而非记事本:Win11 记事本所有标签页同进程,
本机当时开着用户文档,往里注入等于改用户文件。靶窗自报文本(写文件),
判据不依赖截图和 OCR。注入前后各断言一次前台窗口——SendInput 打的是前台,
靶窗不在前台时的注入会进别的应用,宁可失败也不误注入。
2026-10-30 12:09:48 +08:00
郑博文 6c28a3f7e3 feat(android): 阶段 2b——连接界面 + IME 文本通道 + 控制键扫描码
两态 UI:连接态是 host/port/code 表单(原生 View 零依赖,intent extras 保留且
优先,构建脚本不受影响),成功后切远控态。只存 host/port;验证码不落盘(用户裁决)。
返回键分态:IME 显示时先收键盘,否则断开回表单——否则一次返回关键盘又杀会话。

IME 走 P1:onCheckIsTextEditor + BaseInputConnection 子类。commitText 整段
经 v3 Text 通道上行(char[]→GetCharArrayRegion,UTF-16 无损);控制键映射
Set-1 扫描码(值与 viewer/keymap.rs 同源,右 Shift 按官方文档不置 extended)。
composing 不上远端(U4),getText* 回空串——本地没有也不伪造远端缓冲区。

键盘按钮显式 showSoftInput,不自动弹(vivo 行为不一致 + 挡画面)。
RemoteSurfaceView 补 isFocusableInTouchMode:没有它触摸态拿不到焦点,
InputConnection 根本不会建立(2a 弹不出键盘的根因之一)。
2026-10-30 11:51:01 +08:00
郑博文 55cc8edbc1 fix(agent): 文本注入改为单次 SendInput 批量——按官方文档消除事件被打散的风险
实读 Keyboard Input Overview 后修正两处凭记忆的实现:
1. 原文承诺「单次调用的事件不会被其他输入插入」,此前拆成 down 批 + up 批
   两次调用,两次之间可能夹进真实键鼠事件,代理对存在被拆开的风险。合并为
   一次提交(全部 down 紧接全部 up)。
2. KEYBDINPUT 文档「只能与 KEYEVENTF_KEYUP 组合」那句按推断写成结论;
   改为如实标注为推断,并写明真机失败时的退路(只发 UNICODE|KEYUP 单事件)。
2026-10-30 11:41:54 +08:00
郑博文 174a3f8ac0 feat(viewer-core,android-ffi): 主控端文本上行与 JNI 入口
Session::send_text 收 &[u16] 而不是 &str:整条链(Java 字符串 → JNI → 线格式 →
Windows 注入)的原生表示都是 UTF-16,任何一次 UTF-8 往返都有损,故中间层不做转换。
空输入与超限在本层拦掉,超限走 clamp_text_units 并打日志。

nativeSendText 的参数用 char[](JObject 收再转 JCharArray),经 GetCharArrayRegion
按 u16 直接拷贝。不用 JString + get_string:jni 0.21 那条路走 Modified-UTF-8,
IME 提交边界的落单代理项会在往返里丢。JNI 取数失败必须 eprintln,不能静默——
用户对着远端打字最坏的情况是「看起来发送了」。
2026-10-30 11:41:53 +08:00
郑博文 5075888861 feat(agent): v3 文本注入用 KEYEVENTF_UNICODE,一次批量提交成对代理项
Windows 侧按官方 KEYBDINPUT 语义实现:置 KEYEVENTF_UNICODE 时 wVk 必须为 0、
wScan 即要送出的字符,系统合成 VK_PACKET 经 TranslateMessage 产出 WM_CHAR。
down 批与 up 批分两次 SendInput,且让同一段文本的 code unit 在输入队列里相邻,
使代理对能被远端合成一个字符(最终由真机 emoji 验收定论)。

agent-core 的 InputInjector::text 用默认实现记日志而非静默忽略:Linux 后端
本轮不做文本注入(X11 需 keysym/XKB 映射,另立循环),但用户对着 Linux
被控端打字不能「什么都没发生还没痕迹」。Windows 端走自己的 handle(),
不经 agent-core,故超限截断日志在两侧同源补齐。
日志只打长度,不打用户输入内容。
2026-10-30 11:29:25 +08:00
郑博文 38df106fb6 feat(common): 协议 v3——新增 InputEvent::Text 文本注入通道
安卓软键盘的中文候选词结果不是扫描码,扫描码通道对中文天然不通,必须
另开一条按字符注入的通道。载体选 Vec<u16>(UTF-16 code unit)而非 String:
Windows 注入侧与 Java 字符串原生都是 UTF-16,中间任何一次 UTF-8 往返都会
把 IME 提交边界的落单代理项变成替换字符。

InputEvent 因携带 Vec 不再是 Copy,调用方改用 clone;三端必须同步升级,
旧端由 Auth 握手显式拒绝(这正是 PROTO_VERSION 递增的目的)。
新增 6 个单测覆盖 roundtrip、落单代理项、上限边界与 1MiB 长度闸共存。
2026-10-30 11:29:13 +08:00
13 changed files with 1282 additions and 57 deletions

No files matched your search

+154
View File
@@ -48,6 +48,21 @@ pub trait InputInjector: Send + Sync + 'static {
fn mouse_button(&self, b: MouseButton, down: bool);
fn wheel(&self, dy: i32);
fn key(&self, scancode: u16, extended: bool, down: bool);
/// 协议 v3 的文本注入(UTF-16 code unit)。
///
/// **默认实现刻意不注入任何东西,但必须留下可见痕迹**:Linux 后端本轮未实现
/// 文本注入(X11 需要 keysym/XKB 映射,与 Windows 的 `KEYEVENTF_UNICODE`
/// 不同构,另立循环)。全局规则要求异常路径有可见后果——用户对着 Linux 被控端
/// 打字时「什么都没发生」不能是静默的,日志必须说明是没实现。
///
/// 日志纪律:只打印长度,**不打印用户输入内容**。
fn text(&self, units: &[u16]) {
eprintln!(
"[agent-core] 文本注入未实现(忽略 {} 个 UTF-16 code unit)",
units.len()
);
}
}
/// 把一条协议输入事件分发到注入后端。
@@ -59,6 +74,17 @@ pub fn dispatch_input(inj: &dyn InputInjector, ev: InputEvent) {
InputEvent::Wheel { dy } => inj.wheel(dy),
InputEvent::KeyDown { scancode, extended } => inj.key(scancode, extended, true),
InputEvent::KeyUp { scancode, extended } => inj.key(scancode, extended, false),
InputEvent::Text { units } => {
// 上限在前端截断,截断必须可见(否则用户以为长文本已送达)。
let (units, truncated) = common::clamp_text_units(units);
if truncated {
eprintln!(
"[agent-core] 文本超过上限,已截断到 {} 个 code unit",
common::MAX_TEXT_UNITS
);
}
inj.text(&units);
}
}
}
@@ -483,3 +509,131 @@ where
println!("[agent] session closed");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
/// 记录调用轨迹的注入后端:验证分发与截断,不碰任何真实输入。
#[derive(Default)]
struct MockInjector {
calls: Mutex<Vec<String>>,
}
impl MockInjector {
fn take(&self) -> Vec<String> {
let mut g = self.calls.lock().unwrap();
std::mem::take(&mut g)
}
}
impl InputInjector for MockInjector {
fn mouse_move(&self, x: u16, y: u16) {
self.calls.lock().unwrap().push(format!("move {x},{y}"));
}
fn mouse_button(&self, b: MouseButton, down: bool) {
self.calls.lock().unwrap().push(format!("btn {b:?} {down}"));
}
fn wheel(&self, dy: i32) {
self.calls.lock().unwrap().push(format!("wheel {dy}"));
}
fn key(&self, scancode: u16, extended: bool, down: bool) {
self.calls
.lock()
.unwrap()
.push(format!("key {scancode:#x} ext={extended} {down}"));
}
// 刻意**不**实现 text():让默认实现进入测试面,验证「后端没做文本注入
// 时也必须被 dispatch_input 正常调用、且不 panic」——Linux 端正是这个形态。
}
/// 实现了 text() 的后端,用来验证截断后的长度真的传给了注入侧。
struct TextCapableInjector {
received: Mutex<Vec<usize>>,
}
impl InputInjector for TextCapableInjector {
fn mouse_move(&self, _x: u16, _y: u16) {}
fn mouse_button(&self, _b: MouseButton, _down: bool) {}
fn wheel(&self, _dy: i32) {}
fn key(&self, _scancode: u16, _extended: bool, _down: bool) {}
fn text(&self, units: &[u16]) {
self.received.lock().unwrap().push(units.len());
}
}
#[test]
fn dispatch_routes_each_event_kind() {
let inj = MockInjector::default();
dispatch_input(&inj, InputEvent::MouseMove { x: 1, y: 2 });
dispatch_input(&inj, InputEvent::MouseDown(MouseButton::Right));
dispatch_input(&inj, InputEvent::Wheel { dy: -120 });
dispatch_input(
&inj,
InputEvent::KeyDown {
scancode: 0x48,
extended: true,
},
);
let calls = inj.take();
assert_eq!(
calls,
vec![
"move 1,2",
"btn Right true",
"wheel -120",
"key 0x48 ext=true true",
]
);
}
/// v3 关键回归:`Text` 必须被分发,且**后端没有实现 text() 时不能 panic**。
/// 这是 Linux/X11 后端的真实形态——用户对着 Linux 被控端打字会走到这里。
#[test]
fn text_event_reaches_default_backend_without_panic() {
let inj = MockInjector::default();
dispatch_input(
&inj,
InputEvent::Text {
units: vec![0x4F60, 0x597D],
},
);
// 默认实现只记日志,不产生调用记录;此处断言"没炸"即为通过。
assert!(inj.take().is_empty());
}
/// 截断契约:超过 `MAX_TEXT_UNITS` 的输入必须以截断后的长度到达注入侧。
/// 不截断会让长粘贴把远端注入队列刷爆;截断不告知则是静默丢数据。
#[test]
fn text_is_clamped_to_protocol_cap() {
let inj = TextCapableInjector {
received: Mutex::new(vec![]),
};
dispatch_input(
&inj,
InputEvent::Text {
units: vec![0x61; common::MAX_TEXT_UNITS + 500],
},
);
dispatch_input(
&inj,
InputEvent::Text {
units: vec![0x62; 10],
},
);
let got = inj.received.lock().unwrap().clone();
assert_eq!(got, vec![common::MAX_TEXT_UNITS, 10], "超限截断、未超限原样");
}
/// 空 Text 仍会调用后端(是否忽略由后端决定):主控侧 `send_text` 已拦掉空输入,
/// 但被控端不能假设对端守规矩——线格式输入一律按不可信处理。
#[test]
fn empty_text_still_dispatches() {
let inj = TextCapableInjector {
received: Mutex::new(vec![]),
};
dispatch_input(&inj, InputEvent::Text { units: vec![] });
assert_eq!(inj.received.lock().unwrap().clone(), vec![0]);
}
}
+79 -4
View File
@@ -1,11 +1,12 @@
//! 输入注入:SendInput 实现(鼠标归一化绝对坐标 + 扫描码键盘)。
//! 输入注入:SendInput 实现(鼠标归一化绝对坐标 + 扫描码键盘 + v3 文本注入)。
use common::{InputEvent, MouseButton};
use windows::Win32::UI::Input::KeyboardAndMouse::{
SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, INPUT_MOUSE, KEYBDINPUT, KEYEVENTF_EXTENDEDKEY,
KEYEVENTF_KEYUP, KEYEVENTF_SCANCODE, MOUSEEVENTF_ABSOLUTE, MOUSEEVENTF_MIDDLEDOWN,
MOUSEEVENTF_MIDDLEUP, MOUSEEVENTF_MOVE, MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP,
MOUSEEVENTF_VIRTUALDESK, MOUSEEVENTF_WHEEL, MOUSEINPUT, VIRTUAL_KEY, MOUSE_EVENT_FLAGS,
KEYEVENTF_KEYUP, KEYEVENTF_SCANCODE, KEYEVENTF_UNICODE, MOUSEEVENTF_ABSOLUTE,
MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP, MOUSEEVENTF_MOVE, MOUSEEVENTF_RIGHTDOWN,
MOUSEEVENTF_RIGHTUP, MOUSEEVENTF_VIRTUALDESK, MOUSEEVENTF_WHEEL, MOUSEINPUT, VIRTUAL_KEY,
MOUSE_EVENT_FLAGS,
};
use windows::Win32::UI::WindowsAndMessaging::{
GetSystemMetrics, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN,
@@ -73,6 +74,67 @@ pub fn virtual_desktop() -> (i32, i32, i32, i32) {
}
}
/// v3 文本注入:把一段 UTF-16 code unit 作为键盘事件送入前台应用。
///
/// 语义来源(实读,不凭记忆):
/// [KEYBDINPUT (winuser.h)](https://learn.microsoft.com/en-us/windows/win32/api/winuser/ns-winuser-keybdinput)
/// - `KEYEVENTF_UNICODE`(0x0004):`wVk` **必须为 0**,`wScan` 即要送出的 Unicode 字符;
/// 系统合成 `VK_PACKET` 按键事件,经 `TranslateMessage` 转为带该字符的 `WM_CHAR`。
/// - 文档原文是 "This flag can only be combined with the KEYEVENTF_KEYUP flag"。
/// 本项目**仍先发不带 KEYUP 的 down**(与现有 `send_key` 的 down/up 成对形态一致),
/// 把那句读作「不能与 SCANCODE/EXTENDEDKEY 混用」是**我的推断,文档没有明说**。
/// 若真机验收发现 down 事件被系统丢弃(表现为一个字符进两次或不进),
/// 退路是只发 `KEYEVENTF_UNICODE | KEYEVENTF_KEYUP` 单事件——改一处 `mk` 即可。
///
/// 为什么一次性批量提交(而非逐字符一对 down/up):
/// 代理对(U+1F600 = D83D DE00)的两个 code unit 必须在输入队列里**相邻**,
/// 远端才会把它们合成成一个字符。逐字符成对(down,up,down,up)会在两个
/// down 之间插入 up 事件;Windows 对 VK_PACKET 孤立代理项的缓冲规则文档
/// 没有描述,所以这里选择让两个 down 严格相邻的形态,由真机 emoji 验收(V7)定论。
///
/// 为什么是**一次** `SendInput` 调用(而不是 down 批 + up 批两次):
/// [Keyboard Input Overview](https://learn.microsoft.com/en-us/windows/win32/inputdev/about-keyboard-input)
/// 的 Simulating Input 一节明确——单调用注入的一批事件"系统确保不会被其他输入事件
/// 打散"(the system ensures that no other input events intermingle with the
/// simulated events),两次调用之间则可能插入真实键盘/鼠标事件,代理对就有被打散风险。
/// 于是把 N 个 down + N 个 up 拼成一个数组一次提交,顺序仍是全部 down 先于全部 up。
///
/// 上限由调用方(`common::clamp_text_units`,4096)保证;这里对空输入直接返回,
/// 因为 `SendInput(0)` 会返回 0(失败)且没有任何注入语义。
fn send_text(units: &[u16]) {
if units.is_empty() {
return;
}
let mk = |scan: u16, up: bool| INPUT {
r#type: INPUT_KEYBOARD,
Anonymous: INPUT_0 {
ki: KEYBDINPUT {
wVk: VIRTUAL_KEY(0), // KEYEVENTF_UNICODE 要求 wVk=0(官方文档)
wScan: scan,
dwFlags: if up {
KEYEVENTF_UNICODE | KEYEVENTF_KEYUP
} else {
KEYEVENTF_UNICODE
},
time: 0,
dwExtraInfo: 0,
},
},
};
// 一次提交:全部 down,紧接全部 up。
let inputs: Vec<INPUT> = units
.iter()
.map(|&u| mk(u, false))
.chain(units.iter().map(|&u| mk(u, true)))
.collect();
let n = unsafe { SendInput(&inputs, std::mem::size_of::<INPUT>() as i32) };
// 可见失败后果:SendInput 返回「实际插入的事件数」,不等于请求数即失败
// (常见原因:桌面被 Secure UI 锁定、被 BlockInput 拦截)。
if n as usize != inputs.len() {
eprintln!("[input] SendInput 文本注入不完整: {n}/{}", inputs.len());
}
}
pub fn handle(ev: InputEvent) {
match ev {
InputEvent::MouseMove { x, y } => {
@@ -105,5 +167,18 @@ pub fn handle(ev: InputEvent) {
}
InputEvent::KeyDown { scancode, extended } => send_key(scancode, extended, false),
InputEvent::KeyUp { scancode, extended } => send_key(scancode, extended, true),
InputEvent::Text { units } => {
// 上限截断必须可见(与 agent-core::dispatch_input 同口径;Windows 端
// 走自己的 handle(),不经过 agent-core,所以这里同源补齐——R-3 教训)。
let (units, truncated) = common::clamp_text_units(units);
if truncated {
eprintln!(
"[input] 文本超过上限,已截断到 {} 个 code unit",
common::MAX_TEXT_UNITS
);
}
// 只打印长度:注入日志不得含用户输入内容(安全红线)
send_text(&units);
}
}
}
+40 -1
View File
@@ -20,7 +20,7 @@
//! 会话结束则整个返回 null)。
use common::InputEvent;
use jni::objects::{JClass, JString};
use jni::objects::{JCharArray, JClass, JObject, JString};
use jni::sys::{jbyteArray, jint, jlong, jstring};
use jni::JNIEnv;
use std::sync::{Arc, Mutex};
@@ -262,6 +262,45 @@ pub extern "system" fn Java_com_verify_remote_RustBridge_nativeSendKey(
});
}
/// 协议 v3:文本上行。`units` 是 Kotlin 侧的 `CharArray`(UTF-16 code unit)。
///
/// 为什么不用 `JString`:jni 0.21 的 `get_string` 走 Modified-UTF-8 → Rust `String`,
/// 落单代理项(IME 提交边界常见)会在这次往返里丢失。`char[]` 经
/// `GetCharArrayRegion` 按 u16 直接拷贝,零转换(`jchar = u16`,见 jni-sys-0.3.1/lib.rs:15)。
///
/// 截断与空输入的判断在 `viewer_core::Session::send_text` 里做,与被控端同一口径。
/// 失败必须有可见后果:JNI 取数失败走 eprintln(Android 上落 stderr→logcat),
/// 不静默返回成功——用户对着远端打字却没字,最坏的就是「看起来发送了」。
#[no_mangle]
pub extern "system" fn Java_com_verify_remote_RustBridge_nativeSendText<'local>(
// 不需要 `mut`:jni 0.21.1 的 `get_array_length` / `get_char_array_region`
// 都是 `&self`(jnienv.rs:1652 / :1887),与 `new_string` 之类的 `&mut self` 不同。
env: JNIEnv<'local>,
_class: JClass<'local>,
handle: jlong,
units: JObject<'local>,
) {
if units.as_raw().is_null() {
// Kotlin 侧不会传 null,但 JNI 入口的对端数据一律不信任(全局安全规则)
return;
}
let arr = JCharArray::from(units);
let len = match env.get_array_length(&arr) {
Ok(n) if n > 0 => n as usize,
Ok(_) => return,
Err(e) => {
eprintln!("[android-ffi] get_array_length failed: {e}");
return;
}
};
let mut buf = vec![0u16; len];
if let Err(e) = env.get_char_array_region(&arr, 0, &mut buf) {
eprintln!("[android-ffi] get_char_array_region failed: {e}");
return;
}
with_session(handle, |s| s.send_text(&buf));
}
#[no_mangle]
pub extern "system" fn Java_com_verify_remote_RustBridge_nativeRequestKeyframe(
_env: JNIEnv,
@@ -1,29 +1,50 @@
package com.verify.remote
import android.app.Activity
import android.content.Context
import android.os.Bundle
import android.text.InputType
import android.util.Log
import android.view.Gravity
import android.view.KeyEvent
import android.view.SurfaceHolder
import android.view.View
import android.view.inputmethod.InputMethodManager
import android.widget.Button
import android.widget.EditText
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.LinearLayout.LayoutParams
import android.widget.TextView
import kotlin.concurrent.thread
/**
* 安卓主控端(阶段 2a):连接被控端 → MediaCodec 解码 → SurfaceView 显示 + 触摸操作。
* 安卓主控端(阶段 2b):正式连接界面 + 远控画面 + 触摸操作 + 软键盘文本输入。
*
* 两态:
* - 连接态:host / port / code 表单 + 状态文本(阶段 1/2a 只能靠 `am start --es host`
* 传参,本界面对它补位;intent extras 仍保留且优先级更高,构建脚本继续可用)。
* - 远控态:letterbox 画面 + 底部状态/操作条(帧数、RTT、诊断、键盘、断开)。
*
* 线程模型(与 android-ffi 的锁纪律对应):
* - UI 线程:触摸/按键 → nativeSend*(入队不阻塞);连接/断开
* - UI 线程:触摸/按键/IME 提交 → nativeSend*(入队不阻塞);连接/断开
* - 解码线程:nativeNextFrame 阻塞取帧 → H264Decoder.queue
* - 断开顺序:**先置 stopDecoding 并 join 解码线程,再 destroy**
* (Rust 侧明确:解码线程存活期间 destroy 是 UB)
*
* 布局:SurfaceView 按视频宽高比 letterbox 居中(RemoteSurfaceView),
* 触摸坐标只在画面区域内产生,与远端归一化坐标一一对应。
* 持久化:只存 host/port(连接成功后落盘);**验证码不落盘**(2026-10-30 用户裁决)。
*/
class MainActivity : Activity(), RemoteSurfaceView.Listener {
private lateinit var surfaceView: RemoteSurfaceView
private lateinit var statusView: TextView
private lateinit var connectPanel: LinearLayout
private lateinit var remotePanel: LinearLayout
private lateinit var hostInput: EditText
private lateinit var portInput: EditText
private lateinit var codeInput: EditText
private lateinit var connectButton: Button
private lateinit var connectStatus: TextView
@Volatile
private var handle = 0L
@@ -32,12 +53,16 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
private var stopDecoding = false
private var decodeThread: Thread? = null
/** 连接中标志:防重复点击起出第二个会话(句柄未回填前 handle 仍是 0) */
@Volatile
private var connecting = false
@Volatile
private var remoteW = 0
@Volatile
private var remoteH = 0
/** 最近一次触摸事件(诊断用:logcat 被限流,状态栏是唯一可靠通道) */
/** 最近一次输入事件(触摸/IME)诊断:logcat 被限流,状态栏是唯一可靠通道 */
@Volatile
private var lastTouchDbg = "-"
@@ -52,18 +77,100 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
override fun surfaceCreated(holder: SurfaceHolder) {}
override fun surfaceChanged(h: SurfaceHolder, f: Int, w: Int, ht: Int) {}
override fun surfaceDestroyed(holder: SurfaceHolder) {
// Surface 没了解码器输出会失效:断开,重进 App 重连
// Surface 没了解码器输出会失效:断开,回到连接界面重连
disconnect()
}
})
}
val root = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setBackgroundColor(0xFF000000.toInt())
}
connectPanel = buildConnectPanel()
remotePanel = buildRemotePanel()
val lp = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f)
root.addView(connectPanel, lp)
root.addView(remotePanel, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f))
setContentView(root)
showConnectState("未连接")
// intent 入口优先级高于表单默认值:build-android.ps1 与调试脚本依赖它
val hostExtra = intent?.getStringExtra("host")
if (!hostExtra.isNullOrEmpty()) {
hostInput.setText(hostExtra)
portInput.setText(intent.getIntExtra("port", 47800).toString())
intent.getStringExtra("code")?.let { codeInput.setText(it) }
startConnect()
}
}
// ---- 连接态 UI ----
private fun buildConnectPanel(): LinearLayout {
val prefs = getSharedPreferences(PREFS, Context.MODE_PRIVATE)
// 只回填 host/port(上次**成功连接**过的值);验证码每次手输
hostInput = editText(
"被控端 IP",
InputType.TYPE_CLASS_TEXT,
prefs.getString("host", "").orEmpty(),
)
portInput = editText(
"端口",
InputType.TYPE_CLASS_NUMBER,
prefs.getInt("port", 47800).toString(),
)
// 验证码刻意不从 prefs 读(不落盘)
codeInput = editText("验证码", InputType.TYPE_CLASS_TEXT, "")
connectButton = Button(this).apply {
text = "连接"
setOnClickListener { startConnect() }
}
connectStatus = TextView(this).apply {
textSize = 13f
setTextColor(0xFFBBBBBB.toInt())
setPadding(0, dp(8), 0, 0)
}
val title = TextView(this).apply {
text = "连接被控端"
textSize = 18f
setTextColor(0xFFFFFFFF.toInt())
setPadding(0, dp(16), 0, dp(8))
}
return LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(dp(20), dp(24), dp(20), dp(24))
addView(title)
addView(hostInput)
addView(portInput)
addView(codeInput)
addView(connectButton)
addView(connectStatus)
}
}
private fun editText(hint: String, type: Int, initial: String) = EditText(this).apply {
inputType = type
this.hint = hint
setText(initial)
textSize = 16f
setTextColor(0xFFFFFFFF.toInt())
setHintTextColor(0xFF777777.toInt())
// 表单文字不能被画面抢占焦点
isSingleLine = true
}
// ---- 远控态 UI ----
private fun buildRemotePanel(): LinearLayout {
statusView = TextView(this).apply {
textSize = 12f
setPadding(20, 12, 20, 12)
setPadding(dp(12), dp(6), dp(12), dp(6))
setBackgroundColor(0xCC000000.toInt())
setTextColor(0xFFFFFFFF.toInt())
maxLines = 3
}
// 画面居中、黑边填充。
// 用 FrameLayout + MATCH_PARENT(不是 WRAP_CONTENT):FrameLayout 会把
// 子视图的 WRAP_CONTENT 当 MATCH_PARENT 处理,onMeasure 里拿到的
@@ -71,49 +178,69 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
// (2026-10-30 实测:视图被撑成 1080x2001,触摸按 2001 归一化 → 坐标全错)。
// MATCH_PARENT + gravity=center 时父容器传 AT_MOST,onMeasure 的
// setMeasuredDimension 才真正决定尺寸,再被 gravity 居中摆放。
val stage = android.widget.FrameLayout(this).apply {
val stage = FrameLayout(this).apply {
setBackgroundColor(0xFF000000.toInt())
addView(
surfaceView,
android.widget.FrameLayout.LayoutParams(
android.widget.FrameLayout.LayoutParams.MATCH_PARENT,
android.widget.FrameLayout.LayoutParams.MATCH_PARENT,
android.view.Gravity.CENTER
)
FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT,
Gravity.CENTER,
),
)
}
val root = android.widget.LinearLayout(this).apply {
orientation = android.widget.LinearLayout.VERTICAL
addView(stage, android.widget.LinearLayout.LayoutParams(
android.widget.LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f))
val keyboardButton = Button(this).apply {
text = "键盘"
setOnClickListener { showKeyboard() }
}
val disconnectButton = Button(this).apply {
text = "断开"
setOnClickListener { disconnect() }
}
val bar = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
addView(keyboardButton, LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f))
addView(disconnectButton, LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f))
}
return LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
visibility = View.GONE
addView(stage, LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f))
// 固定高度而非 WRAP_CONTENT:与 weight=1 的兄弟共存时,
// WRAP_CONTENT 可能被压成 0(实测 bounds=[0,0][0,0],状态栏不可见)
addView(statusView, android.widget.LinearLayout.LayoutParams(
android.widget.LinearLayout.LayoutParams.MATCH_PARENT,
(48 * resources.displayMetrics.density).toInt()))
}
setContentView(root)
refreshStatus()
intent?.getStringExtra("host")?.let { host ->
connect(host, intent.getIntExtra("port", 47800), intent.getStringExtra("code") ?: "123456")
addView(statusView, LayoutParams(LayoutParams.MATCH_PARENT, dp(64)))
addView(bar, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT))
}
}
/** 状态栏统一刷新:连接态 + 帧数/RTT + 最近触摸诊断(logcat 被限流,这是唯一通道) */
@Volatile
private var lastFrames = 0L
@Volatile
private var lastRtt = 0L
private fun showConnectState(msg: String) {
connecting = false
remotePanelVisible(false)
connectStatus.text = msg
}
private fun refreshStatus() {
if (handle == 0L) {
statusView.text = "未连接|am start ... --es host <IP> --ei port 47800 --es code t"
return
}
statusView.text = buildString {
append("已连接 ${remoteW}x$remoteH 帧=$lastFrames RTT=${lastRtt}ms\n")
append("触摸: $lastTouchDbg")
}
private fun showRemoteState() {
remotePanelVisible(true)
connectPanel.visibility = View.GONE
}
private fun remotePanelVisible(show: Boolean) {
connectPanel.visibility = if (show) View.GONE else View.VISIBLE
remotePanel.visibility = if (show) View.VISIBLE else View.GONE
}
/**
* 显式唤起软键盘。不做自动弹:自动弹会挡画面,且 vivo ROM 行为不一致。
* SurfaceView 已在触摸模式下可聚焦(见 RemoteSurfaceView.init),
* 否则系统不会建立 InputConnection。
*/
private fun showKeyboard() {
if (handle == 0L) return
surfaceView.requestFocus()
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.showSoftInput(surfaceView, InputMethodManager.SHOW_IMPLICIT)
lastTouchDbg = "showSoftInput 已请求(焦点=${surfaceView.hasFocus()})"
refreshStatus()
}
// ---- RemoteSurfaceView.Listener ----
@@ -133,19 +260,65 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
if (h != 0L) RustBridge.nativeSendWheel(h, dy)
}
/** 返回键 = 断开(不退出 App,便于重连) */
/** 协议 v3:IME 提交的整段文本(UTF-16 code unit,含落单代理项原样透传) */
override fun sendText(units: CharArray) {
val h = handle
if (h != 0L) RustBridge.nativeSendText(h, units)
}
override fun sendKey(key: WinKey, down: Boolean) {
val h = handle
if (h != 0L) {
RustBridge.nativeSendKey(h, key.scancode, if (key.extended) 1 else 0, if (down) 1 else 0)
}
}
override fun isReadyForInput(): Boolean = handle != 0L
/**
* 返回键:软键盘开着时先收键盘(否则一次返回同时关键盘和会话);
* 远控态已连接 = 断开回连接界面;连接态 = 交还系统(退出 App)。
*/
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
if (keyCode == KeyEvent.KEYCODE_BACK && handle != 0L) {
disconnect()
return true
if (keyCode == KeyEvent.KEYCODE_BACK) {
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
// isAcceptingText = 当前有 IME 连接且正在接收输入(API 17+)
if (imm.isAcceptingText) {
imm.hideSoftInputFromWindow(surfaceView.windowToken, 0)
return true
}
if (handle != 0L) {
disconnect()
return true
}
}
return super.onKeyDown(keyCode, event)
}
// ---- 连接 / 解码 ----
private fun connect(host: String, port: Int, code: String) {
if (handle != 0L) return
private fun startConnect() {
if (connecting || handle != 0L) return
val host = hostInput.text.toString().trim()
val code = codeInput.text.toString()
val port = portInput.text.toString().trim().toIntOrNull()
when {
host.isEmpty() -> {
connectStatus.text = "请填写被控端 IP"
return
}
port == null || port !in 1..65535 -> {
connectStatus.text = "端口必须是 1..65535"
return
}
code.isEmpty() -> {
connectStatus.text = "请填写验证码"
return
}
}
connecting = true
connectButton.isEnabled = false
showRemoteState()
statusView.text = "连接 $host:$port …"
thread(name = "connect") {
try {
@@ -156,7 +329,15 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
handle = h
remoteW = w
remoteH = ht
connecting = false
runOnUiThread {
connectButton.isEnabled = true
// 只有认证成功的会话才值得记住;验证码不落盘(用户裁决)
getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit()
.putString("host", host)
.putInt("port", port)
.apply()
if (w > 0 && ht > 0) {
surfaceView.videoAspectRatio = w.toFloat() / ht
}
@@ -165,7 +346,12 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
}
} catch (e: Exception) {
Log.e(TAG, "connect failed", e)
runOnUiThread { statusView.text = "连接失败: ${e.message}" }
val msg = e.message ?: "连接失败"
runOnUiThread {
connectButton.isEnabled = true
handle = 0L
showConnectState("连接失败:$msg")
}
}
}
}
@@ -222,7 +408,7 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
lastFrames = 0
lastRtt = 0
RustBridge.destroy(h)
refreshStatus()
showConnectState("已断开")
}
override fun onDestroy() {
@@ -230,7 +416,34 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
disconnect()
}
/** 状态栏统一刷新:连接态 + 帧数/RTT + 最近输入诊断(logcat 被限流,这是唯一通道) */
@Volatile
private var lastFrames = 0L
@Volatile
private var lastRtt = 0L
private fun refreshStatus() {
if (handle == 0L) {
statusView.text = if (connecting) statusView.text else "未连接"
return
}
statusView.text = buildString {
append("已连接 v$PROTO_LABEL ${remoteW}x$remoteH 帧=$lastFrames RTT=${lastRtt}ms\n")
append("输入: $lastTouchDbg")
}
}
private fun dp(v: Int) = (v * resources.displayMetrics.density).toInt()
companion object {
private const val TAG = "MainActivity"
private const val PREFS = "connect"
/**
* 与协议版本保持一致:远控画面状态栏显示当前握手版本(方案 R5 要求)。
* 值随 `common::PROTO_VERSION` 人工同步——Android 侧不做线格式解析,
* 版本由 Rust 握手校验,这里只作显示与排障用。
*/
private const val PROTO_LABEL = "3"
}
}
@@ -0,0 +1,108 @@
package com.verify.remote
import android.view.KeyEvent
import android.view.View
import android.view.inputmethod.BaseInputConnection
import android.view.inputmethod.EditorInfo
/**
* P1 IME 通道(loop-2b-plan §5,用户已裁决):**本地没有编辑缓冲,远端才是权威**。
*
* 三条约定(与方案一致):
* 1. `commitText` = 用户确认的整段文本 → 直接走协议 v3 `Text` 通道(布局无关、支持中文);
* 2. `sendKeyEvent` = 硬键盘/控制键 → 既有扫描码通道(Enter/Backspace/方向键…);
* 3. `deleteSurroundingText`:本地不知道远端内容,`before>0 && after==0`
* 是 IME 退格最常见形态 → 发对应次数的 Backspace;其余形态记录后放弃(U3)。
*
* composing(拼音未确认串)不显示到远端(U4):`setComposingText` 吞掉但返回 true,
* 让 IME 维持本地组合状态,候选确认后仍会走 `commitText`。
*
* 日志纪律:debug 通道只报**长度与事件类型**,不报用户输入内容(安全红线)。
*/
class RemoteInputConnection(
target: View,
private val sendText: (CharArray) -> Unit,
private val sendKey: (WinKey, Boolean) -> Unit,
private val debug: (String) -> Unit,
) : BaseInputConnection(target, false) {
override fun commitText(text: CharSequence, newCursorPosition: Int): Boolean {
val units = text.toString().toCharArray()
if (units.isEmpty()) return true
sendText(units)
debug("commit ${units.size}u")
return true
}
override fun sendKeyEvent(event: KeyEvent): Boolean {
if (event.action == KeyEvent.ACTION_MULTIPLE) {
val chars: String? = event.characters
if (!chars.isNullOrEmpty()) {
sendText(chars.toCharArray())
debug("multiple ${chars.length}u")
}
return true
}
val key = WinScancode.of(event.keyCode)
if (key == null) {
debug("IME 未映射键 code=${event.keyCode}")
return false
}
sendKey(key, event.action == KeyEvent.ACTION_DOWN)
return true
}
override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean =
deleteBackspaces(beforeLength, afterLength)
override fun deleteSurroundingTextInCodePoints(beforeLength: Int, afterLength: Int): Boolean =
deleteBackspaces(beforeLength, afterLength)
private fun deleteBackspaces(before: Int, after: Int): Boolean {
if (before == 0 && after == 0) return true
if (after != 0) {
// U3:forward-delete 与「先删 after 再删 before」的形态本地无远端真值可依
debug("delete($before,$after):after>0 形态未支持")
return false
}
val bk = WinScancode.of(KeyEvent.KEYCODE_DEL) ?: return false
repeat(before.coerceAtMost(MAX_BACKSPACE)) {
sendKey(bk, true)
sendKey(bk, false)
}
debug("backspace x$before")
return true
}
/** 吞掉 composing 但返回 true:IME 保持本地组合流程,候选确认仍会 commitText。 */
override fun setComposingText(text: CharSequence, newCursorPosition: Int): Boolean = true
override fun setComposingRegion(start: Int, end: Int): Boolean = true
override fun finishComposingText(): Boolean = true
// 本地无可信缓冲区(远端才是权威),一律回空串——比 null 温和,多数 IME 兼容。
override fun getTextBeforeCursor(n: Int, flags: Int): CharSequence = ""
override fun getTextAfterCursor(n: Int, flags: Int): CharSequence = ""
override fun getSelectedText(flags: Int): CharSequence = ""
override fun getCursorCapsMode(reqModes: Int): Int = 0
override fun performEditorAction(actionCode: Int): Boolean {
if (actionCode == EditorInfo.IME_ACTION_DONE) {
// 软键盘「完成/回车」在远控语义里就是 Enter
WinScancode.of(KeyEvent.KEYCODE_ENTER)?.let {
sendKey(it, true)
sendKey(it, false)
}
debug("IME_ACTION_DONE→Enter")
return true
}
debug("editorAction $actionCode 忽略")
return true
}
companion object {
/** 单次退格请求上限:防 IME 异常大值刷爆消息队列(与 agent 侧 WHEEL_MAX_CLICKS 同思路) */
private const val MAX_BACKSPACE = 64
}
}
@@ -1,13 +1,17 @@
package com.verify.remote
import android.content.Context
import android.text.InputType
import android.view.MotionEvent
import android.view.SurfaceView
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputConnection
import kotlin.math.abs
import kotlin.math.hypot
/**
* 保持视频宽高比的 SurfaceView(letterbox),并把触摸手势映射为远端鼠标事件。
* 保持视频宽高比的 SurfaceView(letterbox),把触摸手势映射为远端鼠标事件,
* 并作为软键盘的宿主(协议 v3 文本通道 + 扫描码控制键)。
*
* 为什么视图尺寸必须等于画面尺寸:触摸坐标按视图宽高归一化到远端 0..=65535。
* 若视图被拉伸(阶段 1 用 MATCH_PARENT),画面与视图不再一一对应,手指位置与
@@ -21,6 +25,11 @@ import kotlin.math.hypot
* - 双指上下滑动 = 滚轮
*
* 刻意不做「手指即光标」的绝对定位:手指会完全挡住目标,桌面精细操作不可用。
*
* **IME 接线(阶段 2b)**:本视图是文本编辑器(`onCheckIsTextEditor`),
* 软键盘的提交经 `RemoteInputConnection` 上行。`isFocusableInTouchMode` 必须为
* true,否则触摸态拿不到焦点、系统不建立 InputConnection(阶段 2a 只有
* `isFocusable`,这就是当时软键盘弹不出来的原因之一)。
*/
class RemoteSurfaceView(context: Context) : SurfaceView(context) {
@@ -29,6 +38,12 @@ class RemoteSurfaceView(context: Context) : SurfaceView(context) {
fun sendMouseMove(x: Int, y: Int)
fun sendMouseButton(button: Int, down: Boolean)
fun sendWheel(dy: Int)
/** 协议 v3:一段 UTF-16 code unit(IME 提交结果) */
fun sendText(units: CharArray)
/** 控制键(Enter/Backspace/方向键等)走扫描码通道 */
fun sendKey(key: WinKey, down: Boolean)
/** 连接态:只有已连接才声明自己是文本编辑器,避免未连接时误弹键盘 */
fun isReadyForInput(): Boolean
}
var listener: Listener? = null
@@ -60,6 +75,32 @@ class RemoteSurfaceView(context: Context) : SurfaceView(context) {
init {
isFocusable = true
// IME 需要触摸态焦点;没有这行,点「键盘」按钮系统不会建立输入连接
isFocusableInTouchMode = true
}
// ---- IME 通道 ----
override fun onCheckIsTextEditor(): Boolean = listener?.isReadyForInput() == true
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? {
val l = listener ?: return null
if (!l.isReadyForInput()) return null
// TYPE_CLASS_TEXT:让输入法给出正常键盘(含中文候选)。
// 常量从 InputType 引用而非 EditorInfo:Kotlin 不保证经实现类名访问接口常量。
// 刻意不加 TYPE_TEXT_FLAG_NO_SUGGESTIONS——中文输入依赖候选词。
outAttrs.inputType = InputType.TYPE_CLASS_TEXT
// 抑制全屏/extract 模式抢走画面(API 29 上两个标志都还在,具体效果真机验证,见 U4/U5)
outAttrs.imeOptions =
EditorInfo.IME_FLAG_NO_EXTRACT_UI or EditorInfo.IME_FLAG_NO_FULLSCREEN
// 右下动作键文案。字段名是 actionLabel(javap android-34 核实,不是 imeActionLabel)
outAttrs.actionLabel = "发送"
return RemoteInputConnection(
target = this,
sendText = { l.sendText(it) },
sendKey = { k, down -> l.sendKey(k, down) },
debug = { msg -> onDebug?.invoke(msg) },
)
}
/**
@@ -26,6 +26,12 @@ object RustBridge {
external fun nativeSendMouseButton(handle: Long, button: Int, down: Int)
external fun nativeSendWheel(handle: Long, dy: Int)
external fun nativeSendKey(handle: Long, scancode: Int, extended: Int, down: Int)
/**
* 协议 v3 文本上行。参数刻意用 `CharArray`(UTF-16 code unit)而不是 `String`:
* jni 侧 `get_string` 走 Modified-UTF-8 往返,IME 提交边界的落单代理项会在
* 往返中丢失;`char[]` 经 `GetCharArrayRegion` 按 u16 原样拷贝。
*/
external fun nativeSendText(handle: Long, units: CharArray)
external fun nativeRequestKeyframe(handle: Long)
external fun nativeDestroy(handle: Long)
@@ -0,0 +1,126 @@
package com.verify.remote
import android.view.KeyEvent
/**
* Android KeyEvent.KEYCODE_* → Windows PS/2 Set-1 扫描码 + extended 标志。
*
* **扫描码值直接取自桌面 viewer 自己的映射(`viewer/src/keymap.rs`,项目内部事实)**,
* 不是凭记忆写的;Android 侧一律用命名常量 `KeyEvent.KEYCODE_*` 引用,
* 数值由编译器解析(已用 `javap -constants android.jar` 验证这些常量在 API 34 存在)。
*
* 分工(协议 v3 之后):
* - 可打印字符优先走 **Text 通道**(`KeyEvent.unicodeChar` / IME `commitText`),
* 布局无关、中文可用;
* - 本表服务控制键(Enter/Tab/Backspace/方向键/功能键)与物理键盘,
* 走 `KeyDown/KeyUp` 扫描码通道。
*
* 不做 Shift/Ctrl 组合状态跟踪:软键盘的大写字母由 IME 直接 commit 字符,
* 不需要本地修饰键语义。物理键盘组合键(Ctrl+C 等)登记为后续事项。
*/
data class WinKey(val scancode: Int, val extended: Boolean)
object WinScancode {
fun of(keyCode: Int): WinKey? = when (keyCode) {
// 字母区(Set-1 make code,同 keymap.rs)
KeyEvent.KEYCODE_A -> WinKey(0x1E, false)
KeyEvent.KEYCODE_B -> WinKey(0x30, false)
KeyEvent.KEYCODE_C -> WinKey(0x2E, false)
KeyEvent.KEYCODE_D -> WinKey(0x20, false)
KeyEvent.KEYCODE_E -> WinKey(0x12, false)
KeyEvent.KEYCODE_F -> WinKey(0x21, false)
KeyEvent.KEYCODE_G -> WinKey(0x22, false)
KeyEvent.KEYCODE_H -> WinKey(0x23, false)
KeyEvent.KEYCODE_I -> WinKey(0x17, false)
KeyEvent.KEYCODE_J -> WinKey(0x24, false)
KeyEvent.KEYCODE_K -> WinKey(0x25, false)
KeyEvent.KEYCODE_L -> WinKey(0x26, false)
KeyEvent.KEYCODE_M -> WinKey(0x32, false)
KeyEvent.KEYCODE_N -> WinKey(0x31, false)
KeyEvent.KEYCODE_O -> WinKey(0x18, false)
KeyEvent.KEYCODE_P -> WinKey(0x19, false)
KeyEvent.KEYCODE_Q -> WinKey(0x10, false)
KeyEvent.KEYCODE_R -> WinKey(0x13, false)
KeyEvent.KEYCODE_S -> WinKey(0x1F, false)
KeyEvent.KEYCODE_T -> WinKey(0x14, false)
KeyEvent.KEYCODE_U -> WinKey(0x16, false)
KeyEvent.KEYCODE_V -> WinKey(0x2F, false)
KeyEvent.KEYCODE_W -> WinKey(0x11, false)
KeyEvent.KEYCODE_X -> WinKey(0x2D, false)
KeyEvent.KEYCODE_Y -> WinKey(0x15, false)
KeyEvent.KEYCODE_Z -> WinKey(0x2C, false)
// 数字主键区(1..9, 0;Android keycode 不连续,逐个写)
KeyEvent.KEYCODE_1 -> WinKey(0x02, false)
KeyEvent.KEYCODE_2 -> WinKey(0x03, false)
KeyEvent.KEYCODE_3 -> WinKey(0x04, false)
KeyEvent.KEYCODE_4 -> WinKey(0x05, false)
KeyEvent.KEYCODE_5 -> WinKey(0x06, false)
KeyEvent.KEYCODE_6 -> WinKey(0x07, false)
KeyEvent.KEYCODE_7 -> WinKey(0x08, false)
KeyEvent.KEYCODE_8 -> WinKey(0x09, false)
KeyEvent.KEYCODE_9 -> WinKey(0x0A, false)
KeyEvent.KEYCODE_0 -> WinKey(0x0B, false)
// 符号区(US 布局物理位置,同 keymap.rs)
KeyEvent.KEYCODE_SPACE -> WinKey(0x39, false)
KeyEvent.KEYCODE_ENTER -> WinKey(0x1C, false)
KeyEvent.KEYCODE_DEL -> WinKey(0x0E, false) // Backspace
KeyEvent.KEYCODE_FORWARD_DEL -> WinKey(0x53, true) // Delete(扩展)
KeyEvent.KEYCODE_TAB -> WinKey(0x0F, false)
KeyEvent.KEYCODE_ESCAPE -> WinKey(0x01, false)
KeyEvent.KEYCODE_CAPS_LOCK -> WinKey(0x3A, false)
KeyEvent.KEYCODE_GRAVE -> WinKey(0x29, false)
KeyEvent.KEYCODE_MINUS -> WinKey(0x0C, false)
KeyEvent.KEYCODE_EQUALS -> WinKey(0x0D, false)
KeyEvent.KEYCODE_LEFT_BRACKET -> WinKey(0x1A, false)
KeyEvent.KEYCODE_RIGHT_BRACKET -> WinKey(0x1B, false)
KeyEvent.KEYCODE_BACKSLASH -> WinKey(0x2B, false)
KeyEvent.KEYCODE_SEMICOLON -> WinKey(0x27, false)
KeyEvent.KEYCODE_APOSTROPHE -> WinKey(0x28, false)
KeyEvent.KEYCODE_COMMA -> WinKey(0x33, false)
KeyEvent.KEYCODE_PERIOD -> WinKey(0x34, false)
KeyEvent.KEYCODE_SLASH -> WinKey(0x35, false)
// 修饰键
KeyEvent.KEYCODE_SHIFT_LEFT -> WinKey(0x2A, false)
// 右 Shift **不是**扩展键:微软 Keyboard Input Overview 明确
// "The right-hand Shift key is not considered an extended key either;
// it has a separate scan code (0x36) instead."(本会话实读该页核实)
KeyEvent.KEYCODE_SHIFT_RIGHT -> WinKey(0x36, false)
KeyEvent.KEYCODE_CTRL_LEFT -> WinKey(0x1D, false)
KeyEvent.KEYCODE_CTRL_RIGHT -> WinKey(0x1D, true)
KeyEvent.KEYCODE_ALT_LEFT -> WinKey(0x38, false)
KeyEvent.KEYCODE_ALT_RIGHT -> WinKey(0x38, true)
KeyEvent.KEYCODE_META_LEFT -> WinKey(0x5B, true)
KeyEvent.KEYCODE_META_RIGHT -> WinKey(0x5C, true)
// 导航区(全部扩展键)
KeyEvent.KEYCODE_DPAD_UP -> WinKey(0x48, true)
KeyEvent.KEYCODE_DPAD_DOWN -> WinKey(0x50, true)
KeyEvent.KEYCODE_DPAD_LEFT -> WinKey(0x4B, true)
KeyEvent.KEYCODE_DPAD_RIGHT -> WinKey(0x4D, true)
KeyEvent.KEYCODE_MOVE_HOME -> WinKey(0x47, true)
KeyEvent.KEYCODE_MOVE_END -> WinKey(0x4F, true)
KeyEvent.KEYCODE_PAGE_UP -> WinKey(0x49, true)
KeyEvent.KEYCODE_PAGE_DOWN -> WinKey(0x51, true)
KeyEvent.KEYCODE_INSERT -> WinKey(0x52, true)
// 功能键(F11/F12 的 0x57/0x58 与 linux-agent keycode_of 注释同源)
KeyEvent.KEYCODE_F1 -> WinKey(0x3B, false)
KeyEvent.KEYCODE_F2 -> WinKey(0x3C, false)
KeyEvent.KEYCODE_F3 -> WinKey(0x3D, false)
KeyEvent.KEYCODE_F4 -> WinKey(0x3E, false)
KeyEvent.KEYCODE_F5 -> WinKey(0x3F, false)
KeyEvent.KEYCODE_F6 -> WinKey(0x40, false)
KeyEvent.KEYCODE_F7 -> WinKey(0x41, false)
KeyEvent.KEYCODE_F8 -> WinKey(0x42, false)
KeyEvent.KEYCODE_F9 -> WinKey(0x43, false)
KeyEvent.KEYCODE_F10 -> WinKey(0x44, false)
KeyEvent.KEYCODE_F11 -> WinKey(0x57, false)
KeyEvent.KEYCODE_F12 -> WinKey(0x58, false)
else -> null
}
}
+127 -3
View File
@@ -16,7 +16,10 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt};
///
/// - v1:初版(无版本字段,隐式)
/// - v2:`Auth`/`AuthOk` 携带版本;帧头增加 `seq`/`keyframe`
pub const PROTO_VERSION: u16 = 2;
/// - v3:新增 `InputEvent::Text`(文本注入通道,UTF-16 code unit)。
/// `InputEvent` 因携带 `Vec<u16>` 不再是 `Copy`;三端必须同步升级,
/// 旧端由 `Auth` 握手显式拒绝。
pub const PROTO_VERSION: u16 = 3;
/// 控制通道消息(双向)
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -47,9 +50,12 @@ pub enum MouseButton {
Middle,
}
/// 输入事件。鼠标坐标为虚拟桌面归一化 0..=65535;
/// 输入事件。鼠标坐标为**被采集显示器**(主屏)归一化 0..=65535
/// (协议 v2 语义,v3 未改;`agent/src/input.rs` 的注入空间与此一致,见个人层 lessons R-4);
/// 按键为 Set-1 扫描码,extended 标记扩展键(方向键/Home 等,需要 EXTENDEDKEY 标志)。
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
///
/// v3 起本类型不再 `Copy`:`Text` 携带堆上 `Vec<u16>`。需要复制的地方用 `.clone()`。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum InputEvent {
MouseMove { x: u16, y: u16 },
MouseDown(MouseButton),
@@ -57,6 +63,42 @@ pub enum InputEvent {
Wheel { dy: i32 },
KeyDown { scancode: u16, extended: bool },
KeyUp { scancode: u16, extended: bool },
/// v3 新增:**文本注入通道**。`units` 为 UTF-16 code unit 序列。
///
/// 为什么不是 `String`:Windows 注入侧(`KEYEVENTF_UNICODE`)与 Java 字符串的
/// 原生表示都是 UTF-16 code unit;用 `String` 要走 UTF-8 往返,遇到 IME 提交边界
/// 产生的落单代理项会**有损**。`Vec<u16>` 忠实透传,由接收端按 Win32 语义处理。
///
/// 为什么整段携带、而不是逐 code unit 一条消息:一次 IME 候选确认常是多个汉字;
/// 代理对(emoji 等增补平面字符)**必须在同一次注入批次里成对提交**才能被目标
/// 应用合成成一个字符,拆开注入必现乱码。
///
/// 上限 [`MAX_TEXT_UNITS`]:超长输入截断且两侧打警告日志(不静默丢弃)。
/// 另一道防线是 [`read_control`] 的 1 MiB 消息长度闸。
///
/// 日志纪律:中间层与注入端只允许打印 `units.len()`,**不得**打印用户输入内容。
Text { units: Vec<u16> },
}
/// [`InputEvent::Text`] 单次携带的 code unit 上限。
///
/// 与 `read_control` 的 1 MiB 消息闸是两道独立防线:前者防恶意对端把接收端分配打爆,
/// 后者防合法但异常长的 IME 提交把注入队列刷爆。4096 unit ≈ 8 KiB,
/// 远超正常 IME 一次提交(数十 unit),又足够小使单次 `SendInput` 批量仍可接受。
pub const MAX_TEXT_UNITS: usize = 4096;
/// 把文本输入截断到 [`MAX_TEXT_UNITS`],返回 `(可注入的 units, 是否发生截断)`。
///
/// 调用方**必须**在截断为真时打印日志(可见后果);本函数只负责机械截断,
/// 不自己打印——因为 `common` 同时被主控端与被控端复用,日志口径由调用方决定。
pub fn clamp_text_units(units: Vec<u16>) -> (Vec<u16>, bool) {
if units.len() > MAX_TEXT_UNITS {
let mut trimmed = units;
trimmed.truncate(MAX_TEXT_UNITS);
(trimmed, true)
} else {
(units, false)
}
}
/// 视频帧头。载荷为 H.264 Annex-B 码流(含 00 00 00 01 起始码)。
@@ -269,6 +311,88 @@ mod tests {
}
}
// ---- v3 文本注入通道 ----
/// 版本守卫:v3 是本轮契约变更的登记点,改动必须显式经过这里。
#[test]
fn proto_version_is_three_after_text_event() {
assert_eq!(PROTO_VERSION, 3, "新增 InputEvent::Text 后必须递增到 3");
}
#[test]
fn text_event_roundtrips_utf16_units() {
// 「你好」+ 一个 emoji 的代理对(U+1F600 = D83D DE00)
let units: Vec<u16> = vec![0x4F60, 0x597D, 0xD83D, 0xDE00];
let m = ControlMsg::Input(InputEvent::Text {
units: units.clone(),
});
let bytes = encode_control(&m);
let back: ControlMsg = bincode::deserialize(&bytes[4..]).unwrap();
match back {
ControlMsg::Input(InputEvent::Text { units: got }) => assert_eq!(got, units),
other => panic!("期望 Input::Text,实际 {other:?}"),
}
}
/// 落单代理项必须原样往返:IME 提交边界可能给出孤立高/低代理项,
/// 用 `String` 承载会在这里有损(这正是选 `Vec<u16>` 的理由)。
#[test]
fn text_event_keeps_lone_surrogate() {
let units = vec![0xD83Du16];
let m = ControlMsg::Input(InputEvent::Text { units: units.clone() });
let bytes = encode_control(&m);
let back: ControlMsg = bincode::deserialize(&bytes[4..]).unwrap();
match back {
ControlMsg::Input(InputEvent::Text { units: got }) => assert_eq!(got, units),
other => panic!("期望 Input::Text,实际 {other:?}"),
}
}
#[test]
fn text_units_clamped_at_boundary() {
// 边界:恰好上限不截断;超一个才截断
let exact = vec![0x61u16; MAX_TEXT_UNITS];
let (kept, truncated) = clamp_text_units(exact);
assert_eq!(kept.len(), MAX_TEXT_UNITS);
assert!(!truncated);
let over = vec![0x61u16; MAX_TEXT_UNITS + 1];
let (kept, truncated) = clamp_text_units(over);
assert_eq!(kept.len(), MAX_TEXT_UNITS);
assert!(truncated, "超限必须返回截断标志,让调用方打印警告");
let (empty, truncated) = clamp_text_units(vec![]);
assert!(empty.is_empty());
assert!(!truncated);
}
/// 空 `Text` 也必须能往返(IME 可能提交空串;调用方负责决定忽略还是报错)。
#[test]
fn text_event_empty_roundtrips() {
let m = ControlMsg::Input(InputEvent::Text { units: vec![] });
let bytes = encode_control(&m);
let back: ControlMsg = bincode::deserialize(&bytes[4..]).unwrap();
match back {
ControlMsg::Input(InputEvent::Text { units }) => assert!(units.is_empty()),
other => panic!("期望 Input::Text,实际 {other:?}"),
}
}
/// 上限内的 `Text` 必须能通过 `read_control` 的长度闸;
/// 反向验证:伪造一个超限的声明长度必须在分配前被拒。
#[tokio::test]
async fn text_within_cap_passes_control_length_gate() {
let (mut a, mut b) = duplex(1 << 20);
let msg = ControlMsg::Input(InputEvent::Text {
units: vec![0x4F60; MAX_TEXT_UNITS],
});
write_control(&mut a, &msg).await.unwrap();
match read_control(&mut b).await.unwrap() {
ControlMsg::Input(InputEvent::Text { units }) => assert_eq!(units.len(), MAX_TEXT_UNITS),
other => panic!("期望 Input::Text,实际 {other:?}"),
}
}
// ---- 视频帧头 ----
#[test]
+167
View File
@@ -0,0 +1,167 @@
# 2b V6/V7 proof: v3 text injection into a dedicated WinForms target window,
# read back from that process itself.
# ASCII-only source (PS 5.1 decodes BOM-less .ps1 as GBK; see personal lessons).
# Non-ASCII payloads are built from explicit code points, never literals.
#
# Usage:
# powershell -ExecutionPolicy Bypass -File tools\text_injection_proof.ps1
#
# Chain: viewer-core text_check --units <utf16> -> v3 agent SendInput
# KEYEVENTF_UNICODE -> foreground WinForms TextBox -> self-reported text.
#
# Why NOT Notepad (2026-10-30): Windows 11 Notepad hosts all tabs in ONE
# process and this dev box had a user document open in it - injecting "into
# Notepad" would mean injecting into the user's real file. The WinForms target
# is a separate process we own, opened on an empty field.
#
# Foreground handling (measured, not assumed): a form with TopMost=true that
# is activated at startup KEEPS foreground for seconds; AppActivate /
# SetForegroundWindow called from here actually STEALS it away. So we assert
# the target is foreground before injecting, and refuse to inject otherwise -
# a stray injection would land in whatever window was foreground.
$ErrorActionPreference = "Stop"
$repo = "D:\working\projects\yuankong\remote"
$tmp = "$env:TEMP\remote-text-proof"
$base = "D:\working\environment"
$env:RUSTUP_HOME = "$base\rust"
$env:CARGO_HOME = "$base\cargo"
$env:PATH = "$base\mingw64\bin;$base\cargo\bin;" + $env:PATH
Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
public static class WinFg {
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
}
"@
New-Item -ItemType Directory -Force -Path $tmp | Out-Null
$pidFile = "$tmp\target.pid"
$stopFile = "$tmp\stop.flag"
$resultFile = "$tmp\received.txt"
Remove-Item $pidFile,$stopFile,$resultFile -ErrorAction SilentlyContinue
function Wait-File {
param([string]$Path, [int]$TimeoutMs = 15000)
$tries = 0
while (-not (Test-Path $Path) -and $tries -lt ($TimeoutMs / 150)) {
Start-Sleep -Milliseconds 150; $tries++
}
if (-not (Test-Path $Path)) { throw "file did not appear: $Path" }
}
function Read-Received {
Wait-File $resultFile
$bytes = [System.IO.File]::ReadAllBytes($resultFile)
$text = [System.Text.Encoding]::UTF8.GetString($bytes)
if ($text.Length -gt 0 -and $text[0] -eq [char]0xFEFF) { $text = $text.Substring(1) }
return $text
}
function CodePointsToString([int[]]$cps) {
-join ($cps | ForEach-Object { [char]$_ })
}
function Show-Text([string]$s) {
# ASCII-safe console preview: non-ASCII rendered as U+XXXX
($s.ToCharArray() | ForEach-Object {
if ([int]$_ -ge 32 -and [int]$_ -lt 127) { $_ } else { "U+{0:X4}" -f [int]$_ }
}) -join ""
}
function Assert-TargetForeground([IntPtr]$hwnd) {
$fg = [WinFg]::GetForegroundWindow()
if ($fg -ne $hwnd) {
throw "foreground is $fg, not target $hwnd - refusing to inject (would hit another app)"
}
}
function Send-Units([int[]]$cps, [IntPtr]$hwnd, [int]$delayMs = 1500) {
$units = $cps -join ","
$exe = "$repo\target\debug\examples\text_check.exe"
if (-not (Test-Path $exe)) { throw "missing $exe (cargo build -p viewer-core --example text_check)" }
Assert-TargetForeground $hwnd
& $exe --port 47800 --code t --units $units --delay $delayMs --hold 800
$code = $LASTEXITCODE
Assert-TargetForeground $hwnd # 注入窗口在 --delay 期间必须仍是靶窗
if ($code -ne 0) { throw "text_check exited $code" }
Start-Sleep -Milliseconds 500
}
Write-Host "=== 0. ensure CURRENT v3 agent is the one running ==="
$ag = Get-Process agent -ErrorAction SilentlyContinue
$agentExe = "$repo\target\release\agent.exe"
if ($ag -and (Get-Item $agentExe).LastWriteTime -gt $ag.StartTime) {
Write-Host " running agent predates current build; restarting"
Stop-Process -Name agent -Force -ErrorAction SilentlyContinue
Start-Sleep -Milliseconds 800
$ag = $null
}
if (-not $ag) {
Start-Process -FilePath $agentExe -ArgumentList "--port","47800","--code","t" -WindowStyle Hidden
Start-Sleep -Seconds 1
Write-Host " agent started (v3)"
}
Write-Host "=== 1. launch WinForms target (own process, empty field) ==="
# NO -WindowStyle Hidden: the hidden host makes the form invisible, and an
# invisible window can never be foreground (measured 2026-10-30).
Start-Process powershell.exe -ArgumentList "-NoProfile","-ExecutionPolicy","Bypass",`
"-File","$repo\tools\text_target.ps1","-PidFile",$pidFile,`
"-ResultFile",$resultFile,"-StopFile",$stopFile
Wait-File $pidFile
$tid = [int](Get-Content $pidFile -Raw).Trim()
$tp = Get-Process -Id $tid
Start-Sleep -Milliseconds 1200
$hwnd = $tp.MainWindowHandle
if ($hwnd -eq 0) { throw "target has no window handle" }
Write-Host " target pid=$tid hwnd=$hwnd title=[$($tp.MainWindowTitle)]"
Assert-TargetForeground $hwnd
Write-Host " target is foreground: yes"
Write-Host "=== 2. V6: four CJK BMP characters (Ni Hao Shi Jie) ==="
$cjk = @(0x4F60, 0x597D, 0x4E16, 0x754C)
Send-Units $cjk $hwnd
$got6 = Read-Received
$exp6 = CodePointsToString $cjk
Write-Host (" received [{0}] expected [{1}]" -f (Show-Text $got6), (Show-Text $exp6))
if ($got6 -ne $exp6) { throw "V6 FAIL" }
Write-Host " V6 PASS - BMP CJK arrived intact through v3 Text + KEYEVENTF_UNICODE"
Write-Host "=== 3. V7: exclaim + emoji U+1F600 (UTF-16 surrogate pair) appended ==="
$mix = @(0x0021, 0xD83D, 0xDE00)
Send-Units $mix $hwnd
$got7 = Read-Received
$exp7 = $exp6 + (CodePointsToString $mix)
Write-Host (" received len={0} [{1}]" -f $got7.Length, (Show-Text $got7))
Write-Host (" expected len={0} [{1}]" -f $exp7.Length, (Show-Text $exp7))
if ($got7 -ne $exp7) { throw "V7 FAIL - mismatch" }
if ($got7.Contains([char]0xFFFD)) { throw "V7 FAIL - U+FFFD present: pair got split" }
Write-Host " V7 PASS - surrogate pair survived as one character, no replacement char"
Write-Host "=== 4. negative guard: missing --units must NOT inject anything ==="
$before = Read-Received
$exe = "$repo\target\debug\examples\text_check.exe"
# Omit --units entirely (passing an empty string would be eaten by PowerShell
# argument handling and parsed as the next flag). viewer-core's parse_units
# rejects the degenerate input before any session is even opened.
# No 2>&1 here: viewer-core logs go to stderr by design; merging them turns
# every normal log line into a PowerShell error record.
& $exe --port 47800 --code t --delay 100 --hold 100 | Out-Null
$emptyCode = $LASTEXITCODE
Write-Host " exit=$emptyCode (expected non-zero: tool rejects empty --units)"
if ($emptyCode -eq 0) { throw "FAIL - empty units should be rejected by the tool" }
Start-Sleep -Milliseconds 400
$after = Read-Received
if ($after -ne $before) { throw "FAIL - target text changed unexpectedly: [$after]" }
Write-Host " guard PASS - no injection happened, target text unchanged"
Write-Host "=== 5. stop target ==="
New-Item -ItemType File -Force -Path $stopFile | Out-Null
$tries = 0
while ((Get-Process -Id $tid -ErrorAction SilentlyContinue) -and $tries -lt 40) {
Start-Sleep -Milliseconds 150; $tries++
}
Write-Host "=== PROOF COMPLETE: final target text = [$(Show-Text (Read-Received))] ==="
+62
View File
@@ -0,0 +1,62 @@
# 2b V6/V7 proof target: a dedicated WinForms window we own.
# ASCII-only source (PS 5.1 decodes BOM-less .ps1 as GBK; see personal lessons).
#
# Launched by text_injection_proof.ps1. Shows a topmost empty TextBox, writes
# its current text to ResultFile (UTF-8 with BOM) on every change, exits when
# StopFile appears. The proof injects through the remote-control pipeline and
# polls ResultFile - so "what arrived on the controlled desktop" is reported
# by the receiving application itself, not by a screenshot or OCR.
param(
[Parameter(Mandatory = $true)][string]$PidFile,
[Parameter(Mandatory = $true)][string]$ResultFile,
[Parameter(Mandatory = $true)][string]$StopFile
)
$ErrorActionPreference = "Stop"
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
# Event handler scriptblocks resolve script-scoped vars; make that explicit
# instead of relying on the param locals staying on a visible scope chain.
$script:PidFile = $PidFile
$script:ResultFile = $ResultFile
$script:StopFile = $StopFile
$script:lastWritten = $null
$form = New-Object System.Windows.Forms.Form
$form.Text = "RemoteTextTarget"
$form.ClientSize = New-Object System.Drawing.Size(420, 140)
$form.TopMost = $true
$form.StartPosition = "Manual"
$form.Location = New-Object System.Drawing.Point(80, 80)
$tb = New-Object System.Windows.Forms.TextBox
$tb.Multiline = $true
$tb.Font = New-Object System.Drawing.Font("Segoe UI", 16.0)
$tb.Dock = "Fill"
$form.Controls.Add($tb)
$timer = New-Object System.Windows.Forms.Timer
$timer.Interval = 150
$timer.Add_Tick({
if (Test-Path $script:StopFile) {
$form.Close()
return
}
if ($tb.Text -ne $script:lastWritten) {
$script:lastWritten = $tb.Text
$enc = New-Object System.Text.UTF8Encoding($true)
[System.IO.File]::WriteAllText($script:ResultFile, $tb.Text, $enc)
}
if (-not $tb.Focused) { [void]$tb.Focus() }
})
$form.Add_Shown({
$form.Activate()
[void]$tb.Focus()
[System.IO.File]::WriteAllText($script:PidFile, "$PID")
})
$timer.Start()
[void]$form.ShowDialog()
$timer.Stop()
+87
View File
@@ -0,0 +1,87 @@
//! 协议 v3 文本注入链路的桌面真边界验证工具(loop-2b,不依赖 Android)。
//!
//! 证明的事:`Session::send_text` 经线格式到被控端后,`agent` 的
//! `KEYEVENTF_UNICODE` 注入把**同一段 UTF-16 code unit** 送进了前台应用。
//! 判据由外部读取目标窗口的实际文本内容(调用方负责),本工具只负责发送。
//!
//! 用法:
//! 终端1: target\release\agent.exe --port 47800 --code t
//! 终端2: 把目标应用(记事本)切到前台,然后
//! cargo run -p viewer-core --example text_check -- \
//! --port 47800 --code t --units 20320,22909,55357,56832
//!
//! `--units` 是十进制的 UTF-16 code unit 列表(逗号分隔):
//! 「你好😀」= 20320,22909,55357,56832(😀 是代理对)。
//! 刻意不收自然语言字符串参数:命令行到 Rust 的编码往返会引入与本轮无关的变量,
//! 由调用方(PowerShell)从 .NET 字符串取 char[] 更忠实。
//!
//! `--delay`:连接后等待毫秒再注入。给调用方把目标窗口切到前台的时间——
//! SendInput 注入的是**前台**应用,窗口没在前台等于测了个寂寞。
//! `--hold`:注入后保持会话的毫秒数,等消息在远端消化完再关连接。
use anyhow::{bail, Result};
use std::time::Duration;
use viewer_core::{ConnectConfig, Session};
fn arg(name: &str) -> Option<String> {
let a: Vec<String> = std::env::args().collect();
a.iter()
.position(|x| x == name)
.and_then(|i| a.get(i + 1))
.cloned()
}
fn parse_units(spec: &str) -> Result<Vec<u16>> {
if spec.trim().is_empty() {
bail!("--units 为空:本轮要验证的是「有文本送出并被注入」,空输入没有验证价值");
}
spec.split(',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| {
s.parse::<u32>()
.ok()
.and_then(|v| u16::try_from(v).ok())
.ok_or_else(|| anyhow::anyhow!("--units 必须是 0..=65535 的十进制数,got {s:?}"))
})
.collect()
}
#[tokio::main]
async fn main() -> Result<()> {
let port: u16 = arg("--port")
.map(|s| s.parse())
.transpose()?
.unwrap_or(47800);
let code = arg("--code").unwrap_or_else(|| "123456".into());
let host = arg("--host").unwrap_or_else(|| "127.0.0.1".into());
let delay = arg("--delay")
.map(|s| s.parse())
.transpose()?
.unwrap_or(1500u64);
let hold = arg("--hold")
.map(|s| s.parse())
.transpose()?
.unwrap_or(800u64);
let units = parse_units(&arg("--units").unwrap_or_default())?;
let session = Session::connect(ConnectConfig::direct(&host, port, &code)).await?;
println!(
"[text_check] connected peer={} units={}",
session.peer_addr(),
units.len()
);
tokio::time::sleep(Duration::from_millis(delay)).await;
session.send_text(&units);
println!("[text_check] sent (只报长度,不打印内容)");
tokio::time::sleep(Duration::from_millis(hold)).await;
let st = session.stats();
println!(
"[text_check] frames={} rtt={}ms — 会话活着,注入已发给前台应用",
st.frames_in, st.rtt_ms
);
session.close().await;
Ok(())
}
+23
View File
@@ -344,6 +344,29 @@ impl Session {
let _ = self.tx.send(Command::Input(ev));
}
/// 协议 v3:发送一段文本(UTF-16 code unit)到被控端注入。
///
/// 为什么参数是 `&[u16]` 而不是 `&str`:被控端的注入通道(Windows
/// `KEYEVENTF_UNICODE`)与主控端来源(Java 字符串)都是 UTF-16 code unit,
/// 中间任何一次 UTF-8 往返都会把 IME 提交边界的落单代理项变成替换字符。
/// 见 `common::InputEvent::Text` 的取舍说明。
///
/// 超限处理:截断到 [`common::MAX_TEXT_UNITS`] 并打日志——静默丢弃会让用户
/// 以为长文本已送达。日志只报长度,不报内容。
pub fn send_text(&self, units: &[u16]) {
if units.is_empty() {
return; // 空提交没有注入语义,也不该占用一条消息
}
let (units, truncated) = common::clamp_text_units(units.to_vec());
if truncated {
log(&format!(
"文本超过上限,已截断到 {} 个 code unit",
common::MAX_TEXT_UNITS
));
}
self.send_input(InputEvent::Text { units });
}
/// 请求被控端强制关键帧(解码器错误/跳帧后的恢复路径)。
pub fn request_keyframe(&self) {
let _ = self.tx.send(Command::KeyFrameReq);