Compare commits
7
Commits
5079d33b0a
...
8ffe19cd5f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ffe19cd5f | ||
|
|
936986a940 | ||
|
|
6c28a3f7e3 | ||
|
|
55cc8edbc1 | ||
|
|
174a3f8ac0 | ||
|
|
5075888861 | ||
|
|
38df106fb6 |
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
@@ -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))] ==="
|
||||
@@ -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()
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user