remote/viewer-core/examples/rel_mouse_proof.rs
郑博文 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

186 lines
6.6 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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(())
}