diff --git a/android-ffi/src/lib.rs b/android-ffi/src/lib.rs index 09e0f0e..3cce6e8 100644 --- a/android-ffi/src/lib.rs +++ b/android-ffi/src/lib.rs @@ -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( diff --git a/viewer-core/Cargo.toml b/viewer-core/Cargo.toml index 9c559cd..32dcb0b 100644 --- a/viewer-core/Cargo.toml +++ b/viewer-core/Cargo.toml @@ -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", +] } diff --git a/viewer-core/examples/rel_mouse_proof.rs b/viewer-core/examples/rel_mouse_proof.rs new file mode 100644 index 0000000..6ede895 --- /dev/null +++ b/viewer-core/examples/rel_mouse_proof.rs @@ -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 { + let a: Vec = 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(()) +} diff --git a/viewer-core/src/lib.rs b/viewer-core/src/lib.rs index 56f7a91..349d88f 100644 --- a/viewer-core/src/lib.rs +++ b/viewer-core/src/lib.rs @@ -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);