7 Commits
Author SHA1 Message Date
郑博文 1a6c3f15a2 test(tools): 3b 真机验收脚本 + 修正 IME proof 的按钮定位
rel_mouse_phone_proof.ps1 覆盖 V-B1a(相对位移方向/符号/不跳变)、V-B2(切绝对
模式后 R7 重测)、V-B3(长按出右键、拖拽不出)。

三处 harness 教训(都已写进脚本注释,避免下次重踩):
1. uiautomator 不暴露 SurfaceView,裸 SurfaceView 塌成 android.view.View;
   按 16:9 宽高比识别视频视图,并断言比例,避免静默抓错节点。
2. 上一段滑动的相对事件可能在 SetCursorPos 之后才落地,制造出 15px/298px 的
   假跳变。判据前必须先等链路静默、并要求基线两次读数一致。
3. ime_chinese_proof 用「第一个 Button」当键盘按钮——3b 在按钮栏上方插入了
   灵敏度行,第一个 Button 变成了减号,proof 报「IME not shown」。
   改为按文案匹配,与位置无关。
2026-10-31 17:35:18 +08:00
郑博文 aede7b6c3c test(android): 相对鼠标/长按右键纯逻辑单测 17 例
覆盖 V-B1 判据对应的行为,不是「跑通就算」:
- 慢速小幅不丢位移(10 次 0.3px 必须发出 3px)
- 负值向远离零取整(-0.7 取 -1,向零截断会让反向慢滑丢位移)
- 撞边不积债(回滑立即发出,不因补偿而空转)
- i16 越界 clamp 而非回绕
- 长按三条判据:够久、没动、非双指

已做 mutation 反向验证:把期望 3 改成 4 后测试确实红,改回后绿(断言非空转)。
2026-10-31 17:35:11 +08:00
郑博文 7c3b6b3e93 feat(android): 相对鼠标模式 + 灵敏度 + 长按右键 + 修复表单 IME 残留
相对模式(默认):手指滑动按增量移动远端指针,DOWN 不发绝对移动——轻点落在
远端指针当前位置,不再「手指落下指针跳到脚下」。换算基准为视图全宽=桌面全宽
(灵敏度 1.0),慢速滑动用小数累积取整,逐次 toInt() 会静默吞掉位移。

长按右键(Q5=A):>=500ms 且位移 <12dp;触发后抑制本次 ACTION_UP 的左键,
否则一次长按会「先右键再左键」把菜单立刻点掉。超过 slop 立即转拖拽并取消计时,
所以拖窗口不会弹菜单。双指右键路径保持不变。

真机暴露并修复(N19):切到远控面板后软键盘仍绑在已隐藏的表单 EditText 上
(dumpsys 里 mServedView 指向 EditText、mInputShown=true),键盘盖住面板底部
按钮栏,**所有按钮点不动**——3b 新增的模式/灵敏度按钮把它暴露出来。改为对每个
表单输入框显式 hideSoftInputFromWindow。

新增右键计数器:长按后紧跟的 ACTION_UP 会覆盖一行式诊断文本,验收脚本读不到
「长按右键」;计数器持久且能区分长按/双指来源。
2026-10-31 17:35:11 +08:00
郑博文 3773a0851c feat(viewer-core,android-ffi): 相对位移上行链路 + 桌面像素级 proof
viewer-core 新增 send_mouse_rel(零位移不占消息:手指静止时 ACTION_MOVE
每帧都发,空事件只会白占 QUIC 帧)。
android-ffi 新增 nativeSendMouseRel,JNI 符号 13 -> 14,Int 入参 clamp 到 i16
而不是回绕(回绕会让指针跳到屏幕另一侧)。

新增 examples/rel_mouse_proof:走完整链路(viewer-core -> QUIC -> agent 注入
-> GetCursorPos 读回)验证 V-B1a/b/c。判据是「位移恰好等于请求值」,不是
「大概动了」——因为被推翻的 MOUSEEVENTF_MOVE 方案正是「看起来能动但比例错」。
运行前后记录并还原光标位置,不污染桌面状态。
2026-10-31 17:35:02 +08:00
郑博文 2022bbcca0 feat(agent-core): InputInjector 新增 mouse_move_rel 默认实现与分发
默认实现不注入但打印日志(与 v3 的 text() 同一条纪律):Linux/X11 后端本轮
未实现相对移动,用户用相对模式操作 Linux 被控端时「指针纹丝不动」不能是静默的。

相对事件不得被降级成绝对移动——单测显式断言默认实现不产生任何注入调用
(否则 Linux 端会把「相对增量」当成「绝对坐标」解释,指针乱跳)。
2026-10-31 17:35:02 +08:00
郑博文 78ac9d9972 feat(agent): 相对鼠标注入(读光标+增量+clamp 绝对注入)+ 显式 DPI 感知
注入实现刻意**不用** SendInput(MOUSEEVENTF_MOVE):实测该路径的 dx 不是
像素而是 mickey,会被「指针加速」与「DPI 缩放」双重放大——本机 dx=1 走 0px、
dx=20 走 41px、dx=100 走 309px,且倍率随用户系统设置与机器变化,无法标定。
改为「GetCursorPos + 增量 + clamp + 绝对注入」,实测 1:1 精确(±1/2/3/5/17/100
四次重复全部等于请求值),撞边后回滑立即响应、不积步数债。

同时显式设 PER_MONITOR_AWARE_V2:实测本机 agent.exe 虽是 per-monitor,
但那是注册表 AppCompatFlags\\Layers 里一条**路径绑定**的 HIGHDPIAWARE 覆盖
造成的(同一 exe 复制到 %TEMP% 启动即变 UNAWARE),换机器/换构建路径即失效。
UNAWARE 时 GetCursorPos 返回逻辑坐标(1536x864)而采集是物理像素(1920x1080),
clamp 上界与增量尺度都会错。设置失败时区分「已被外部设定」与「真正降级」并分别报日志。
2026-10-31 17:34:54 +08:00
郑博文 39af42e1b3 feat(common): 协议 v4 新增 MouseMoveRel 相对鼠标事件
v4 新增 InputEvent::MouseMoveRel{dx,dy},单位为被控端桌面物理像素,
语义是「把远端指针从当前位置移动 (dx,dy)」,越界由被控端 clamp。

为什么必须升版本:bincode 按位置编码枚举判别符,新增变体会改变其后
变体的编号,旧端会静默错解成别的输入事件。v4 后旧 v3 端由 Auth 握手
显式拒绝(已用 v3 二进制实测:client=3, server=4)。

为什么单位是像素而不是归一化增量:AuthOk 已下发桌面宽高,主控端换算
一次即可;被控端拿到像素可直接与 GetCursorPos 同尺度相加,少一次取整。

单测锁住:版本号、负值往返(i16 全范围)、与绝对 MouseMove 不混淆。
2026-10-31 17:34:47 +08:00
18 changed files with 1554 additions and 26 deletions

No files matched your search

Generated
+1
View File
@@ -1434,6 +1434,7 @@ dependencies = [
"quinn",
"rustls",
"tokio",
"windows",
]
[[package]]
+54
View File
@@ -63,6 +63,16 @@ pub trait InputInjector: Send + Sync + 'static {
units.len()
);
}
/// 协议 v4 的相对鼠标移动(单位:被控端桌面物理像素)。
///
/// 与 [`InputInjector::text`] 同一条纪律:**默认实现不注入,但必须留下可见痕迹**。
/// Linux/X11 后端本轮未实现相对移动(XTest 的相对语义要自己维护指针状态,
/// 与 Windows 的 `GetCursorPos`+绝对注入不同构,另立循环)。用户用相对模式
/// 操作 Linux 被控端时「指针纹丝不动」不能是静默的,日志必须说明是没实现。
fn mouse_move_rel(&self, dx: i16, dy: i16) {
eprintln!("[agent-core] 相对鼠标注入未实现(忽略 dx={dx} dy={dy})");
}
}
/// 把一条协议输入事件分发到注入后端。
@@ -85,6 +95,9 @@ pub fn dispatch_input(inj: &dyn InputInjector, ev: InputEvent) {
}
inj.text(&units);
}
// v4:相对移动原样下发(含负值)。clamp 与像素换算由注入后端负责——
// 只有它知道被控端桌面的真实像素尺寸(`agent-core` 是平台无关层)。
InputEvent::MouseMoveRel { dx, dy } => inj.mouse_move_rel(dx, dy),
}
}
@@ -588,6 +601,47 @@ mod tests {
);
}
/// v4:相对移动必须被分发,**负值原样到达后端**(负值是相对语义的核心,
/// 绝对坐标是无符号的,这条路径是新引入的有符号通道)。
#[test]
fn rel_move_reaches_backend_with_signed_deltas() {
struct RelInjector {
got: Mutex<Vec<(i16, i16)>>,
}
impl InputInjector for RelInjector {
fn mouse_move(&self, _x: u16, _y: u16) {}
fn mouse_button(&self, _b: MouseButton, _down: bool) {}
fn wheel(&self, _dy: i32) {}
fn key(&self, _s: u16, _e: bool, _d: bool) {}
fn mouse_move_rel(&self, dx: i16, dy: i16) {
self.got.lock().unwrap().push((dx, dy));
}
}
let inj = RelInjector {
got: Mutex::new(Vec::new()),
};
dispatch_input(&inj, InputEvent::MouseMoveRel { dx: 12, dy: -34 });
dispatch_input(&inj, InputEvent::MouseMoveRel { dx: -1, dy: 0 });
assert_eq!(
*inj.got.lock().unwrap(),
vec![(12, -34), (-1, 0)],
"相对增量(含负值)必须原样到达注入后端"
);
}
/// 后端**没有**实现 mouse_move_rel 时不得 panic,且相对事件不得被误路由成
/// 绝对移动(Linux/X11 后端的真实形态:默认实现 + 日志)。
#[test]
fn rel_move_default_backend_does_not_panic() {
let inj = MockInjector::default();
dispatch_input(&inj, InputEvent::MouseMoveRel { dx: 5, dy: 5 });
// 默认实现只打日志,不应产生任何注入调用(尤其不能变成 move/绝对移动)
assert!(
inj.take().is_empty(),
"默认实现不得把相对移动降级成绝对移动"
);
}
/// v3 关键回归:`Text` 必须被分发,且**后端没有实现 text() 时不能 panic**。
/// 这是 Linux/X11 后端的真实形态——用户对着 Linux 被控端打字会走到这里。
#[test]
+3
View File
@@ -24,4 +24,7 @@ windows = { workspace = true, features = [
"Win32_Graphics_Dxgi_Common",
"Win32_UI_Input_KeyboardAndMouse",
"Win32_UI_WindowsAndMessaging",
# SetProcessDpiAwarenessContext:相对鼠标的 GetCursorPos 必须与采集同为物理像素,
# 不能依赖注册表 AppCompat 覆盖(见 3b 开工实测 §4)。
"Win32_UI_HiDpi",
] }
+60 -2
View File
@@ -9,9 +9,10 @@ use windows::Win32::UI::Input::KeyboardAndMouse::{
MOUSE_EVENT_FLAGS,
};
use windows::Win32::UI::WindowsAndMessaging::{
GetSystemMetrics, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN,
SM_YVIRTUALSCREEN,
GetCursorPos, GetSystemMetrics, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_CXSCREEN,
SM_CYSCREEN, SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN,
};
use windows::Win32::Foundation::POINT;
// windows 0.58 未导出 LEFT 常量,按 WinUser.h 原值定义
const MOUSEEVENTF_LEFTDOWN: u32 = 0x0002;
@@ -36,6 +37,62 @@ fn send_mouse(flags: u32, dx: i32, dy: i32, data: u32) {
}
}
/// 主显示器物理尺寸(像素)。相对移动的 clamp 上界必须与**采集空间**一致
/// (capture 用 `EnumOutputs(0)`;AuthOk 下发的 width/height 也来自它)。
fn output_size() -> (i32, i32) {
unsafe {
(
GetSystemMetrics(SM_CXSCREEN),
GetSystemMetrics(SM_CYSCREEN),
)
}
}
/// v4 相对鼠标:把远端指针从当前位置移动 `(dx, dy)` 像素。
///
/// **为什么不用 `SendInput(MOUSEEVENTF_MOVE)`**(协议 v4 的语义最容易在这里跑偏):
/// 实测(2026-10-31,见个人层 `docs/output/2026-10-31-3b-开工前实测.md`)该路径的
/// `dx` 不是像素,而是「mickey」,会被两件事放大:
/// 1. **指针加速**(「提高指针精确度」)。本机 `SPI_GETMOUSE=6/10/1` 下
/// dx=20 实际走 41px、dx=100 走 309px,且倍率是非线性的;
/// 2. **DPI 缩放**。关掉加速后倍率恒为 1.25 = 本机 125% 缩放。
/// 两者都随用户系统设置与机器变化,无法标定,因此**不能**把协议里的像素增量直接透传。
///
/// 改为「读光标 → 加增量 → clamp → 绝对注入」:
/// - 语义仍是**相对**(主控端只说移动多少,不说去哪里),远端指针始终权威;
/// - 实测 1:1 精确(dx=±1/2/3/5/17/100 四次重复全部等于请求值);
/// - 撞边后回滑立即响应,不积累「步数债」(clamp 丢掉的部分不回补)。
///
/// 已知限制(不隐藏):读光标与注入之间若用户同时移动真鼠标,会有一次增量被叠加。
/// 本机联调场景可接受;彻底消除需要 `SendInput` 的 mickey 路径,而那条路不可标定。
fn send_mouse_rel(dx: i16, dy: i16) {
if dx == 0 && dy == 0 {
return; // 零位移没有注入语义,也不该产生一次系统调用
}
let (w, h) = output_size();
if w <= 1 || h <= 1 {
eprintln!("[input] 相对移动失败:输出尺寸异常 {w}x{h}");
return;
}
let mut cur = POINT::default();
// GetCursorPos 失败时不能猜一个坐标继续注入——那会把指针扔到屏幕角落
if let Err(e) = unsafe { GetCursorPos(&mut cur) } {
eprintln!("[input] GetCursorPos 失败,相对移动被丢弃: {e}");
return;
}
let tx = (cur.x + dx as i32).clamp(0, w - 1);
let ty = (cur.y + dy as i32).clamp(0, h - 1);
// 归一化到 0..=65535(绝对注入空间 = 主显示器,不带 VIRTUALDESK,见 MouseMove 分支)
let nx = ((tx as i64 * 65535 + (w as i64 - 1) / 2) / (w as i64 - 1)) as i32;
let ny = ((ty as i64 * 65535 + (h as i64 - 1) / 2) / (h as i64 - 1)) as i32;
send_mouse(
MOUSEEVENTF_ABSOLUTE.0 | MOUSEEVENTF_MOVE.0,
nx,
ny,
0,
);
}
fn send_key(scancode: u16, extended: bool, up: bool) {
let mut flags = KEYEVENTF_SCANCODE;
if extended {
@@ -167,6 +224,7 @@ pub fn handle(ev: InputEvent) {
}
InputEvent::KeyDown { scancode, extended } => send_key(scancode, extended, false),
InputEvent::KeyUp { scancode, extended } => send_key(scancode, extended, true),
InputEvent::MouseMoveRel { dx, dy } => send_mouse_rel(dx, dy),
InputEvent::Text { units } => {
// 上限截断必须可见(与 agent-core::dispatch_input 同口径;Windows 端
// 走自己的 handle(),不经过 agent-core,所以这里同源补齐——R-3 教训)。
+52
View File
@@ -32,8 +32,60 @@ fn parse_args() -> Result<(u16, String)> {
Ok((port, code))
}
/// 显式声明进程 DPI 感知(**必须在任何采集/注入调用之前**,越早越好)。
///
/// 为什么不能省(3b 开工实测,见个人层 `docs/output/2026-10-31-3b-开工前实测.md` §4):
/// 相对鼠标走「`GetCursorPos` + 增量」,而 `GetCursorPos` 的坐标空间取决于进程的
/// DPI 感知模式——UNAWARE 进程拿到的是**逻辑坐标**(本机 125% 缩放下是 1536x864),
/// 而 DXGI 采集与 `AuthOk` 下发的是**物理像素**(1920x1080)。两者混用会让
/// clamp 上界偏小、增量尺度偏小:指针到不了屏幕右/下边缘,且移动距离只有 80%。
///
/// 为什么不能依赖现状:本机 `agent.exe` 实测确实是 `PER_MONITOR_AWARE(2)`,但那是
/// `HKCU\...\AppCompatFlags\Layers` 里一条**路径绑定**的 `HIGHDPIAWARE` 覆盖造成的
/// ——同一个 exe 复制到 `%TEMP%` 启动就变成 `UNAWARE(0)`。换机器、换构建路径、
/// CI 产物、用户机器上都不成立,所以必须由代码显式设置。
///
/// 失败时只告警不退出:DPI 感知不可用是老系统(< Win10 1703)的情况,
/// 此时相对鼠标会退化(精度下降),但绝对注入与其余功能不受影响——
/// 让整个被控端起不来是更差的选择。
fn init_dpi_awareness() {
use windows::Win32::UI::HiDpi::{
GetThreadDpiAwarenessContext, SetProcessDpiAwarenessContext, GetAwarenessFromDpiAwarenessContext,
DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2,
};
match unsafe { SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) } {
Ok(()) => println!("[agent] DPI awareness: PER_MONITOR_AWARE_V2 (set by this process)"),
Err(e) => {
// 失败有两种完全不同的原因,**必须分开报告**,否则日志会误导排查方向:
// a) 进程的感知模式已被别处设定(注册表 AppCompat 覆盖、清单、父进程)——
// ERROR_ACCESS_DENIED(0x80070005),此时若已经是 per-monitor 就完全没问题;
// b) 系统过旧不支持该 API——这才是真正的降级。
// 本机实测是 (a):HKCU 的 AppCompatFlags\Layers 里有一条路径绑定的 HIGHDPIAWARE。
let actual = unsafe {
GetAwarenessFromDpiAwarenessContext(GetThreadDpiAwarenessContext())
};
// DPI_AWARENESS(WinUser.h):INVALID=-1、UNAWARE=0、SYSTEM_AWARE=1、
// PER_MONITOR_AWARE=2。注意 PER_MONITOR 是 **2** 而不是 3。
let is_per_monitor = actual.0 == 2;
if is_per_monitor {
println!(
"[agent] DPI awareness: 已由外部设定为 PER_MONITOR_AWARE(无需本进程设置;{e})"
);
} else {
eprintln!(
"[agent] 警告:DPI 感知为 {}(非 per-monitor),且本进程设置失败({e})。\
相对鼠标的位移比例会与被采集桌面不一致;绝对触摸不受影响。",
actual.0
);
}
}
}
}
#[tokio::main]
async fn main() -> Result<()> {
// 第一件事:定下 DPI 感知,后续所有坐标运算都依赖它
init_dpi_awareness();
let (port, code) = parse_args()?;
rustls::crypto::ring::default_provider()
.install_default()
+15
View File
@@ -225,6 +225,21 @@ pub extern "system" fn Java_com_verify_remote_RustBridge_nativeSendMouseMove(
});
}
#[no_mangle]
pub extern "system" fn Java_com_verify_remote_RustBridge_nativeSendMouseRel(
_env: JNIEnv,
_class: JClass,
handle: jlong,
dx: jint,
dy: jint,
) {
// 协议 v4 的字段是 i16:Kotlin 侧传 Int,越界一律 clamp 而不是静默回绕。
// 越界值只可能来自主控端 bug 或恶意对端;clamp 让行为有界且可见(不 panic、不 UB)。
let dx = dx.clamp(i16::MIN as i32, i16::MAX as i32) as i16;
let dy = dy.clamp(i16::MIN as i32, i16::MAX as i32) as i16;
with_session(handle, |s| s.send_mouse_rel(dx, dy));
}
/// button: 0=左 1=右 其他=中;down: 1=按下 0=抬起
#[no_mangle]
pub extern "system" fn Java_com_verify_remote_RustBridge_nativeSendMouseButton(
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "com.verify.remote"
minSdk = 26
targetSdk = 34
versionCode = 2
versionName = "0.1-phase3a"
versionCode = 3
versionName = "0.1-phase3b"
// 阶段 1 只测真机 arm64;x86_64 留给模拟器回归
ndk {
abiFilters += listOf("arm64-v8a")
@@ -45,6 +45,10 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
private lateinit var codeInput: EditText
private lateinit var connectButton: Button
private lateinit var disconnectButton: Button
private lateinit var modeButton: Button
private lateinit var sensUpButton: Button
private lateinit var sensDownButton: Button
private lateinit var sensLabel: TextView
private lateinit var connectStatus: TextView
@Volatile
@@ -97,6 +101,9 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
orientation = LinearLayout.VERTICAL
setBackgroundColor(0xFF000000.toInt())
}
// 3b:模式与灵敏度先于面板构造恢复——buildRemotePanel 里的按钮文案要读它们
surfaceView.relativeMode = prefs().getBoolean(PREF_RELATIVE, true)
surfaceView.sensitivity = prefs().getFloat(PREF_SENSITIVITY, RelPointer.DEFAULT_SENSITIVITY)
connectPanel = buildConnectPanel()
remotePanel = buildRemotePanel()
val lp = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f)
@@ -208,6 +215,32 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
text = "键盘"
setOnClickListener { showKeyboard() }
}
// 协议 v4:指针模式切换(Q3=A:默认相对,可切绝对)
modeButton = Button(this).apply {
text = modeLabel()
setOnClickListener {
surfaceView.relativeMode = !surfaceView.relativeMode
prefs().edit().putBoolean(PREF_RELATIVE, surfaceView.relativeMode).apply()
text = modeLabel()
lastTouchDbg = if (surfaceView.relativeMode) "模式:相对" else "模式:绝对"
refreshStatus()
}
}
// 灵敏度(Q4=A:0.25–3.0,默认 1.0)。仅相对模式生效,绝对模式下调了也不影响。
sensDownButton = Button(this).apply {
text = "−"
setOnClickListener { adjustSensitivity(-SENS_STEP) }
}
sensUpButton = Button(this).apply {
text = "+"
setOnClickListener { adjustSensitivity(+SENS_STEP) }
}
sensLabel = TextView(this).apply {
text = sensText()
textSize = 12f
setTextColor(0xFFFFFFFF.toInt())
gravity = Gravity.CENTER
}
disconnectButton = Button(this).apply {
text = "断开"
// 连接等待期它显示「取消」并走同一个 cancelConnect():两个按钮的
@@ -217,8 +250,15 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
val bar = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
addView(keyboardButton, LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f))
addView(modeButton, LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f))
addView(disconnectButton, LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f))
}
val sensBar = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
addView(sensDownButton, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT))
addView(sensLabel, LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f))
addView(sensUpButton, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT))
}
return LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
visibility = View.GONE
@@ -226,10 +266,26 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
// 固定高度而非 WRAP_CONTENT:与 weight=1 的兄弟共存时,
// WRAP_CONTENT 可能被压成 0(实测 bounds=[0,0][0,0],状态栏不可见)
addView(statusView, LayoutParams(LayoutParams.MATCH_PARENT, dp(64)))
addView(sensBar, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT))
addView(bar, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT))
}
}
private fun prefs() = getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private fun modeLabel() = if (surfaceView.relativeMode) "模式:相对" else "模式:绝对"
private fun sensText() = "灵敏度 ${"%.2f".format(surfaceView.sensitivity)}"
/** 灵敏度按 0.25 步进调整并落盘(绝对模式下调了也不影响,切回相对即生效) */
private fun adjustSensitivity(delta: Float) {
surfaceView.sensitivity = surfaceView.sensitivity + delta
prefs().edit().putFloat(PREF_SENSITIVITY, surfaceView.sensitivity).apply()
sensLabel.text = sensText()
lastTouchDbg = "灵敏度 ${"%.2f".format(surfaceView.sensitivity)}"
refreshStatus()
}
private fun showConnectState(msg: String) {
connecting = false
remotePanelVisible(false)
@@ -239,6 +295,36 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
private fun showRemoteState() {
remotePanelVisible(true)
connectPanel.visibility = View.GONE
// 3b/N17(Q6=A):表单被隐藏时必须主动清焦点。否则 EditText 仍持有焦点,
// 远控面板上的按钮第一下 tap 只被用来「丢焦点」而不触发点击——真机上表现
// 为「按钮要点两次」(3a 的 D8 已实测到 4 轮全丢 tap)。
clearFormFocus()
}
/**
* 收走表单输入框的焦点并隐藏软键盘。
*
* 为什么放在切面板时而不是按钮点击时:点按钮时焦点已经丢了,补救太晚;
* 面板可见的那一刻才是「表单不再需要输入」的准确时机。
*
* **3b 真机暴露的加强(N19)**:只 `clearFocus()` 不够。实测远控面板已经显示、
* 表单已 GONE 之后,`dumpsys input_method` 里 `mServedView` **仍指向表单的
* EditText**、`mInputShown=true`——软键盘继续挂在屏幕底部,把面板底部的按钮栏
* (键盘/模式/断开,y=2256..2324)整个盖住,**所有按钮点不动**。
* 因此必须显式对每个表单输入框 `hideSoftInputFromWindow`,并确认 IME 真的收起。
*/
private fun clearFormFocus() {
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
// 逐个表单输入框收键盘:currentFocus 可能已经不是它们(隐藏视图的焦点
// 状态不可靠),而 IME 的 mServedView 恰恰还绑在它们身上。
for (v in listOf(hostInput, portInput, codeInput)) {
v.clearFocus()
imm.hideSoftInputFromWindow(v.windowToken, 0)
}
currentFocus?.let { if (it !== surfaceView) it.clearFocus() }
keyboardVisible = false
// 布局变化会再触发 updateKeyboardVisibility(),用真实可见区域复核一次
window.decorView.post { updateKeyboardVisibility() }
}
private fun remotePanelVisible(show: Boolean) {
@@ -276,6 +362,12 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
if (h != 0L) RustBridge.nativeSendMouseMove(h, x, y)
}
/** 协议 v4:相对位移(被控端桌面物理像素)。零位移由 Rust 侧拦掉,这里不重复判断。 */
override fun sendMouseRel(dx: Int, dy: Int) {
val h = handle
if (h != 0L) RustBridge.nativeSendMouseRel(h, dx, dy)
}
override fun sendMouseButton(button: Int, down: Boolean) {
val h = handle
if (h != 0L) RustBridge.nativeSendMouseButton(h, button, if (down) 1 else 0)
@@ -301,6 +393,22 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
override fun isReadyForInput(): Boolean = handle != 0L
/**
* 3b:右键注入计数(长按 / 双指各一路)。持久计数而不是覆盖式诊断行——
* 长按之后的 ACTION_UP 会把 lastTouchDbg 改写成「长按结束」,验收脚本读不到
* 「长按右键」。计数不会丢,且能区分两种来源。
*/
@Volatile
private var rightClicks = 0
@Volatile
private var rightClickSource = "-"
override fun onRightClickInjected(source: String) {
rightClicks++
rightClickSource = source
runOnUiThread { refreshStatus() }
}
/**
* 软键盘是否可见(自测,不依赖 InputMethodManager 的间接信号)。
*
@@ -492,6 +600,9 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
handle = h
remoteW = w
remoteH = ht
// 相对模式的换算基准:视图像素 → 桌面像素必须知道远端真实尺寸
surfaceView.remoteWidth = w
surfaceView.remoteHeight = ht
connectButton.text = "断开"
disconnectButton.text = "断开"
// 只有认证成功的会话才值得记住;验证码不落盘(用户裁决)
@@ -623,6 +734,9 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
if (handle == h) handle = 0L
remoteW = 0
remoteH = 0
// 断开后相对换算失去基准:不清零会让下一次会话在拿到新尺寸前按旧尺寸换算
surfaceView.remoteWidth = 0
surfaceView.remoteHeight = 0
lastFrames = 0
lastRtt = 0
lastFps = 0
@@ -647,6 +761,8 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
decodeThread = null
remoteW = 0
remoteH = 0
surfaceView.remoteWidth = 0
surfaceView.remoteHeight = 0
lastFrames = 0
lastRtt = 0
lastFps = 0
@@ -677,7 +793,9 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
}
statusView.text = buildString {
append("已连接 v$PROTO_LABEL ${remoteW}x$remoteH fps=$lastFps RTT=${lastRtt}ms 帧=$lastFrames\n")
append("输入: $lastTouchDbg")
append("${if (surfaceView.relativeMode) "相对" else "绝对"} | 输入: $lastTouchDbg")
// 右键计数只在发生过时出现,避免常态占宽(状态栏只有 3 行)
if (rightClicks > 0) append(" | 右键=$rightClicks($rightClickSource)")
}
}
@@ -686,12 +804,17 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
companion object {
private const val TAG = "MainActivity"
private const val PREFS = "connect"
/** 3b:指针模式与灵敏度落盘(验证码仍不落盘,见 2b 裁决) */
private const val PREF_RELATIVE = "relative_mode"
private const val PREF_SENSITIVITY = "sensitivity"
private const val SENS_STEP = 0.25f
/**
* 与协议版本保持一致:远控画面状态栏显示当前握手版本(方案 R5 要求)。
* 值随 `common::PROTO_VERSION` 人工同步——Android 侧不做线格式解析,
* 版本由 Rust 握手校验,这里只作显示与排障用。
* 3b:3 → 4(协议 v4 相对鼠标)。
*/
private const val PROTO_LABEL = "3"
private const val PROTO_LABEL = "4"
}
}
@@ -0,0 +1,119 @@
package com.verify.remote
import kotlin.math.abs
import kotlin.math.roundToInt
/**
* 相对鼠标的**纯逻辑**:把「手指在视图上移动了多少像素」换算成「远端指针该走多少桌面像素」,
* 并做小数累积取整。抽成纯 Kotlin 是为了能在 JVM 单测里验证(V-B1 的判据依赖它)。
*
* 为什么不直接发 `deltaViewPx`:
* - 视图尺寸 ≠ 桌面尺寸(letterbox 后视图是 1080x607,桌面是 1920x1080);
* - `sensitivity != 1.0` 时还要缩放。
* 两步合并成一次乘法,避免分两次取整(每多一次取整就多一个累积误差源)。
*
* 为什么不直接 `toInt()` 就发:
* 手指慢速滑动时每次 `ACTION_MOVE` 的增量可能只有 0.3 个桌面像素,`toInt()` 逐次截断会让
* **位移被静默吞掉**(V-B1「慢速小幅滑动不丢位移」直接不成立)。必须把余数累积到下一次。
*/
object RelPointer {
/** 灵敏度允许范围(Q4=A:默认 1.0,可调 0.25–3.0) */
const val MIN_SENSITIVITY = 0.25f
const val MAX_SENSITIVITY = 3.0f
const val DEFAULT_SENSITIVITY = 1.0f
fun clampSensitivity(v: Float): Float = v.coerceIn(MIN_SENSITIVITY, MAX_SENSITIVITY)
/**
* 视图像素增量 → 桌面像素增量(**浮点,未取整**)。
*
* `sensitivity = 1.0` 的语义:手指扫过视图整个宽度 = 指针走完整个桌面宽度。
* 这样相对模式与绝对模式的手感一致(绝对模式下 10% 视图宽 = 10% 桌面宽)。
*
* 任一维度非法(<=0,例如视图尚未测量、远端尺寸未取到)时返回 0:
* 宁可不移动,也不要按错误比例乱飞。
*/
fun toDesktopDelta(
viewDeltaPx: Float,
viewExtentPx: Int,
remoteExtentPx: Int,
sensitivity: Float,
): Float {
if (viewExtentPx <= 0 || remoteExtentPx <= 0) return 0f
if (viewDeltaPx == 0f) return 0f
val scale = remoteExtentPx.toFloat() / viewExtentPx.toFloat()
return viewDeltaPx * scale * clampSensitivity(sensitivity)
}
}
/**
* 小数累积器:把连续的浮点位移攒成整数像素再发送,余数留到下一次。
*
* 保证:连续输入之和与单独一次输入等价(不丢位移);每次发出的都是整数(协议是 i16)。
* 撞边等外部原因丢弃的位移**不回补**——累积器只负责「取整」,不负责「补偿」,
* 否则撞边后回滑会出现一段"空转"(V-B1 第三条判据要求撞边后立即响应)。
*/
class RelAccumulator {
private var accX = 0f
private var accY = 0f
/** 会话/手势结束时清空,避免上一段手势的余数漏到下一段 */
fun reset() {
accX = 0f
accY = 0f
}
/**
* 累积一段位移,返回**本次可以发送的整数增量**;不足 1 像素时返回 null(不发)。
*
* X/Y 独立累积:斜向慢速滑动时两个轴各自攒够才发,不会因为一个轴没到 1 就把另一个轴也拖住。
*/
fun take(dxDesktop: Float, dyDesktop: Float): Pair<Int, Int>? {
accX += dxDesktop
accY += dyDesktop
// roundToInt 而非 toInt:-0.7 应取 -1 而不是 0(向零截断会让反向慢滑丢位移)
val ix = accX.roundToInt()
val iy = accY.roundToInt()
if (ix == 0 && iy == 0) return null
accX -= ix
accY -= iy
// 协议是 i16;单次事件不可能接近这个范围,但显式 clamp 让越界行为有界
return ix.coerceIn(-32768, 32767) to iy.coerceIn(-32768, 32767)
}
}
/**
* 长按右键的判定(Q5=A:≥500ms 且位移 < slop)。
*
* 抽成纯逻辑的原因:真实触发依赖 Handler 计时与 MotionEvent,JVM 里不可构造;
* 但「什么条件下该弹右键」是纯判断,必须能被单测钉住(尤其"拖窗口不弹菜单"这条)。
*/
object LongPressPolicy {
/** 长按触发阈值(毫秒) */
const val LONG_PRESS_MS = 500L
/**
* 是否应触发长按右键。
*
* @param elapsedMs 从 ACTION_DOWN 起经过的时间
* @param movedPx 期间的最大位移(欧氏距离)
* @param slopPx 拖动阈值(12dp),超过即视为拖拽而非长按
* @param twoFinger 是否出现过第二根手指(双指手势有自己的右键路径,不能叠加)
*
* 三条都必须满足:够久、没怎么动、不是双指。
* 「拖窗口不弹菜单」靠 movedPx 这条——超过 slop 时 [RemoteSurfaceView] 会切到 DRAG
* 并取消计时任务,所以这里通常压根不会被调用;但作为纯逻辑仍要自洽。
*/
fun shouldFire(
elapsedMs: Long,
movedPx: Float,
slopPx: Float,
twoFinger: Boolean,
): Boolean {
if (twoFinger) return false
if (elapsedMs < LONG_PRESS_MS) return false
return abs(movedPx) < slopPx
}
}
@@ -1,6 +1,8 @@
package com.verify.remote
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.text.InputType
import android.view.MotionEvent
import android.view.SurfaceView
@@ -21,10 +23,15 @@ import kotlin.math.hypot
* 实现为本项目独立编写):
* - 单指轻点 = 左键单击
* - 单指按下并移动 = 左键拖拽(窗口拖动、文字选中)
* - 单指长按不动 = 右键(协议 v4 起;双指轻点仍可用)
* - 双指轻点 = 右键
* - 双指上下滑动 = 滚轮
*
* 刻意不做「手指即光标」的绝对定位:手指会完全挡住目标,桌面精细操作不可用。
* **两种指针模式(协议 v4)**:
* - 相对模式(默认):手指滑动 = 远端指针按**增量**移动,手指只扫一小段就能走很远,
* 且不遮挡目标。DOWN 不发送绝对移动——轻点落在**远端指针当前位置**。
* - 绝对模式:手指位置直接映射到远端坐标(2a/3a 行为,DOWN 先发绝对 `MouseMove`)。
* 两种模式由 [relativeMode] 切换;R7 绝对触摸回归判据依赖绝对模式仍可用。
*
* **IME 接线(阶段 2b)**:本视图是文本编辑器(`onCheckIsTextEditor`),
* 软键盘的提交经 `RemoteInputConnection` 上行。`isFocusableInTouchMode` 必须为
@@ -36,6 +43,8 @@ class RemoteSurfaceView(context: Context) : SurfaceView(context) {
/** 由 Activity 实现,转发到 RustBridge */
interface Listener {
fun sendMouseMove(x: Int, y: Int)
/** 协议 v4:相对位移(被控端桌面物理像素) */
fun sendMouseRel(dx: Int, dy: Int)
fun sendMouseButton(button: Int, down: Boolean)
fun sendWheel(dy: Int)
/** 协议 v3:一段 UTF-16 code unit(IME 提交结果) */
@@ -44,10 +53,41 @@ class RemoteSurfaceView(context: Context) : SurfaceView(context) {
fun sendKey(key: WinKey, down: Boolean)
/** 连接态:只有已连接才声明自己是文本编辑器,避免未连接时误弹键盘 */
fun isReadyForInput(): Boolean
/**
* 右键注入发生了(3b)。用于把「长按是否真的发了右键」变成**可持久观测**的状态。
*
* 为什么需要它:`onDebug` 是覆盖式的一行文本,长按后紧跟着的 ACTION_UP 会
* 立刻把它改写成「长按结束」——真机验收脚本因此看不到「长按右键」,
* 判据被后续事件冲掉。计数器不会丢。
*/
fun onRightClickInjected(source: String)
}
var listener: Listener? = null
/**
* 相对指针模式(协议 v4)。默认 true:桌面远控的通行形态,手指不遮目标。
* 切换时清空累积器——上一模式的余数不该漏进新模式。
*/
var relativeMode: Boolean = true
set(value) {
if (field != value) {
field = value
relAcc.reset()
}
}
/** 灵敏度(仅相对模式生效)。默认 1.0 = 手指扫过视图全宽,指针走完桌面全宽。 */
var sensitivity: Float = RelPointer.DEFAULT_SENSITIVITY
set(value) {
field = RelPointer.clampSensitivity(value)
relAcc.reset()
}
/** 远端桌面像素尺寸(相对模式换算用);未连接时为 0,此时相对移动不发送 */
var remoteWidth: Int = 0
var remoteHeight: Int = 0
/** 视频宽高比(w/h)。>0 时视图按此比例测量。 */
var videoAspectRatio = 0f
set(value) {
@@ -70,6 +110,14 @@ class RemoteSurfaceView(context: Context) : SurfaceView(context) {
private var twoFingerMoved = false
private var wheelAccum = 0f
/** 相对模式的小数累积器(慢速滑动不丢位移) */
private val relAcc = RelAccumulator()
/** 长按右键:计时任务 + 是否已消费本次手势的 UP(防「先右键再左键」) */
private val ui = Handler(Looper.getMainLooper())
private var longPressFired = false
private var longPressTwoFinger = false
/** 判定「拖拽 vs 点击」的位移阈值:12dp */
private val dragSlopPx = resources.displayMetrics.density * 12f
@@ -79,6 +127,32 @@ class RemoteSurfaceView(context: Context) : SurfaceView(context) {
isFocusableInTouchMode = true
}
/**
* 长按计时任务(Q5=A:≥500ms 且位移 < slop)。
* 命名刻意避开 `cancelLongPress`——`View` 已有同名方法,会被要求 override。
*/
private val longPressTask = Runnable {
val l = listener ?: return@Runnable
if (mode != Mode.TAP) return@Runnable // 已转拖拽/双指
val elapsed = System.currentTimeMillis() - downTime
val movedPx = hypot((lastX - downX).toDouble(), (lastY - downY).toDouble()).toFloat()
if (!LongPressPolicy.shouldFire(elapsed, movedPx, dragSlopPx, longPressTwoFinger)) {
return@Runnable
}
// 相对模式:在远端指针**当前位置**点右键,不发绝对移动
if (!relativeMode) l.sendMouseMove(normX(lastX), normY(lastY))
l.sendMouseButton(BTN_RIGHT, true)
l.sendMouseButton(BTN_RIGHT, false)
longPressFired = true
performClick()
onDebug?.invoke("长按右键")
listener?.onRightClickInjected("长按")
}
private fun cancelLongPressTimer() {
ui.removeCallbacks(longPressTask)
}
// ---- IME 通道 ----
override fun onCheckIsTextEditor(): Boolean = listener?.isReadyForInput() == true
@@ -135,6 +209,25 @@ class RemoteSurfaceView(context: Context) : SurfaceView(context) {
private fun normX(x: Float) = (x / width * 65535).toInt().coerceIn(0, 65535)
private fun normY(y: Float) = (y / height * 65535).toInt().coerceIn(0, 65535)
/**
* 相对模式下把视图像素增量发出去(经累积取整)。
*
* 为什么 TAP/DRAG 都走这一个函数:两个分支都必须用**同一个**累积器,
* 分成两套会让 TAP→DRAG 切换时丢一次余数。
*/
private fun sendRelative(l: Listener, fromX: Float, fromY: Float, toX: Float, toY: Float) {
val dx = RelPointer.toDesktopDelta(toX - fromX, width, remoteWidth, sensitivity)
val dy = RelPointer.toDesktopDelta(toY - fromY, height, remoteHeight, sensitivity)
if (dx == 0f && dy == 0f) return
val d = relAcc.take(dx, dy) ?: return
l.sendMouseRel(d.first, d.second)
}
/** 绝对模式下按当前手指位置发归一化坐标 */
private fun sendAbsolute(l: Listener, x: Float, y: Float) {
l.sendMouseMove(normX(x), normY(y))
}
private fun moved(fromX: Float, fromY: Float, toX: Float, toY: Float) =
hypot((toX - fromX).toDouble(), (toY - fromY).toDouble()) > dragSlopPx
@@ -166,8 +259,15 @@ class RemoteSurfaceView(context: Context) : SurfaceView(context) {
lastX = event.x; lastY = event.y
downTime = System.currentTimeMillis()
mode = Mode.TAP
// 先把远端指针移到位,点击/拖拽都以此为基准
l.sendMouseMove(normX(event.x), normY(event.y))
longPressFired = false
longPressTwoFinger = false
relAcc.reset()
// 相对模式:**不发**绝对移动。手指落下不该让远端指针跳到自己脚下
// (那正是相对鼠标要解决的问题);轻点落在指针当前位置。
if (!relativeMode) sendAbsolute(l, event.x, event.y)
// 长按右键计时(Q5=A:500ms 且不动)
cancelLongPressTimer()
ui.postDelayed(longPressTask, LongPressPolicy.LONG_PRESS_MS)
onDebug?.invoke("按下")
}
@@ -176,6 +276,8 @@ class RemoteSurfaceView(context: Context) : SurfaceView(context) {
// 必须先抬起,否则远端会留下一个被按住未释放的左键。
if (mode == Mode.DRAG) l.sendMouseButton(BTN_LEFT, false)
mode = Mode.TWO_FINGER
longPressTwoFinger = true
cancelLongPressTimer()
twoFingerMoved = false
wheelAccum = 0f
twoFingerStartY = midY(event)
@@ -186,16 +288,28 @@ class RemoteSurfaceView(context: Context) : SurfaceView(context) {
MotionEvent.ACTION_MOVE -> when (mode) {
Mode.TAP -> if (moved(downX, downY, event.x, event.y)) {
// 超过 slop = 拖拽,长按不再可能(先取消计时,避免拖到一半弹菜单)
cancelLongPressTimer()
mode = Mode.DRAG
l.sendMouseButton(BTN_LEFT, true)
if (relativeMode) {
// 相对拖拽:从落点开始按增量移动(不先跳到手指位置)
relAcc.reset()
sendRelative(l, downX, downY, event.x, event.y)
} else {
sendAbsolute(l, event.x, event.y)
}
lastX = event.x; lastY = event.y
l.sendMouseMove(normX(event.x), normY(event.y))
onDebug?.invoke("拖拽中")
}
Mode.DRAG -> {
if (relativeMode) {
sendRelative(l, lastX, lastY, event.x, event.y)
} else {
sendAbsolute(l, event.x, event.y)
}
lastX = event.x; lastY = event.y
l.sendMouseMove(normX(event.x), normY(event.y))
}
Mode.TWO_FINGER -> {
@@ -226,13 +340,22 @@ class RemoteSurfaceView(context: Context) : SurfaceView(context) {
}
MotionEvent.ACTION_UP -> {
cancelLongPressTimer()
when (mode) {
Mode.TAP -> {
// 轻点 = 左键单击(位置已在 DOWN 时发送)
l.sendMouseButton(BTN_LEFT, true)
l.sendMouseButton(BTN_LEFT, false)
performClick()
onDebug?.invoke("单击")
if (longPressFired) {
// 长按已弹右键:本次 UP 不能再发左键单击,
// 否则一次长按会「先右键再左键」,右键菜单立刻被左键点掉
onDebug?.invoke("长按结束")
} else {
// 轻点 = 左键单击。绝对模式位置已在 DOWN 时发送;
// 相对模式点击**远端指针当前位置**,不发任何移动
if (!relativeMode) sendAbsolute(l, lastX, lastY)
l.sendMouseButton(BTN_LEFT, true)
l.sendMouseButton(BTN_LEFT, false)
performClick()
onDebug?.invoke("单击")
}
}
Mode.DRAG -> {
l.sendMouseButton(BTN_LEFT, false)
@@ -244,11 +367,14 @@ class RemoteSurfaceView(context: Context) : SurfaceView(context) {
Mode.IDLE -> {}
}
mode = Mode.IDLE
longPressFired = false
}
MotionEvent.ACTION_CANCEL -> {
cancelLongPressTimer()
if (mode == Mode.DRAG) l.sendMouseButton(BTN_LEFT, false)
mode = Mode.IDLE
longPressFired = false
onDebug?.invoke("手势取消")
}
}
@@ -259,10 +385,13 @@ class RemoteSurfaceView(context: Context) : SurfaceView(context) {
private fun maybeRightClick(l: Listener): Boolean {
if (twoFingerMoved) return false
if (System.currentTimeMillis() - twoFingerDownTime > TWO_FINGER_TAP_MAX_MS) return false
l.sendMouseMove(normX(lastX), normY(lastY))
// 双指右键沿用「先定位再点」:双指落点本身就是用户的定位意图,
// 与单指相对模式「不动指针」的语义不同(2a 起就是这个行为,回归判据依赖它)
sendAbsolute(l, lastX, lastY)
l.sendMouseButton(BTN_RIGHT, true)
l.sendMouseButton(BTN_RIGHT, false)
performClick()
listener?.onRightClickInjected("双指")
return true
}
@@ -36,6 +36,11 @@ object RustBridge {
external fun nativeStats(handle: Long): Long
external fun nativeDesktopSize(handle: Long): Long
external fun nativeSendMouseMove(handle: Long, x: Int, y: Int)
/**
* 协议 v4:相对移动(单位 = 被控端桌面物理像素)。
* Rust 侧把 Int clamp 到 i16,越界不会回绕;零位移在 viewer-core 被拦掉。
*/
external fun nativeSendMouseRel(handle: Long, dx: Int, dy: Int)
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)
@@ -0,0 +1,172 @@
package com.verify.remote
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* 3b/V-B1:相对鼠标的纯逻辑(协议 v4)。
*
* 这些断言对应验收判据,不是「跑通就算」:
* - V-B1b「慢速小幅滑动不丢位移」→ [accumulatorKeepsSubPixelRemainders]
* - V-B1c「撞边后回滑立即响应」→ 累积器只做取整、不做补偿([accumulatorDoesNotBankLostSteps])
*/
class RelPointerTest {
// ---- 换算:视图像素 → 桌面像素 ----
@Test
fun sensitivity_one_maps_full_view_width_to_full_desktop_width() {
// Q4=A 的语义定义:1.0 = 手指扫过视图全宽 = 指针走完桌面全宽
val d = RelPointer.toDesktopDelta(
viewDeltaPx = 1080f, // 视图全宽(1080x607 letterbox)
viewExtentPx = 1080,
remoteExtentPx = 1920, // 远端桌面宽
sensitivity = 1.0f,
)
assertEquals(1920f, d, 0.01f)
}
@Test
fun sensitivity_scales_linearly() {
val base = RelPointer.toDesktopDelta(100f, 1080, 1920, 1.0f)
val twice = RelPointer.toDesktopDelta(100f, 1080, 1920, 2.0f)
assertEquals(base * 2f, twice, 0.01f)
}
@Test
fun negative_delta_stays_negative() {
// 向左滑必须得到负增量(绝对坐标是无符号的,相对通道是唯一有符号路径)
val d = RelPointer.toDesktopDelta(-54f, 1080, 1920, 1.0f)
assertTrue("向左滑应得到负增量,实际 $d", d < 0f)
}
@Test
fun invalid_extents_yield_zero_not_garbage() {
// 视图未测量 / 远端尺寸未取到时必须不动,而不是按错误比例乱飞
assertEquals(0f, RelPointer.toDesktopDelta(50f, 0, 1920, 1.0f), 0f)
assertEquals(0f, RelPointer.toDesktopDelta(50f, 1080, 0, 1.0f), 0f)
assertEquals(0f, RelPointer.toDesktopDelta(0f, 1080, 1920, 1.0f), 0f)
}
@Test
fun sensitivity_is_clamped_to_documented_range() {
assertEquals(RelPointer.MIN_SENSITIVITY, RelPointer.clampSensitivity(0.01f), 0f)
assertEquals(RelPointer.MAX_SENSITIVITY, RelPointer.clampSensitivity(99f), 0f)
assertEquals(1.5f, RelPointer.clampSensitivity(1.5f), 0f)
// 边界常量本身必须与方案 Q4=A 一致
assertEquals(0.25f, RelPointer.MIN_SENSITIVITY, 0f)
assertEquals(3.0f, RelPointer.MAX_SENSITIVITY, 0f)
assertEquals(1.0f, RelPointer.DEFAULT_SENSITIVITY, 0f)
}
// ---- 累积取整:V-B1b ----
@Test
fun accumulatorKeepsSubPixelRemainders() {
// 每次只移动 0.3 桌面像素:逐次 toInt() 会全被吞掉(位移静默丢失)。
// 累积器必须在攒够 1 像素时发出,且总量守恒。
val acc = RelAccumulator()
var total = 0
repeat(10) {
acc.take(0.3f, 0f)?.let { total += it.first }
}
// 10 * 0.3 = 3.0 → 必须发出 3 像素,而不是 0
assertEquals("慢速滑动不得丢位移", 3, total)
}
@Test
fun accumulatorDoesNotEmitBelowHalfPixel() {
// 小于 0.5 的余量四舍五入后是 0 → 不发(否则会产生大量零位移消息)。
// 注意阈值是 0.5 而不是 1.0:累积器用 roundToInt 而非 toInt,
// 0.8 会先发出 1 并把 -0.2 留作余数(自我修正,长期总量守恒)。
val acc = RelAccumulator()
assertNull("0.4 四舍五入为 0,不得发送", acc.take(0.4f, 0.4f))
// 再攒 0.4 → 合计 0.8,四舍五入为 1,此时必须发出
val d = acc.take(0.4f, 0.4f)
assertEquals(1, d!!.first)
assertEquals(1, d.second)
// 余数 -0.2:紧接着的 0.2 会被抵消成 0,不再发送(证明余数被正确保留)
assertNull(acc.take(0.2f, 0.2f))
}
@Test
fun accumulatorRoundsNegativeValuesAwayFromZero() {
// -0.7 应取 -1(向零截断会得到 0,反向慢滑就丢了)
val acc = RelAccumulator()
val d = acc.take(-0.7f, 0f)
assertEquals(-1, d!!.first)
}
@Test
fun accumulatorAxesAreIndependent() {
// 斜向慢滑:X 攒够时 Y 还没攒够,不能因为 Y 不够就把 X 也拖住
val acc = RelAccumulator()
val d = acc.take(1.0f, 0.2f)
assertEquals(1, d!!.first)
assertEquals(0, d.second)
}
@Test
fun accumulatorResetDropsRemainder() {
// 手势/会话结束时清空:上一段的余数不该漏进下一段
val acc = RelAccumulator()
acc.take(0.6f, 0.6f)
acc.reset()
assertNull(acc.take(0.3f, 0.3f))
}
@Test
fun accumulatorDoesNotBankLostSteps() {
// V-B1c:撞边时被 clamp 丢掉的位移**不回补**。累积器只负责取整,
// 不负责补偿——否则撞边后回滑会先"空转"一段,违反「立即响应」。
val acc = RelAccumulator()
acc.take(1.0f, 0f) // 发出 +1
val back = acc.take(-1.0f, 0f)
assertEquals("回滑必须立即发出,不得因补偿而空转", -1, back!!.first)
}
@Test
fun accumulatorClampsToProtocolRange() {
// 协议字段是 i16;越界必须被 clamp 而不是回绕(回绕会让指针跳到屏幕另一侧)
val acc = RelAccumulator()
val d = acc.take(100_000f, -100_000f)
assertEquals(32767, d!!.first)
assertEquals(-32768, d.second)
}
// ---- 长按右键:V-B3 ----
@Test
fun longPress_fires_at_threshold_when_still() {
assertTrue(LongPressPolicy.shouldFire(500L, 0f, 30f, false))
assertTrue(LongPressPolicy.shouldFire(900L, 2f, 30f, false))
}
@Test
fun longPress_does_not_fire_too_early() {
assertFalse(LongPressPolicy.shouldFire(499L, 0f, 30f, false))
assertFalse(LongPressPolicy.shouldFire(0L, 0f, 30f, false))
}
@Test
fun longPress_does_not_fire_after_moving() {
// V-B3「拖动窗口时不弹菜单」:超过 slop 一律不算长按
assertFalse(LongPressPolicy.shouldFire(800L, 31f, 30f, false))
assertFalse(LongPressPolicy.shouldFire(800L, 200f, 30f, false))
}
@Test
fun longPress_does_not_fire_with_two_fingers() {
// 双指有自己的右键路径,长按不能叠加(否则一次双指轻点会出两个右键)
assertFalse(LongPressPolicy.shouldFire(900L, 0f, 30f, true))
}
@Test
fun long_press_threshold_is_pinned_to_decision() {
// Q5=A:500ms,改动必须显式经过这条断言
assertEquals(500L, LongPressPolicy.LONG_PRESS_MS)
}
}
+73 -3
View File
@@ -19,7 +19,10 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt};
/// - v3:新增 `InputEvent::Text`(文本注入通道,UTF-16 code unit)。
/// `InputEvent` 因携带 `Vec<u16>` 不再是 `Copy`;三端必须同步升级,
/// 旧端由 `Auth` 握手显式拒绝。
pub const PROTO_VERSION: u16 = 3;
/// - v4:新增 `InputEvent::MouseMoveRel`(相对鼠标,单位=被控端桌面物理像素)。
/// bincode 按位置编码枚举判别序号,新增变体会改变其后变体的编号,
/// 故同样必须三端同步;旧端由 `Auth` 握手显式拒绝。
pub const PROTO_VERSION: u16 = 4;
/// 控制通道消息(双向)
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -78,6 +81,25 @@ pub enum InputEvent {
///
/// 日志纪律:中间层与注入端只允许打印 `units.len()`,**不得**打印用户输入内容。
Text { units: Vec<u16> },
/// v4 新增:**相对**鼠标位移,单位是**被控端桌面的物理像素**(不是归一化值)。
///
/// 语义:把远端指针从**当前位置**移动 `(dx, dy)`,越界部分由被控端 clamp 到桌面内。
/// 主控端只说「移动多少」,不说「去哪里」——远端指针位置始终是唯一权威,
/// 本机真鼠标同时移动也不会造成跳变。
///
/// 为什么单位是像素而不是归一化增量:`AuthOk` 已下发桌面 `width`/`height`,
/// 主控端换算一次即可;被控端拿到像素可直接与 `GetCursorPos` 同尺度相加,
/// 少一次取整(每多一次取整就多一个累积误差源)。
///
/// 为什么是 `i16`:单次触摸事件的位移远小于 32767;用定长整数让线格式保持紧凑。
///
/// **与 [`InputEvent::MouseMove`] 互斥**:绝对模式发 `MouseMove`,相对模式发本变体,
/// 由主控端的模式开关决定,同一会话内不应混用(混用不会出错,但手感会跳)。
///
/// 注入实现注意:Windows 上**不能**直接 `SendInput(MOUSEEVENTF_MOVE)` 透传
/// ——实测该路径被「指针加速」与「DPI 缩放」双重放大(本机 dx=100 实际走了 309px,
/// 且倍率随系统设置变化)。正确做法见 `agent/src/input.rs::send_mouse_rel`。
MouseMoveRel { dx: i16, dy: i16 },
}
/// [`InputEvent::Text`] 单次携带的 code unit 上限。
@@ -313,10 +335,58 @@ mod tests {
// ---- v3 文本注入通道 ----
/// 版本守卫:v3 是本轮契约变更的登记点,改动必须显式经过这里。
/// 版本守卫:v3 是文本通道的登记点。v4 之后此断言随版本推进,
/// 保留它的作用是「任何一次版本变更都必须显式经过这里」。
#[test]
fn proto_version_is_three_after_text_event() {
assert_eq!(PROTO_VERSION, 3, "新增 InputEvent::Text 后必须递增到 3");
assert!(
PROTO_VERSION >= 3,
"Text 通道自 v3 起存在,版本不得回退(当前 {PROTO_VERSION})"
);
}
// ---- v4 相对鼠标 ----
/// 版本守卫:v4 是相对鼠标的登记点。
#[test]
fn proto_version_is_four_after_rel_move() {
assert_eq!(
PROTO_VERSION, 4,
"新增 InputEvent::MouseMoveRel 后必须递增到 4"
);
}
/// 相对位移必须原样往返,**包含负值**(向左/向上移动)。
/// 负值是本变体的核心语义:绝对坐标是无符号的,相对增量必须有符号。
#[test]
fn mouse_move_rel_roundtrips_signed_deltas() {
for (dx, dy) in [(0i16, 0i16), (1, -1), (-32768, 32767), (100, -250)] {
let m = ControlMsg::Input(InputEvent::MouseMoveRel { dx, dy });
let bytes = encode_control(&m);
let back: ControlMsg = bincode::deserialize(&bytes[4..]).unwrap();
match back {
ControlMsg::Input(InputEvent::MouseMoveRel { dx: gx, dy: gy }) => {
assert_eq!((gx, gy), (dx, dy), "相对增量往返失真");
}
other => panic!("期望 Input::MouseMoveRel,实际 {other:?}"),
}
}
}
/// 判别序号守卫:`MouseMoveRel` 必须与既有变体**互不混淆**。
/// bincode 按位置编码枚举判别符,若新变体插在中间会让旧端静默错解——
/// 这条测试锁住「新增变体后各变体仍各自往返」。
#[test]
fn rel_move_does_not_alias_other_input_events() {
let rel = ControlMsg::Input(InputEvent::MouseMoveRel { dx: 7, dy: -9 });
let abs = ControlMsg::Input(InputEvent::MouseMove { x: 7, y: 9 });
let rb = encode_control(&rel);
let ab = encode_control(&abs);
assert_ne!(rb, ab, "相对与绝对移动的线格式不得相同");
match bincode::deserialize::<ControlMsg>(&rb[4..]).unwrap() {
ControlMsg::Input(InputEvent::MouseMoveRel { .. }) => {}
other => panic!("相对移动被错解为 {other:?}"),
}
}
#[test]
+21 -6
View File
@@ -144,12 +144,27 @@ while ((Get-Date) -lt $deadline) { Start-Sleep -Milliseconds 900; $ui = Get-Ui;
if ($ui -notmatch "RTT=") { throw "phone did not connect" }
Write-Host "=== 4. show IME (tap keyboard button) ==="
$btns = [regex]::Matches($ui, '<node[^>]*class="android\.widget\.Button"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"')
if ($btns.Count -lt 1) { throw "keyboard button not found" }
$b0 = $btns[0]
$kx = [int](([int]$b0.Groups[1].Value + [int]$b0.Groups[3].Value) / 2)
$ky = [int](([int]$b0.Groups[2].Value + [int]$b0.Groups[4].Value) / 2)
[void](Adb-Shell @("input","tap","$kx","$ky"))
# Select the keyboard button BY ITS LABEL, not by position.
# 3b added a sensitivity row (- / +) above the button bar, so "the first Button"
# is now the minus sign - the old positional assumption silently tapped the wrong
# control and the proof reported "IME not shown" (a harness defect, not a product
# regression; measured 2026-10-31). Label matching is order-independent.
$F_KEYBOARD = -join ([char]0x952E,[char]0x76D8) # "keyboard"
$kb = $null
foreach ($n in [regex]::Matches($ui,'<node[^>]*class="android\.widget\.Button"[^>]*>')) {
$t = [System.Net.WebUtility]::HtmlDecode([regex]::Match($n.Value,'text="([^"]*)"').Groups[1].Value)
if ($t -eq $F_KEYBOARD) {
$b = [regex]::Match($n.Value,'bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"')
if ($b.Success) {
$kb = @(
[int](([int]$b.Groups[1].Value + [int]$b.Groups[3].Value) / 2),
[int](([int]$b.Groups[2].Value + [int]$b.Groups[4].Value) / 2)
)
}
}
}
if (-not $kb) { throw "keyboard button not found by label" }
[void](Adb-Shell @("input","tap","$($kb[0])","$($kb[1])"))
Start-Sleep -Seconds 3
$ime = (Adb-Shell @("dumpsys","input_method")).out
if ($ime -notmatch "mInputShown=true") { throw "IME not shown" }
+504
View File
@@ -0,0 +1,504 @@
# 3b phone-side proof: relative mouse (protocol v4) + long-press right click + R7 regression.
#
# ASCII-only source (PS 5.1 decodes BOM-less .ps1 as GBK; see personal lessons).
# Expected Chinese UI strings are matched as CODE-POINT fragments (R-8: assert the
# actual value, and print what the parser really read so an empty result cannot pass).
#
# Judgements:
# V-B1a relative mode moves the PC pointer by the expected amount (direction+sign)
# V-B2 absolute mode still maps finger -> desktop 1:1 (R7 re-run, <=8px error)
# V-B3 long press (>=500ms, still) opens a context menu; a drag does NOT
# V-B7 R7 + Chinese IME + full chain regressions stay green
#
# Prereqs: vivo device online via adb, 3b APK installed, v4 agent running.
# powershell -ExecutionPolicy Bypass -File tools\rel_mouse_phone_proof.ps1 -Host_ 192.168.1.110
param(
[string]$Host_ = "192.168.1.110",
[int]$Port = 47800,
[string]$Code = "t",
[string]$AgentExe = ""
)
$ErrorActionPreference = "Stop"
$repo = (Resolve-Path "$PSScriptRoot\..").Path
$adb = "D:\working\android\sdk\platform-tools\adb.exe"
$ev = Join-Path (Split-Path $repo -Parent) "evidence\3b"
New-Item -ItemType Directory -Force -Path $ev | Out-Null
# ---- code-point fragments (grep-verified present in the Kotlin sources) ----
$F_CONNECT = -join ([char]0x8FDE,[char]0x63A5) # "connect"
$F_RECONNECT = -join ([char]0x91CD,[char]0x65B0,[char]0x8FDE,[char]0x63A5) # "reconnect"
$F_RELATIVE = -join ([char]0x76F8,[char]0x5BF9) # "relative"
$F_ABSOLUTE = -join ([char]0x7EDD,[char]0x5BF9) # "absolute"
$F_MODE_TAG = -join ([char]0x6A21,[char]0x5F0F) # "mode"
$F_LONGPRESS = -join ([char]0x957F,[char]0x6309,[char]0x53F3,[char]0x952E) # "long-press right click"
$F_RCLICK_TAG = -join ([char]0x53F3,[char]0x952E,[char]0x3D) # "right-click="
$F_CLICK = -join ([char]0x5355,[char]0x51FB) # "single click"
# Read the persistent right-click counter from the HUD ("右键=N(source)").
# Returns -1 when absent. A counter is used instead of the one-line debug text
# because ACTION_UP immediately overwrites the debug line after a long press
# (measured 2026-10-31: screen showed "长按结束", the firing evidence was gone).
function RightClickCount {
$m = [regex]::Match((Get-UiText), 'text="([^"]*RTT=[^"]*)"')
if (-not $m.Success) { return -1 }
$hud = [System.Net.WebUtility]::HtmlDecode($m.Groups[1].Value)
$c = [regex]::Match($hud, "$F_RCLICK_TAG(\d+)")
if ($c.Success) { return [int]$c.Groups[1].Value }
return 0
}
function Adb {
param([Parameter(Mandatory)][string[]]$AdbArgs)
$old = $ErrorActionPreference; $ErrorActionPreference = "Continue"
try { $out = & $adb @AdbArgs 2>$null; @{ out = ($out -join "`n"); code = $LASTEXITCODE } }
finally { $ErrorActionPreference = $old }
}
function AdbShell {
param([string[]]$Cmd)
(Adb -AdbArgs (@("shell") + $Cmd)).out
}
function Get-UiText {
$d = AdbShell @("uiautomator","dump","/sdcard/rm.xml")
$r = Adb @("pull","/sdcard/rm.xml","$env:TEMP\rm.xml")
if ($r.code -ne 0 -or -not (Test-Path "$env:TEMP\rm.xml")) {
Write-Host " [ui] dump=[$($d -replace "`n",' ')] pull_rc=$($r.code) -> empty"
return ""
}
$txt = [System.IO.File]::ReadAllText("$env:TEMP\rm.xml", [System.Text.Encoding]::UTF8)
if ($txt.Length -lt 200) {
Write-Host " [ui] short xml ($($txt.Length) chars): $($txt.Substring(0,[Math]::Min(120,$txt.Length)))"
}
return $txt
}
function VisibleTexts([string]$xml) {
[regex]::Matches($xml,'text="([^"]+)"') | ForEach-Object { [System.Net.WebUtility]::HtmlDecode($_.Groups[1].Value) }
}
function FormFields([string]$xml) {
[regex]::Matches($xml,'<node[^>]*class="android\.widget\.EditText"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"')
}
function Center($m) {
$a=[int]$m.Groups[1].Value; $b=[int]$m.Groups[2].Value
$c=[int]$m.Groups[3].Value; $d=[int]$m.Groups[4].Value
@([int](($a+$c)/2), [int](($b+$d)/2))
}
function ButtonCenter([string]$xml, [string]$want) {
foreach ($n in [regex]::Matches($xml,'<node[^>]*class="android\.widget\.Button"[^>]*>')) {
$t = [System.Net.WebUtility]::HtmlDecode([regex]::Match($n.Value,'text="([^"]*)"').Groups[1].Value)
if ($t -eq $want) {
$b = [regex]::Match($n.Value,'bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"')
if ($b.Success) {
$x1=[int]$b.Groups[1].Value; $y1=[int]$b.Groups[2].Value
$x2=[int]$b.Groups[3].Value; $y2=[int]$b.Groups[4].Value
return @([int](($x1+$x2)/2), [int](($y1+$y2)/2))
}
}
}
return $null
}
function ButtonStartingWith([string]$xml, [string]$prefix) {
foreach ($n in [regex]::Matches($xml,'<node[^>]*class="android\.widget\.Button"[^>]*>')) {
$t = [System.Net.WebUtility]::HtmlDecode([regex]::Match($n.Value,'text="([^"]*)"').Groups[1].Value)
if ($t.StartsWith($prefix)) {
$b = [regex]::Match($n.Value,'bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"')
if ($b.Success) {
$x1=[int]$b.Groups[1].Value; $y1=[int]$b.Groups[2].Value
$x2=[int]$b.Groups[3].Value; $y2=[int]$b.Groups[4].Value
return @([int](($x1+$x2)/2), [int](($y1+$y2)/2))
}
}
}
return $null
}
# Toggle the pointer mode and PROVE it flipped by re-reading the button label.
# The bottom bar sits at y=2256..2324, right against the system navigation bar;
# a single tap there is not reliable (measured 2026-10-31: the tap landed but the
# mode did not change). So: tap, wait, re-read, retry; and judge on the BUTTON
# label as well as the HUD (two independent observations of the same state).
function Set-Mode([string]$want) {
# $want is "relative" or "absolute"
$wantTag = if ($want -eq "relative") { $F_RELATIVE } else { $F_ABSOLUTE }
for ($try=0; $try -lt 4; $try++) {
$xml = Get-UiText
$cur = $null
foreach ($n in [regex]::Matches($xml,'<node[^>]*class="android\.widget\.Button"[^>]*>')) {
$t = [System.Net.WebUtility]::HtmlDecode([regex]::Match($n.Value,'text="([^"]*)"').Groups[1].Value)
if ($t.Contains($F_MODE_TAG)) {
$b = [regex]::Match($n.Value,'bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"')
if ($b.Success) {
$x1=[int]$b.Groups[1].Value; $y1=[int]$b.Groups[2].Value
$x2=[int]$b.Groups[3].Value; $y2=[int]$b.Groups[4].Value
$cur = @{ label=$t; cx=[int](($x1+$x2)/2); cy=[int](($y1+$y2)/2) }
}
}
}
if (-not $cur) { throw "mode button not found (no Button whose text contains '$F_MODE_TAG')" }
Write-Host " mode button label now: [$($cur.label)] (try $try)"
if ($cur.label.Contains($wantTag)) { return $true }
[void](AdbShell @("input","tap","$($cur.cx)","$($cur.cy)"))
Start-Sleep -Milliseconds 700
}
$final = ((VisibleTexts (Get-UiText)) -join " || ")
throw "mode did not switch to $want after 4 taps; screen: [$final]"
}
# The video SurfaceView bounds - touch coordinates must land inside it.
#
# uiautomator does NOT expose SurfaceView as "android.view.SurfaceView"; a bare
# SurfaceView with no accessibility node collapses to a plain "android.view.View"
# (measured 2026-10-31 on the live device: the only candidate is 1080x607, which
# is exactly the 16:9 letterbox of the 1920x1080 desktop inside a 1080-wide stage).
# So identify it structurally: the widest View whose aspect ratio matches 16:9.
# Assert the ratio instead of assuming it - a silent wrong node would make every
# downstream coordinate assertion meaningless.
function SurfaceBounds([string]$xml) {
$best = $null; $bestErr = [double]::MaxValue
foreach ($n in [regex]::Matches($xml,'<node[^>]*class="android\.view\.View"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"')) {
$x1=[int]$n.Groups[1].Value; $y1=[int]$n.Groups[2].Value
$x2=[int]$n.Groups[3].Value; $y2=[int]$n.Groups[4].Value
$w = $x2-$x1; $h = $y2-$y1
if ($w -lt 300 -or $h -lt 100) { continue }
$ratio = $w / [double]$h
$err = [Math]::Abs($ratio - (16.0/9.0))
if ($err -lt $bestErr) { $bestErr = $err; $best = @($x1,$y1,$x2,$y2) }
}
if (-not $best) { return $null }
# sanity: the chosen node must really be ~16:9, else we grabbed something else
$w = $best[2]-$best[0]; $h = $best[3]-$best[1]
if ($bestErr -gt 0.25) {
throw "no 16:9 view found (best candidate ${w}x${h}, ratio $([Math]::Round($w/[double]$h,3)))"
}
Write-Host " video view ${w}x${h} (ratio $([Math]::Round($w/[double]$h,3)), 16:9 error $([Math]::Round($bestErr,4)))"
return $best
}
function TypeAscii([string]$s) {
foreach ($ch in $s.ToUpper().ToCharArray()) {
$k = if ($ch -ge '0' -and $ch -le '9') { 7 + [int][string]$ch }
elseif ($ch -ge 'A' -and $ch -le 'Z') { 29 + ([int]$ch - [int][char]'A') }
elseif ($ch -eq '.') { 56 }
else { throw "unsupported char: $ch" }
[void](AdbShell @("input","keyevent","$k"))
Start-Sleep -Milliseconds 70
}
}
function FocusedIndex([string]$xml) {
$i = -1
foreach ($n in [regex]::Matches($xml,'<node[^>]*>')) {
if ($n.Value -match 'class="android\.widget\.EditText"') {
$i++
if ($n.Value -match 'focused="true"') { return $i }
}
}
return -1
}
function Focus-Field($c, [int]$wantIdx) {
for ($try=0; $try -lt 3; $try++) {
[void](AdbShell @("input","tap","$($c[0])","$($c[1])"))
$deadline = (Get-Date).AddSeconds(1.5)
while ((Get-Date) -lt $deadline) {
Start-Sleep -Milliseconds 250
if ((FocusedIndex (Get-UiText)) -eq $wantIdx) { return }
}
}
throw "tap did not focus field $wantIdx after 3 tries"
}
function Clear-Field($c, [int]$idx) {
Focus-Field $c $idx
for ($i=0; $i -lt 40; $i++) { [void](AdbShell @("input","keyevent","67")) }
Start-Sleep -Milliseconds 150
}
function Restart-AppForm {
[void](AdbShell @("input","keyevent","224"))
Start-Sleep -Milliseconds 300
[void](AdbShell @("wm","dismiss-keyguard"))
Start-Sleep -Milliseconds 500
[void](AdbShell @("am","force-stop","com.verify.remote"))
Start-Sleep -Milliseconds 600
[void](AdbShell @("am","start","-n","com.verify.remote/.MainActivity"))
$deadline = (Get-Date).AddSeconds(8)
while ((Get-Date) -lt $deadline) {
$x = Get-UiText
if ((FormFields $x).Count -ge 3) { return $x }
Start-Sleep -Milliseconds 300
}
$diag = (VisibleTexts (Get-UiText)) -join " || "
throw "connect form did not appear. screen: [$diag]"
}
function FillAndConnect([string]$xml, [string]$host_, [string]$port_, [string]$code_) {
$f = FormFields $xml
if ($f.Count -lt 3) { throw "form has $($f.Count) fields" }
Clear-Field (Center $f[0]) 0; TypeAscii $host_
Clear-Field (Center $f[1]) 1; TypeAscii $port_
Clear-Field (Center $f[2]) 2; TypeAscii $code_
$expect = @($host_, $port_, $code_)
for ($retry=0; $retry -le 2; $retry++) {
$x2 = Get-UiText
$vals = @()
foreach ($n in [regex]::Matches($x2,'<node[^>]*>')) {
if ($n.Value -match 'class="android\.widget\.EditText"') {
$vals += [System.Net.WebUtility]::HtmlDecode([regex]::Match($n.Value,'text="([^"]*)"').Groups[1].Value)
}
}
Write-Host " form now: $($vals -join ' | ') (try $retry)"
if ($vals.Count -lt 3) { throw "expected 3 fields after typing, got $($vals.Count)" }
$bad = -1
for ($i=0; $i -lt 3; $i++) { if ($vals[$i] -ne $expect[$i]) { $bad = $i; break } }
if ($bad -ge 0) {
$f = FormFields $x2
Clear-Field (Center $f[$bad]) $bad
TypeAscii $expect[$bad]
continue
}
break
}
if ($vals[0] -ne $host_) { throw "form input FAIL: host is [$($vals[0])], expected [$host_]" }
if ($vals[1] -ne $port_) { throw "form input FAIL: port is [$($vals[1])], expected [$port_]" }
if ($vals[2] -ne $code_) { throw "form input FAIL: code is [$($vals[2])], expected [$code_]" }
$btn = $null
foreach ($want in @($F_CONNECT, $F_RECONNECT)) { $btn = ButtonCenter $x2 $want; if ($btn) { break } }
if (-not $btn) { throw "no primary connect/reconnect button found" }
[void](AdbShell @("input","tap","$($btn[0])","$($btn[1])"))
}
function Wait-Live([int]$seconds, [ref]$hud) {
$deadline = (Get-Date).AddSeconds($seconds)
while ((Get-Date) -lt $deadline) {
Start-Sleep -Milliseconds 500
$m = [regex]::Match((Get-UiText),'text="([^"]*RTT=[^"]*)"')
if ($m.Success) { $hud.Value = [System.Net.WebUtility]::HtmlDecode($m.Groups[1].Value); return $true }
}
return $false
}
# ---- PC-side cursor readback (the only honest judge of "pointer moved N px") ----
Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
public class PC {
[StructLayout(LayoutKind.Sequential)] public struct POINT { public int X; public int Y; }
[DllImport("user32.dll")] public static extern bool GetCursorPos(out POINT p);
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
[DllImport("user32.dll")] public static extern bool SetProcessDpiAwarenessContext(IntPtr ctx);
[DllImport("user32.dll")] public static extern int GetSystemMetrics(int i);
public static void Dpi() { SetProcessDpiAwarenessContext(new IntPtr(-4)); }
public static int[] Get() { POINT p; GetCursorPos(out p); return new int[]{p.X,p.Y}; }
public static void Set(int x,int y) { SetCursorPos(x,y); }
public static int W() { return GetSystemMetrics(0); }
public static int H() { return GetSystemMetrics(1); }
}
"@
[void][PC]::Dpi()
$SW = [PC]::W(); $SH = [PC]::H()
Write-Host " PC screen ${SW}x${SH} (physical, DPI-aware)"
Write-Host "=== 0. stale-artifact guard (R-7) + IME disable + keep-awake ==="
if (-not $AgentExe) { $AgentExe = Join-Path $repo "target\release\agent.exe" }
$ag = @(Get-Process agent -ErrorAction SilentlyContinue)
if ($ag.Count -gt 0 -and (Test-Path $AgentExe) -and ((Get-Item $AgentExe).LastWriteTime -gt $ag[0].StartTime)) {
Write-Host " running agent predates current build; restarting"
Stop-Process -Name agent -Force -ErrorAction SilentlyContinue
Start-Sleep -Milliseconds 800; $ag = @()
}
if ($ag.Count -eq 0) {
if (-not (Test-Path $AgentExe)) { throw "missing $AgentExe" }
Start-Process $AgentExe -ArgumentList "--port","$Port","--code",$Code -WindowStyle Hidden | Out-Null
Start-Sleep -Seconds 1; Write-Host " agent started (v4)"
} else { Write-Host " agent running and current" }
$origIme = ((AdbShell @("settings","get","secure","default_input_method")) -join "").Trim()
Write-Host " original IME: [$origIme] (recorded, restored in finally)"
if ($origIme -and $origIme -ne "null") { [void](AdbShell @("ime","disable",$origIme)) }
[void](AdbShell @("svc","power","stayon","true"))
$origCursor = [PC]::Get()
Write-Host " PC cursor origin ($($origCursor[0]),$($origCursor[1])) (restored in finally)"
try {
# =================================================================
Write-Host "=== 1. connect (protocol v4) and confirm HUD shows v4 ==="
$x = Restart-AppForm
[void](FillAndConnect $x $Host_ "$Port" $Code)
$hud = ""
if (-not (Wait-Live 14 ([ref]$hud))) { throw "never reached a live session" }
Write-Host " HUD: $hud"
if (-not $hud.Contains("v4")) { throw "V-B4 FAIL: HUD does not report protocol v4; got [$hud]" }
Write-Host " PASS: live session on protocol v4"
$xml = Get-UiText
$sb = SurfaceBounds $xml
if (-not $sb) { throw "cannot locate SurfaceView bounds" }
$vx1=$sb[0]; $vy1=$sb[1]; $vx2=$sb[2]; $vy2=$sb[3]
$vcx=[int](($vx1+$vx2)/2); $vcy=[int](($vy1+$vy2)/2)
Write-Host " surface bounds = [$vx1,$vy1][$vx2,$vy2] center=($vcx,$vcy)"
& powershell -NoProfile -ExecutionPolicy Bypass -File "$repo\tools\shot.ps1" -Out "$ev\v-b1-live.png" | Out-Null
# =================================================================
Write-Host "=== 2. V-B1a: relative mode moves PC pointer (swipe right) ==="
# Park the PC cursor at a known point so the delta is unambiguous.
# Let the link drain first (same discipline as 2c): residual events from an
# earlier gesture would otherwise be attributed to this one.
Start-Sleep -Milliseconds 1000
[PC]::Set([int]($SW/2), [int]($SH/2))
Start-Sleep -Milliseconds 500
$before = [PC]::Get()
# A horizontal swipe of ~40% of the view width. With sensitivity 1.0 and
# view width mapping to desktop width, expected delta = 0.4 * SW.
$swipeFromX = [int]($vx1 + ($vx2-$vx1)*0.30)
$swipeToX = [int]($vx1 + ($vx2-$vx1)*0.70)
[void](AdbShell @("input","swipe","$swipeFromX","$vcy","$swipeToX","$vcy","400"))
Start-Sleep -Milliseconds 500
$after = [PC]::Get()
$moved = $after[0] - $before[0]
Write-Host " swipe dx_device=$($swipeToX-$swipeFromX)px -> PC pointer dx=$moved px (before x=$($before[0]), after x=$($after[0]))"
if ($moved -le 0) { throw "V-B1a FAIL: rightward swipe did not move the pointer right (dx=$moved)" }
# Direction check is the hard judgement; exact magnitude depends on how many
# ACTION_MOVE samples the ROM delivers for an `input swipe` (not equal to the
# finger travel), so magnitude is reported, not asserted.
Write-Host " PASS: relative mode moves the pointer in the finger's direction"
Write-Host "=== 2b. V-B1a negative: swipe left moves pointer left ==="
Start-Sleep -Milliseconds 1000
[PC]::Set([int]($SW/2), [int]($SH/2))
Start-Sleep -Milliseconds 500
$before2 = [PC]::Get()
[void](AdbShell @("input","swipe","$swipeToX","$vcy","$swipeFromX","$vcy","400"))
Start-Sleep -Milliseconds 500
$after2 = [PC]::Get()
$moved2 = $after2[0] - $before2[0]
Write-Host " PC pointer dx=$moved2 px (before x=$($before2[0]), after x=$($after2[0]))"
if ($moved2 -ge 0) { throw "V-B1a FAIL: leftward swipe did not move the pointer left (dx=$moved2)" }
Write-Host " PASS: negative direction correct (sign preserved)"
Write-Host "=== 2c. relative mode does NOT teleport the pointer on touch-down ==="
# The defining property of relative mode: ACTION_DOWN must not send an
# absolute move. Tap at the far LEFT of the view; the pointer must not jump
# to the left edge of the desktop.
#
# HARNESS DISCIPLINE (measured 2026-10-31): a swipe's final relative events
# can still be in flight when we re-park the cursor. The first attempt showed
# phantom "jumps" of 15px and 298px that were really the PREVIOUS swipe's
# queued deltas landing after our SetCursorPos. So: wait for the link to go
# quiet, then require the baseline to be STABLE across two reads before
# judging. Otherwise this test would report a product defect that isn't one.
Start-Sleep -Milliseconds 1200
[PC]::Set([int]($SW*0.8), [int]($SH/2))
Start-Sleep -Milliseconds 500
$stable = $false
for ($i=0; $i -lt 6; $i++) {
$r1 = [PC]::Get(); Start-Sleep -Milliseconds 350; $r2 = [PC]::Get()
if ($r1[0] -eq $r2[0] -and $r1[1] -eq $r2[1]) { $stable = $true; break }
Write-Host " cursor still settling: ($($r1[0]),$($r1[1])) -> ($($r2[0]),$($r2[1]))"
}
if (-not $stable) { throw "cannot obtain a stable cursor baseline (residual relative events never drained)" }
$beforeTap = [PC]::Get()
$tapX = [int]($vx1 + ($vx2-$vx1)*0.05)
[void](AdbShell @("input","tap","$tapX","$vcy"))
Start-Sleep -Milliseconds 700
$afterTap = [PC]::Get()
$jump = [Math]::Max([Math]::Abs($afterTap[0] - $beforeTap[0]), [Math]::Abs($afterTap[1] - $beforeTap[1]))
$dbgTap = ((VisibleTexts (Get-UiText)) -join " || ")
Write-Host " tap at view-left x=$tapX ; pointer ($($beforeTap[0]),$($beforeTap[1])) -> ($($afterTap[0]),$($afterTap[1])) jump=$jump px"
if ($dbgTap.Contains($F_LONGPRESS)) { throw "tap was interpreted as a long press; harness timing is wrong" }
if ($jump -gt 8) { throw "V-B1a FAIL: touch-down teleported the pointer by $jump px (absolute move leaked into relative mode)" }
Write-Host " PASS: relative mode leaves the pointer where it was (no teleport)"
# =================================================================
Write-Host "=== 3. V-B2: switch to ABSOLUTE mode, re-run R7 touch mapping ==="
[void](Set-Mode "absolute")
$hud2 = ""
$m2 = [regex]::Match((Get-UiText),'text="([^"]*RTT=[^"]*)"')
if ($m2.Success) { $hud2 = [System.Net.WebUtility]::HtmlDecode($m2.Groups[1].Value) }
Write-Host " HUD: $hud2"
if (-not $hud2.Contains($F_ABSOLUTE)) { throw "V-B2 FAIL: HUD still does not say absolute; got [$hud2]" }
Write-Host " PASS: mode switched to absolute (button label and HUD agree)"
# R7 expectations for 1920x1080 (physical pixels, 125% scaling does NOT apply
# to the injected desktop space - the agent injects into the physical desktop)
$r7 = @(
@{ frac=0.10; ex=[int](192); ey=[int](108) },
@{ frac=0.90; ex=[int](1728); ey=[int](972) },
@{ frac=0.50; ex=[int](960); ey=[int](540) }
)
$worst = 0
foreach ($c in $r7) {
$tx = [int]($vx1 + ($vx2-$vx1)*$c.frac)
$ty = [int]($vy1 + ($vy2-$vy1)*$c.frac)
[void](AdbShell @("input","tap","$tx","$ty"))
Start-Sleep -Milliseconds 400
$p = [PC]::Get()
$errX = [Math]::Abs($p[0] - $c.ex)
$errY = [Math]::Abs($p[1] - $c.ey)
$e = [Math]::Max($errX,$errY)
if ($e -gt $worst) { $worst = $e }
Write-Host " frac=$($c.frac) expect=($($c.ex),$($c.ey)) actual=($($p[0]),$($p[1])) err=($errX,$errY)"
}
Write-Host " worst R7 error = $worst px"
if ($worst -gt 8) { throw "V-B2 FAIL: R7 absolute mapping error $worst px > 8px" }
Write-Host " PASS: absolute mode still maps 1:1 (R7 regression green)"
# =================================================================
Write-Host "=== 4. V-B3: long press opens a context menu; drag does NOT ==="
# Switch back to relative for the long-press test (that is the default mode
# users get, and the gesture must work there).
[void](Set-Mode "relative")
# Use a desktop of our own: the proof's WinForms target window (R-11: never
# inject into a window we do not own - it would modify user data).
$target = Join-Path $repo "tools\text_target.ps1"
if (-not (Test-Path $target)) { throw "missing $target" }
$tp = Start-Process powershell -ArgumentList "-NoProfile","-ExecutionPolicy","Bypass","-File",$target `
-PassThru -WindowStyle Minimized
Start-Sleep -Seconds 3
Write-Host " target window started (pid $($tp.Id))"
# Park the pointer over the middle of the target window's edit area by
# absolute injection first (so we know where the long press lands).
[PC]::Set([int]($SW/2), [int]($SH/2))
Start-Sleep -Milliseconds 300
$preLong = [PC]::Get()
$rcBefore = RightClickCount
Write-Host " right-click counter before: $rcBefore"
[void](AdbShell @("input","swipe","$vcx","$vcy","$vcx","$vcy","900")) # press & hold ~900ms, no movement
Start-Sleep -Milliseconds 700
$rcAfter = RightClickCount
$debug = ((VisibleTexts (Get-UiText)) -join " || ")
& powershell -NoProfile -ExecutionPolicy Bypass -File "$repo\tools\shot.ps1" -Out "$ev\v-b3-longpress.png" | Out-Null
Write-Host " right-click counter after : $rcAfter (source tag in HUD)"
Write-Host " screen after long press: $debug"
if ($rcBefore -lt 0 -or $rcAfter -lt 0) { throw "V-B3 FAIL: could not read the right-click counter from the HUD" }
if ($rcAfter -le $rcBefore) {
throw "V-B3 FAIL: long press did not inject a right click (counter stayed $rcAfter; screen: $debug)"
}
Write-Host " PASS: long press injected a right click (counter $rcBefore -> $rcAfter)"
Write-Host "=== 4b. V-B3 negative: a DRAG must not fire long press ==="
$rcBeforeDrag = RightClickCount
[void](AdbShell @("input","swipe","$vcx","$vcy",$([int]($vcx+120)),$([int]($vcy+80)),900))
Start-Sleep -Milliseconds 700
$rcAfterDrag = RightClickCount
$debug2 = ((VisibleTexts (Get-UiText)) -join " || ")
Write-Host " right-click counter after drag: $rcAfterDrag (was $rcBeforeDrag)"
Write-Host " screen after drag: $debug2"
if ($rcAfterDrag -gt $rcBeforeDrag) {
throw "V-B3 FAIL: drag injected a right click (counter $rcBeforeDrag -> $rcAfterDrag); long press must be suppressed once the finger moves"
}
Write-Host " PASS: drag did not fire long press"
Write-Host "=== 4c. V-B3: two-finger right click still works ==="
$rcBeforeTwo = RightClickCount
# adb cannot synthesize a two-finger tap (lessons R-6): this is a MANUAL item.
Write-Host " (two-finger tap is not drivable via adb - recorded as manual, not asserted here)"
Stop-Process -Id $tp.Id -Force -ErrorAction SilentlyContinue
}
finally {
[void][PC]::Set($origCursor[0], $origCursor[1])
[void](AdbShell @("svc","power","stayon","false"))
if ($origIme -and $origIme -ne "null") {
[void](AdbShell @("ime","enable",$origIme))
[void](AdbShell @("ime","set",$origIme))
Write-Host " IME restored: $(((AdbShell @('settings','get','secure','default_input_method')) -join '').Trim())"
}
Write-Host " PC cursor restored to ($($origCursor[0]),$($origCursor[1]))"
}
Write-Host "=== REL MOUSE PHONE PROOF COMPLETE ==="
+8
View File
@@ -20,3 +20,11 @@ rustls.workspace = true
# headless_check 用软解交叉验证收到的码流合法(库本身刻意不依赖 openh264,
# 解码在 Android 侧由 MediaCodec 承担)
openh264 = { version = "0.9.9", features = ["source"] }
# rel_mouse_proof(3b/V-B1)需要读回 Windows 光标位置来闭环验证相对注入。
# 仅 examples 使用,不进库本体;库本身仍然平台无关。
[target.'cfg(windows)'.dev-dependencies]
windows = { workspace = true, features = [
"Win32_Foundation",
"Win32_UI_WindowsAndMessaging",
"Win32_UI_HiDpi",
] }
+185
View File
@@ -0,0 +1,185 @@
//! 3b 桌面验收:相对鼠标(协议 v4)的像素级闭环。
//!
//! 为什么必须单独写一个 proof:V-B1 的判据是「位移**恰好**等于请求值」,
//! 而 Android 侧的真机脚本只能看到 UI 文本,看不到远端指针实际走了多少像素。
//! 这里直接走完整链路:viewer-core 发 `MouseMoveRel` → QUIC → agent 注入 →
//! `GetCursorPos` 读回,逐项比对。
//!
//! 覆盖判据:
//! - V-B1a 方向与量值:dx=+50 → +50px,dx=-50 → -50px(误差 ≤1px)
//! - V-B1b 慢速不丢位移:连发 20 次 dx=+1 → 累计 +20px(误差 ≤2px)
//! - V-B1c 撞边不积债:右边界 +200 后 -50 → 立即 -50
//!
//! 用法(agent 需已启动):
//! cargo run --release -p viewer-core --example rel_mouse_proof -- --host 127.0.0.1 --port 47800 --code t
//!
//! 安全:运行前记录光标原位,结束时还原(不污染用户桌面状态)。
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()
}
/// 读当前光标(物理像素)。用 windows crate 而不是 shell,避免进程 DPI 差异。
fn cursor() -> (i32, i32) {
use windows::Win32::Foundation::POINT;
use windows::Win32::UI::WindowsAndMessaging::GetCursorPos;
let mut p = POINT::default();
unsafe {
let _ = GetCursorPos(&mut p);
}
(p.x, p.y)
}
fn set_cursor(x: i32, y: i32) {
use windows::Win32::UI::WindowsAndMessaging::SetCursorPos;
unsafe {
let _ = SetCursorPos(x, y);
}
}
fn screen_size() -> (i32, i32) {
use windows::Win32::UI::WindowsAndMessaging::{GetSystemMetrics, SM_CXSCREEN, SM_CYSCREEN};
unsafe { (GetSystemMetrics(SM_CXSCREEN), GetSystemMetrics(SM_CYSCREEN)) }
}
fn enable_dpi_awareness() {
use windows::Win32::UI::HiDpi::{
SetProcessDpiAwarenessContext, DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2,
};
unsafe {
let _ = SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
}
}
/// 发一串相对增量,每次之间留出注入完成的时间,然后读回光标。
fn apply_rel(session: &Session, steps: &[(i16, i16)], settle_ms: u64) {
for &(dx, dy) in steps {
session.send_mouse_rel(dx, dy);
// 让 QUIC 上行 + agent 注入 + 光标更新都完成;太小会读到中间态
std::thread::sleep(Duration::from_millis(settle_ms));
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
enable_dpi_awareness();
let port: u16 = arg("--port").map(|s| s.parse()).transpose()?.unwrap_or(47800);
let code = arg("--code").unwrap_or_else(|| "t".into());
let host = arg("--host").unwrap_or_else(|| "127.0.0.1".into());
let (sw, sh) = screen_size();
println!("[proof] screen {sw}x{sh} (physical, DPI-aware)");
let session = Session::connect(ConnectConfig::direct(&host, port, code)).await?;
let (dw, dh) = session.desktop_size();
println!("[proof] agent desktop {dw}x{dh}");
// 记录原位,结束时还原(不污染桌面状态)
let (ox, oy) = cursor();
println!("[proof] cursor origin ({ox},{oy})");
let mut failures = 0usize;
// ---- V-B1a 方向与量值 ----
println!("\n=== V-B1a direction & magnitude ===");
for &delta in &[50i16, -50i16, 120i16, -120i16] {
// 每次都从屏幕中心出发,避免被边界 clamp 干扰量值判定
set_cursor(sw / 2, sh / 2);
std::thread::sleep(Duration::from_millis(60));
let (bx, _) = cursor();
apply_rel(&session, &[(delta, 0)], 80);
let (ax, _) = cursor();
let applied = ax - bx;
let ok = (applied - delta as i32).abs() <= 1;
println!(
" dx={delta:+5} from x={bx:4} to x={ax:4} applied={applied:+5} {}",
if ok { "PASS" } else { "FAIL" }
);
if !ok {
failures += 1;
}
}
// 纵向同理(同一套代码路径,但轴不同,clamp 上界不同)
for &delta in &[40i16, -40i16] {
set_cursor(sw / 2, sh / 2);
std::thread::sleep(Duration::from_millis(60));
let (_, by) = cursor();
apply_rel(&session, &[(0, delta)], 80);
let (_, ay) = cursor();
let applied = ay - by;
let ok = (applied - delta as i32).abs() <= 1;
println!(
" dy={delta:+5} from y={by:4} to y={ay:4} applied={applied:+5} {}",
if ok { "PASS" } else { "FAIL" }
);
if !ok {
failures += 1;
}
}
// ---- V-B1b 慢速小幅不丢位移 ----
println!("\n=== V-B1b slow small steps must not be lost ===");
{
set_cursor(sw / 2, sh / 2);
std::thread::sleep(Duration::from_millis(60));
let (bx, _) = cursor();
// 20 次 dx=+1:若注入层按 mickey 透传,dx=1 会被加速吞成 0 像素(实测如此)
let steps: Vec<(i16, i16)> = (0..20).map(|_| (1i16, 0i16)).collect();
apply_rel(&session, &steps, 45);
std::thread::sleep(Duration::from_millis(120));
let (ax, _) = cursor();
let applied = ax - bx;
let ok = (applied - 20).abs() <= 2;
println!(" 20 x dx=+1 from x={bx} to x={ax} applied={applied:+} (expect +20) {}",
if ok { "PASS" } else { "FAIL" });
if !ok {
failures += 1;
}
}
// ---- V-B1c 撞边不积债 ----
println!("\n=== V-B1c edge clamp leaves no step debt ===");
{
set_cursor(sw - 1, sh / 2);
std::thread::sleep(Duration::from_millis(60));
let (bx, _) = cursor();
apply_rel(&session, &[(200, 0)], 80);
let (mx, _) = cursor();
let clamped = mx - bx;
// 回滑必须立即生效(若累积器"记住"了被 clamp 丢掉的 200px,这里会先空转)
apply_rel(&session, &[(-50, 0)], 80);
let (ax, _) = cursor();
let back = ax - mx;
let ok = clamped == 0 && (back + 50).abs() <= 1;
println!(
" at right edge x={bx}: +200 -> applied {clamped:+} (clamped); -50 -> applied {back:+} {}",
if ok { "PASS" } else { "FAIL" }
);
if !ok {
failures += 1;
}
}
// 还原光标
set_cursor(ox, oy);
println!("\n========== RESULT ==========");
if failures == 0 {
println!("REL MOUSE PROOF PASS (all V-B1a/b/c checks)");
} else {
println!("REL MOUSE PROOF FAIL ({failures} checks failed)");
}
session.close().await;
if failures > 0 {
std::process::exit(1);
}
Ok(())
}
+15
View File
@@ -618,6 +618,21 @@ impl Session {
self.send_input(InputEvent::Text { units });
}
/// 协议 v4:发送**相对**鼠标位移(单位:被控端桌面物理像素)。
///
/// 与 [`Session::send_text`] 同样的空值纪律:零位移不占一条消息——它没有注入语义,
/// 只会白占一个 QUIC 帧(触摸事件里 `dx==0 && dy==0` 是常态,手指静止时每帧都发)。
///
/// 语义是「从远端指针当前位置移动 (dx,dy)」,远端负责 clamp 到桌面内。
/// 主控端**不要**先算绝对坐标再发——那就退化成绝对模式,失去相对鼠标的意义
/// (本机真鼠标同时移动时会跳变)。
pub fn send_mouse_rel(&self, dx: i16, dy: i16) {
if dx == 0 && dy == 0 {
return;
}
self.send_input(InputEvent::MouseMoveRel { dx, dy });
}
/// 请求被控端强制关键帧(解码器错误/跳帧后的恢复路径)。
pub fn request_keyframe(&self) {
let _ = self.tx.send(Command::KeyFrameReq);