Author SHA1 Message Date
郑博文 0089ae86e1 test(android-2b): 真机 IME 提交 proof 与字节安全证据采集
ime_commit_proof 补上 phone_chain_proof 没覆盖的那半段:IME **显示**状态下
按键被输入法消费,只有 RemoteInputConnection.commitText 能到达我们——这正是
方案风险 R1(vivo ROM + 第三方中文 IME 行为未观测)此前唯一没有证据的点。
真机实测:mInputShown=true、mHaveConnection=true、状态栏 commit 2u、
Windows 靶窗收到 Hi(讯飞把首字母自动大写,属输入法行为,不是链路缺陷)。
back 键第一次只关键盘不断会话,也在这支脚本里断言。

shot.ps1 / capture_evidence.ps1:PS 5.1 的 '>' 以 UTF-16 写文件,
'adb exec-out screencap -p > x.png' 产出的 9 张证据图首字节全是 FF FE,
全部不可用;改为设备内 screencap 落地再 pull,并校验 PNG 签名。

V5 等待预算 30s→50s:不可达 IP 实测 32s 才回表单报 timed out,
预算过小会把「仍在超时中」误判成验收失败。
2026-10-30 13:06:08 +08:00
郑博文 9f0b0742a8 feat(android): 键盘事件双通道 + 手机到 Windows 全链路 proof
真机暴露出 IME/按键通道的缺口,一并补上并证明:
1. RemoteInputConnection.sendKeyEvent 与 Activity.onKeyDown 都先处理**可打印**
   unicodeChar(走 v3 Text 通道),再退到扫描码查表。原先只查表:软键盘发的是
   keyCode=UNKNOWN + unicodeChar,查不到表就被当未映射键吞掉,字整个丢了。
   门槛排除控制值(Enter/Tab)——远端应用要的是按键事件而非 WM_CHAR。
2. onKeyDown/onKeyUp 补齐硬键与物理键盘路径(阶段 2 验收里的软键盘按键),
   repeatCount>0 只发一次 down,避免长按把远端消息队列刷爆。
   isAutorepeat() 是 @hide,改用公开的 getRepeatCount()(javap 核实)。
3. getUnicodeChar() 返回的是单个 UTF-16 code unit 而非码点,注释按此改正:
   BMP 外字符由 IME commitText 成对送达,不经这条路。

tools/phone_chain_proof.ps1:手机按键 -> v3 Text -> agent SendInput -> 自有
Windows 靶窗自报内容,全链路一次跑通(真机 vivo S6 实测 abc 到位、ENTER 走
scan 28、BACK 断开回表单、表单 UI 重连成功)。要点:
- 靶窗必须自报窗口句柄(pid+hwnd 两行),Get-Process 取 MainWindowHandle 有竞态;
- 不能用 keybd_event(VK_MENU) 破前台锁:ALT 之后的首个字符被当菜单助记键吞掉,
  实测三个字符只剩 b;改用 AttachThreadInput + SetForegroundWindow,不合成按键;
- 退役遗留靶窗只能 touch 已存在的 stop.flag:新建会命中即将启动那次的停止路径,
  靶窗刚起就自杀(实测 pid 报了又消失);
- adb 进度信息走 stderr,PS 5.1 + ErrorActionPreference=Stop 会当终止错误,
  按 build-android.ps1 同款办法临时放宽并只看退出码。
- 表单填字用 keyevent 而非 adb input text:设备只有第三方中文 IME,text 注入会被
  组词(ASCII 句点变 U+3002)。跑前记录并禁用原 IME,finally 精确还原。
2026-10-30 12:58:36 +08:00
郑博文 a93430dd3f chore(tools): 2b 注入 proof 路径随仓库位置推导,缺失参数报错文案改正
proof 脚本原先硬编码 D 盘绝对路径,换机器就失效;仓库内已有同约定
(build-android.ps1 用 \ 推导),此处对齐。
text_check 的报错文案说「为空」,实际触发条件是 --units 缺失,改正。
2026-10-30 12:21:19 +08:00
郑博文 79e3e77c97 fix(android): windowSoftInputMode=adjustPan——软键盘弹出不再踢掉会话
默认 adjustResize 会缩小窗口高度,SurfaceView 随之收到 surfaceDestroyed,
而现有回调把 surfaceDestroyed 当作「断开」处理——等于用户一点键盘输入,
2a 验收过的会话就自己断了。真机首验前先做这道防御;adjustPan 只平移窗口,
不改变 surface 尺寸。合并后的 manifest 已核对(android:windowSoftInputMode 存在)。
2026-10-30 12:17:20 +08:00
郑博文 8ffe19cd5f test(agent-core): v3 分发路径单测——默认后端不 panic、截断契约、空输入
新功能必须带测试,agent-core 此前无测试模块,v3 新增的 text 默认实现与
截断分支正好落在盲区里:

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

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

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

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

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

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

InputEvent 因携带 Vec 不再是 Copy,调用方改用 clone;三端必须同步升级,
旧端由 Auth 握手显式拒绝(这正是 PROTO_VERSION 递增的目的)。
新增 6 个单测覆盖 roundtrip、落单代理项、上限边界与 1MiB 长度闸共存。
2026-10-30 11:29:13 +08:00
郑博文 5079d33b0a fix(agent): 显式 max_idle_timeout=10s——强杀主控端后 10 秒内可重连
问题(实测复现)
串行 accept 下,主控端被强杀(force-stop,非正常 close)时 QUIC 连接
不会立即消亡:agent 还在向死连接发帧,新连接只能排队,要等 quinn 默认
idle 超时(约 30s)才能建立。表现为「杀掉 App 重开,一直连不上」。

修法
TransportConfig 显式设 max_idle_timeout=10s。正常会话每 500ms 一个 Ping
永不触发该超时,只回收死连接。10s = 20 个 Ping 周期,抗抖动余量足够。

验证(vivo S6 实测)
- 会话1 auth ok -> force-stop App -> 立即重连
- 10.2s 后 peer connected + auth ok(修复前 ~30s)
2026-10-30 10:38:04 +08:00
郑博文 fe558a1c1e feat(android): 状态栏触摸诊断通道(vivo logcat 被限流的对策)
本机(vivo S6 + 办公 Windows)logcat 只能抓到 4 行,App 日志完全不可靠,
排查触摸/坐标问题时没有任何可用通道。

- RemoteSurfaceView 加 onDebug 回调:ACTION_DOWN/MOVE/UP 的视图坐标与
  发出的 norm 归一化坐标实时上报
- MainActivity 统一 refreshStatus():连接态 + 帧数/RTT + 最近触摸合并成
  一处刷新(原来 8 处各自 statusView.text 互相覆盖,且引用过已删的 showHint)
- 删掉临时步骤标记与 lastMeasureInfo(诊断价值已被 onDebug 覆盖)

实测:状态栏正确显示 UP view=(643,222) norm=(39017,23968) 1080x607,
据此确认了 letterbox 视图尺寸与归一化换算全对,把排查焦点正确引向
agent 注入侧(VIRTUALDESK,已另行修复)。
2026-10-30 10:35:21 +08:00
郑博文 6a6f989595 fix(agent): 绝对坐标注入去掉 VIRTUALDESK——注入空间与采集空间对齐
根因(实测确认)
capture 用 EnumOutputs(0) 只采**主屏**,AuthOk 报告的 width/height 也是主屏;
但 input 注入带 MOUSEEVENTF_VIRTUALDESK,归一化 0..=65535 被映射到**整个虚拟
桌面**。双显示器机器上虚拟桌面(主屏 2560x1440 + 副屏 1920x1080 在左)比主屏
宽得多,手机画面与指针位置系统性错位——点主屏任务栏,指针落到别处。
单显示器时两个空间重合,所以桌面 viewer 联调从未暴露。

修法
MOUSEEVENTF_ABSOLUTE 不带 VIRTUALDESK 时,0..=65535 恰好映射到主显示器,
与采集空间一致。协议注释「鼠标坐标为虚拟桌面归一化」的语义由「整个虚拟桌面」
修正为「被采集的输出」——桌面 viewer 是绝对定位透传,行为不变。

验证(vivo S6 -> 本机双屏,修复后)
- 手机拖微信标题栏:窗口跟随手指移动(drag-before/after 截图对比)
- App 状态栏 norm 坐标与视图坐标换算一致(view=(643,222) norm=(39017,23968))
2026-10-30 10:30:53 +08:00
郑博文 5047618c01 feat(android): 触摸映射 + letterbox 显示(阶段 2a 代码部分)
问题:阶段 1 的 SurfaceView 用 MATCH_PARENT,2560x1440 的视频被拉进
1080x2400 竖屏,画面变形。变形状态下手指位置与远端指针不再一一对应,
触摸映射无从谈起。所以两者必须一起做。

新增 RemoteSurfaceView
- onMeasure 按视频宽高比 letterbox:视图尺寸 = 画面尺寸,黑边不属于视图,
  触摸天然只在画面内产生事件,坐标归一化到 0..=65535 才准确
- 手势:单指轻点=左键、单指拖动=左键拖拽、双指轻点=右键、双指滑动=滚轮
- 刻意不做「手指即光标」的绝对跟随:手指会完全挡住目标,精细操作不可用
- 边界处理:进入拖拽后落下第二指,先抬起左键再切双指手势,
  否则远端会留下一个被按住未释放的按键

协议侧(不改线格式,只加只读查询)
- viewer-core::Session 记录 AuthOk 的桌面尺寸,新增 desktop_size()
- android-ffi 新增 nativeDesktopSize(handle) -> i64(高32=宽,低32=高)
- RustBridge.desktopSize() 拆包
- 尺寸必须在建解码器之前拿到:等首帧再改视图尺寸会触发重新测量,
  把刚创建的解码器 surface 打掉

MainActivity
- 接入 RemoteSurfaceView.Listener;连接成功后先取尺寸设宽高比,再起解码线程
- 返回键 = 断开(不退出 App,便于重连);去掉阶段 1 的双击断开
  (双击现在语义上是「两次左键点击」,会和远端交互冲突)

验证:cargo build --workspace 通过;cargo test 全绿(common 20 + viewer-core 3)。
真机手势验收待手机重新连接后补。
2026-10-30 09:51:38 +08:00
郑博文 f119300a57 feat(agent): 串行多会话——会话结束不再退出进程
问题(阶段 2 的硬阻塞)
accept() 只接一次,会话结束进程退出。阶段 1 一次验收看不出来,
但阶段 2 要反复测试输入,每测一次都要手动重启被控端。
(即公共阻碍清单里的 P5,此前判断为「只影响调试效率」,
 现在它阻塞迭代,提前做掉。)

改动
1. main:会话体抽成 serve_session(),外层 accept 循环。
   会话失败(含认证失败)只记日志不退出进程。
   明确不做并发多会话:DXGI Desktop Duplication 每个输出仅允许一个
   活动实例,第二个 DuplicateOutput 会让第一个收到 ACCESS_LOST。
2. 认证失败路径补 conn.closed() 等待(agent-core 已有同款修复,
   此处同源补齐 = 手册 §6.1 第 1 条):否则客户端只看到 connection
   lost 而不是 AuthFail。
3. capture 循环加 tx.receiver_count()==0 守卫。
   只靠「send 失败退出」不够——桌面完全静止时那个分支根本不发送,
   线程会永远持有 duplication,下一个会话的 DuplicateOutput 直接失败
   (表现为新会话黑屏)。agent-core 同源修复。
4. 会话收尾:finish 流 + drop 接收端 + 等采集线程退出(2s 超时)后才
   返回,调用方才 accept 下一个连接。

验证(连续两个会话,中间不重启 agent)
  会话1:收到帧 40/40,结论 ✅
  agent 存活:✅
  会话2:收到帧 40/40,结论 ✅
  日志:session closed -> waiting for next connection -> peer connected
        -> auth ok;stderr 有 [capture] no receiver, stop 证明线程真退出
2026-10-30 09:31:44 +08:00
郑博文 58bb66f4c2 feat(android): 阶段 1 安卓主控 App 工程(已真机验收出画面)
Kotlin 层三个文件(刻意零第三方依赖,纯 View 布局——阶段 1 只验证链路):
- RustBridge.kt:android-ffi 的 JNI 声明 + 帧头拆解(25 字节定长头 + Annex-B 载荷)
- H264Decoder.kt:MediaCodec 同步模式 + Surface 直出;内置两条实测教训——
  只用 getInputBuffer(i)(Exynos Quirk)、awaitingIdr 状态机(R-3 跳帧断参考链)
- MainActivity.kt:连接/解码循环/触摸绝对坐标映射;断开顺序遵守
  「先停解码线程 join 后 destroy」的 FFI 纪律

构建链(build-android.ps1,一条命令:cargo ndk -> .so -> gradle assembleDebug -> 安装启动):
- 路径自适应( 定位仓库根),local.properties 自动生成
- gradle.properties 固定 JDK 17,不动全局 JAVA_HOME

真机验收(vivo S6 / 192.168.1.163 -> PC / 192.168.1.180:47800):
- agent 日志:peer connected + auth ok, desktop 2560x1440
- 手机截图:Windows 桌面(WPS 文件列表)已渲染在 SurfaceView 上
  证据:evidence/screen-connected.png(本地,不入库)

已知环境限制(记录在个人层 baseline):本机 Windows 防火墙的 agent.exe
程序级放行规则对 UDP 入站不生效(临时关防火墙即通),QQPC 驱动级防护
静默拦 TCP 入站(管理员无法停止/杀死)。阶段 2 前需落定长期方案:
端口级防火墙规则或换开发机。
2026-10-29 19:59:43 +08:00
郑博文 64d497e577 test(viewer-core): headless_check 增加 --host,可指定被控端地址
默认仍 127.0.0.1。加 --host 后能用局域网 IP 跑同一验收,
便于排查「桌面通、手机不通」这类只影响入站路径的问题
(注:本机回环连接不经过防火墙,只有跨主机连接才验证防火墙路径)。
2026-10-29 19:18:11 +08:00
郑博文 44767ed370 feat(android-ffi): viewer-core 的 JNI 薄层,arm64 交叉编译通过(QUIC 栈上手机的第一道关)
方案变更说明(A2 落地修正):原裁决选 UniFFI,但实查 Maven Central 与
maven.mozilla.org 均无 uniffi Kotlin 运行时构件(404),自编译发布成本超过收益。
改为裸 JNI:阶段 0(真机)已验证该链路,接口刻意保持窄(连接/取帧/发输入/销毁),
将来若引入 UniFFI 只替换本层。

线程与锁纪律(决定接口形态):
- nativeNextFrame 等帧期间不持会话锁(clone Arc<Session> 后即释放),
  桌面静止 500ms 帧间隔不会卡住 UI 线程的输入调用
- destroy 前必须先停解码线程,Kotlin 侧顺序纪律保证(已登记风险清单)
- 句柄 = Box 裸指针(永不为 0),destroy 后置 0

帧返回约定:byte[] = [keyframe 标志 1][seq 8][ts 8][w 4][h 4][Annex-B payload],
定长 25 字节头,Kotlin 切片零 JNI 往返。

viewer-core 适配:Session.frames 改 Arc<tokio Mutex>,next_frame 取 &self,
使解码线程与输入线程可并发借用同一 Session。

验证:
- 桌面 cargo build -p android-ffi 零警告
- cargo ndk -t arm64-v8a --release:quinn/ring/rustls 交叉编译全部通过
  (消除阶段 1 最大未知:QUIC 栈能否编到 Android)
- libandroidffi.so 4.2MB,10 个 Java_com_verify_remote_RustBridge_* 符号导出正确
2026-10-29 18:27:34 +08:00
郑博文 34df7b070b feat(viewer-core): 新增安卓主控的连接/协议/收帧层(阶段 1 桌面验证通过)
定位(2026-10-29 方案裁决 A1-A4):
纯 Rust、平台无关,只做「怎么连 + 收发什么」——QUIC 会话、协议 v2 认证、
视频帧流、输入上行、Ping/Pong、KeyFrameReq。解码/渲染/触摸全在 Kotlin 侧,
本 crate 不碰像素、不依赖 openh264(dev-dependency 仅验证工具用)。

设计要点
- PeerTarget::Direct 是唯一的连接形态;不做中继、不定义 Relay 变体
  (《多平台架构设计》§6.1 裁决)。将来设备码+打洞只换 connect() 内部。
- 帧经容量 2 的 mpsc 交付,**不丢帧**:跳帧决策属于看得到解码器状态的消费方
  (Kotlin),Rust 层丢帧会重演 R-3 教训(跳帧断参考链)。
- insecure_skip_verify=false 时明确报错而非静默放行——证书钉扎登记在方案
  阶段 4,安全红线不允许默默降级。
- 双流结构与桌面 viewer 一致(流1 认证+视频,流2 输入+控制),入会即请求 IDR。

验证(headless,桌面→桌面 127.0.0.1)
- 单测 3 passed(帧契约、默认配置、strict-verify 显式报错)
- examples/headless_check:300 帧,seq 违例 0、keyframe 标志不符 0、
  解码成功 300/300、RTT 路径工作(RTT=1ms 出现)
- 全 workspace 回归:common 20 passed,构建全绿
2026-10-29 18:14:27 +08:00
32 changed files with 4067 additions and 33 deletions

No files matched your search

Generated
+155 -15
View File
@@ -31,6 +31,17 @@ dependencies = [
"tokio",
]
[[package]]
name = "android-ffi"
version = "0.1.0"
dependencies = [
"anyhow",
"common",
"jni 0.21.1",
"tokio",
"viewer-core",
]
[[package]]
name = "anyhow"
version = "1.0.104"
@@ -76,7 +87,7 @@ dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"thiserror",
"thiserror 2.0.21",
]
[[package]]
@@ -121,6 +132,12 @@ dependencies = [
"shlex",
]
[[package]]
name = "cesu8"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
[[package]]
name = "cfg-if"
version = "1.0.5"
@@ -387,6 +404,22 @@ dependencies = [
"web-sys",
]
[[package]]
name = "jni"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
dependencies = [
"cesu8",
"cfg-if",
"combine",
"jni-sys 0.3.1",
"log",
"thiserror 1.0.69",
"walkdir",
"windows-sys 0.45.0",
]
[[package]]
name = "jni"
version = "0.22.4"
@@ -396,10 +429,10 @@ dependencies = [
"cfg-if",
"combine",
"jni-macros",
"jni-sys",
"jni-sys 0.4.1",
"log",
"simd_cesu8",
"thiserror",
"thiserror 2.0.21",
"walkdir",
"windows-link",
]
@@ -417,6 +450,15 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "jni-sys"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
dependencies = [
"jni-sys 0.4.1",
]
[[package]]
name = "jni-sys"
version = "0.4.1"
@@ -764,7 +806,7 @@ dependencies = [
"rustc-hash",
"rustls",
"socket2",
"thiserror",
"thiserror 2.0.21",
"tokio",
"tracing",
"web-time",
@@ -788,7 +830,7 @@ dependencies = [
"rustls-pki-types",
"rustls-platform-verifier",
"slab",
"thiserror",
"thiserror 2.0.21",
"tinyvec",
"tracing",
"web-time",
@@ -973,7 +1015,7 @@ checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98"
dependencies = [
"core-foundation",
"core-foundation-sys",
"jni",
"jni 0.22.4",
"log",
"once_cell",
"rustls",
@@ -1231,13 +1273,33 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl 1.0.69",
]
[[package]]
name = "thiserror"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [
"thiserror-impl",
"thiserror-impl 2.0.21",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
@@ -1362,6 +1424,18 @@ dependencies = [
"tokio",
]
[[package]]
name = "viewer-core"
version = "0.1.0"
dependencies = [
"anyhow",
"common",
"openh264",
"quinn",
"rustls",
"tokio",
]
[[package]]
name = "walkdir"
version = "2.5.0"
@@ -1678,13 +1752,22 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets",
"windows-targets 0.52.6",
]
[[package]]
@@ -1696,20 +1779,35 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
dependencies = [
"windows_aarch64_gnullvm 0.42.2",
"windows_aarch64_msvc 0.42.2",
"windows_i686_gnu 0.42.2",
"windows_i686_msvc 0.42.2",
"windows_x86_64_gnu 0.42.2",
"windows_x86_64_gnullvm 0.42.2",
"windows_x86_64_msvc 0.42.2",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
@@ -1721,18 +1819,36 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
@@ -1745,24 +1861,48 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
+3 -1
View File
@@ -2,7 +2,9 @@
resolver = "2"
# agent-core:平台无关会话层 + 软件编码视频管线(当前仅 linux-agent 使用)
# linux-agent:Linux 被控端(X11 + XTest)
members = ["common", "agent-core", "agent", "linux-agent", "viewer"]
# viewer-core:安卓主控的连接/协议/收帧层(桌面 headless 可验证;Android 经 JNI 复用)
# android-ffi:viewer-core 的 JNI 薄层(cdylib,交叉编译成 libandroidffi.so)
members = ["common", "agent-core", "agent", "linux-agent", "viewer", "viewer-core", "android-ffi"]
# 默认成员保持 Windows 三件套,`cargo build --release` 行为与改动前完全一致;
# Linux 被控端显式构建:`cargo build --release -p linux-agent`
default-members = ["common", "agent", "viewer"]
+161
View File
@@ -48,6 +48,21 @@ pub trait InputInjector: Send + Sync + 'static {
fn mouse_button(&self, b: MouseButton, down: bool);
fn wheel(&self, dy: i32);
fn key(&self, scancode: u16, extended: bool, down: bool);
/// 协议 v3 的文本注入(UTF-16 code unit)。
///
/// **默认实现刻意不注入任何东西,但必须留下可见痕迹**:Linux 后端本轮未实现
/// 文本注入(X11 需要 keysym/XKB 映射,与 Windows 的 `KEYEVENTF_UNICODE`
/// 不同构,另立循环)。全局规则要求异常路径有可见后果——用户对着 Linux 被控端
/// 打字时「什么都没发生」不能是静默的,日志必须说明是没实现。
///
/// 日志纪律:只打印长度,**不打印用户输入内容**。
fn text(&self, units: &[u16]) {
eprintln!(
"[agent-core] 文本注入未实现(忽略 {} 个 UTF-16 code unit)",
units.len()
);
}
}
/// 把一条协议输入事件分发到注入后端。
@@ -59,6 +74,17 @@ pub fn dispatch_input(inj: &dyn InputInjector, ev: InputEvent) {
InputEvent::Wheel { dy } => inj.wheel(dy),
InputEvent::KeyDown { scancode, extended } => inj.key(scancode, extended, true),
InputEvent::KeyUp { scancode, extended } => inj.key(scancode, extended, false),
InputEvent::Text { units } => {
// 上限在前端截断,截断必须可见(否则用户以为长文本已送达)。
let (units, truncated) = common::clamp_text_units(units);
if truncated {
eprintln!(
"[agent-core] 文本超过上限,已截断到 {} 个 code unit",
common::MAX_TEXT_UNITS
);
}
inj.text(&units);
}
}
}
@@ -192,6 +218,13 @@ fn capture_session(
let mut seq: u64 = 0;
loop {
// 会话结束守卫:发送端已 drop 接收器时立即退出。
// 与 agent/src/capture.rs 同源——静止桌面分支不发送,只靠 send 失败退出
// 会让线程在画面不动时永不退出(X11 无 DXGI 式独占,危害较小但同样泄漏线程)。
if tx.receiver_count() == 0 {
eprintln!("[capture] no receiver, stop");
return LoopEnd::ChannelClosed;
}
let iter_start = Instant::now();
match cap.acquire(80) {
Ok(true) => match cap.copy_frame() {
@@ -476,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]);
}
}
+8
View File
@@ -387,6 +387,14 @@ pub fn run(tx: Sender<Option<Vec<u8>>>, keyreq: Arc<AtomicBool>, congestion: Arc
}
loop {
// 会话结束守卫:发送端已 drop 接收器时立即退出。
// 只靠 `tx.send` 失败退出是不够的——桌面完全静止时这里根本不发送,
// 线程会永远持有 duplication,导致下一个会话的 DuplicateOutput 失败
// (DXGI 每个输出仅允许一个活动实例)。
if tx.receiver_count() == 0 {
eprintln!("[capture] no receiver, stop");
return;
}
let iter_start = Instant::now();
match dup.acquire(80) {
Ok(true) => {
+87 -6
View File
@@ -1,11 +1,12 @@
//! 输入注入:SendInput 实现(鼠标归一化绝对坐标 + 扫描码键盘)。
//! 输入注入:SendInput 实现(鼠标归一化绝对坐标 + 扫描码键盘 + v3 文本注入)。
use common::{InputEvent, MouseButton};
use windows::Win32::UI::Input::KeyboardAndMouse::{
SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, INPUT_MOUSE, KEYBDINPUT, KEYEVENTF_EXTENDEDKEY,
KEYEVENTF_KEYUP, KEYEVENTF_SCANCODE, MOUSEEVENTF_ABSOLUTE, MOUSEEVENTF_MIDDLEDOWN,
MOUSEEVENTF_MIDDLEUP, MOUSEEVENTF_MOVE, MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP,
MOUSEEVENTF_VIRTUALDESK, MOUSEEVENTF_WHEEL, MOUSEINPUT, VIRTUAL_KEY, MOUSE_EVENT_FLAGS,
KEYEVENTF_KEYUP, KEYEVENTF_SCANCODE, KEYEVENTF_UNICODE, MOUSEEVENTF_ABSOLUTE,
MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP, MOUSEEVENTF_MOVE, MOUSEEVENTF_RIGHTDOWN,
MOUSEEVENTF_RIGHTUP, MOUSEEVENTF_VIRTUALDESK, MOUSEEVENTF_WHEEL, MOUSEINPUT, VIRTUAL_KEY,
MOUSE_EVENT_FLAGS,
};
use windows::Win32::UI::WindowsAndMessaging::{
GetSystemMetrics, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN,
@@ -73,12 +74,79 @@ 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 } => {
// 主控端已按虚拟桌面归一化,这里直接透传 0..65535
// 归一化坐标系 = **被采集的那个输出**(capture 用 EnumOutputs(0),
// 即主屏;AuthOk 的 width/height 也是它)。
// MOUSEEVENTF_ABSOLUTE 不带 VIRTUALDESK 时,0..=65535 恰好映射到
// 主显示器——与采集空间一致。
// 曾经带 VIRTUALDESK:多显示器机器上注入空间变成整个虚拟桌面
// (比主屏宽),手机画面与指针位置系统性错位(2026-10-30 实测)。
// 单显示器时两者重合,所以桌面端从未暴露此问题。
send_mouse(
MOUSEEVENTF_ABSOLUTE.0 | MOUSEEVENTF_VIRTUALDESK.0 | MOUSEEVENTF_MOVE.0,
MOUSEEVENTF_ABSOLUTE.0 | MOUSEEVENTF_MOVE.0,
x as i32,
y as i32,
0,
@@ -99,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);
}
}
}
+53 -8
View File
@@ -1,6 +1,8 @@
//! 被控端 Agent:DXGI 桌面复制采集 → LZ4 压缩 → QUIC 下行视频;上行接收输入事件注入。
//! 被控端 Agent:DXGI 桌面复制采集 → H.264 编码 → QUIC 下行视频;上行接收输入事件注入。
//! P0 优化:视频帧经 watch 单槽通道(latest-wins,网络慢时只发最新帧,不积压);
//! 键鼠/控制走独立 QUIC 双向流,不与视频互相阻塞。
//! 会话模型:**串行多会话**——accept 循环一次只服务一个主控,会话结束后回到 accept
//! 等下一个连接(进程不退出)。改成并发多会话需要先解决 DXGI 单实例独占,暂不做。
mod capture;
mod input;
@@ -10,6 +12,7 @@ use common::{read_control, write_control, ControlMsg};
use rustls::pki_types::PrivatePkcs8KeyDer;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::io::AsyncWriteExt;
use tokio::sync::{mpsc, watch};
@@ -50,6 +53,14 @@ async fn main() -> Result<()> {
transport.receive_window(quinn::VarInt::from_u32(16 * 1024 * 1024));
transport.stream_receive_window(quinn::VarInt::from_u32(8 * 1024 * 1024));
transport.send_window(64 * 1024);
// 空闲超时:主控端被强杀(非正常 close)时收不到它的 Ping,连接会一直占住
// 串行 accept,下一个连接要等几十秒才能建立。10s = 20 个 Ping 周期,
// 正常会话(Ping 每 500ms)永不触发,只用来回收死连接。
transport.max_idle_timeout(Some(
Duration::from_secs(10)
.try_into()
.expect("10s fits in VarInt"),
));
let mut server_config = quinn::ServerConfig::with_single_cert(vec![cert], key.into())?;
server_config.transport_config(Arc::new(transport));
@@ -59,13 +70,31 @@ async fn main() -> Result<()> {
)?;
println!("[agent] listening 0.0.0.0:{port}, verify code = {code}");
let conn = endpoint
.accept()
.await
.ok_or_else(|| anyhow::anyhow!("endpoint closed"))?
.await?;
println!("[agent] peer connected: {}", conn.remote_address());
// 串行多会话:一次只服务一个主控,结束后回到 accept。
// 不做并发多会话——DXGI Desktop Duplication 每个输出仅允许一个活动实例,
// 第二个 DuplicateOutput 会让第一个收到 ACCESS_LOST。
// 会话失败(含认证失败)只记日志,不能让进程退出——否则每次连接都要重启被控端。
loop {
let Some(incoming) = endpoint.accept().await else {
println!("[agent] endpoint closed, exit");
return Ok(());
};
match incoming.await {
Ok(conn) => {
println!("[agent] peer connected: {}", conn.remote_address());
if let Err(e) = serve_session(conn, &code).await {
println!("[agent] session ended: {e:#}");
}
println!("[agent] waiting for next connection");
}
Err(e) => println!("[agent] handshake failed: {e}"),
}
}
}
/// 单次会话:认证 → 采集/发送/输入 → 会话结束。
/// 返回 Err 表示会话异常结束(含认证失败),调用方继续 accept。
async fn serve_session(conn: quinn::Connection, code: &str) -> Result<()> {
// 流 1:认证 + 视频下行
let (mut send, mut recv) = conn.accept_bi().await?;
@@ -93,6 +122,10 @@ async fn main() -> Result<()> {
if let Some(msg) = auth_err {
write_control(&mut send, &ControlMsg::AuthFail(msg.clone())).await?;
send.finish()?;
// 必须等对端读到 AuthFail 再关连接:直接返回会让进程带走 QUIC 缓冲区里
// 尚未发出的数据,客户端只会看到 "connection lost" 而不是 AuthFail。
// (agent-core 已有同款修复,此处同源补齐——手册 §6.1 第 1 条)
let _ = tokio::time::timeout(Duration::from_secs(5), conn.closed()).await;
anyhow::bail!(msg);
}
let (w, h) = capture::desktop_size()?;
@@ -117,7 +150,14 @@ async fn main() -> Result<()> {
// 拥塞反馈标志:发送侧 write 超过单帧预算(20ms) → 置位,采集线程跳帧降注入率
let congestion = Arc::new(AtomicBool::new(false));
let congestion_capture = congestion.clone();
std::thread::spawn(move || capture::run(tx, keyreq_capture, congestion_capture));
// 采集线程退出信号:会话结束必须等它真正退出才能 accept 下一个连接——
// DXGI Desktop Duplication 每个输出只允许一个活动实例,旧线程还持有 duplication
// 时新线程的 DuplicateOutput 会让旧实例收到 ACCESS_LOST(表现为下一个会话黑屏)。
let (done_tx, done_rx) = std::sync::mpsc::channel::<()>();
std::thread::spawn(move || {
capture::run(tx, keyreq_capture, congestion_capture);
let _ = done_tx.send(());
});
// 上行控制消息(Pong)经通道交给主循环写流 2,避免多任务争抢
let (ctl_tx, mut ctl_rx) = mpsc::unbounded_channel::<ControlMsg>();
@@ -182,6 +222,11 @@ async fn main() -> Result<()> {
}
}
reader.abort();
// 关掉流并丢弃接收端,让采集线程下一次 send 失败而退出
let _ = send.finish();
drop(frames_rx);
// 等采集线程真正退出后再返回,调用方才会 accept 下一个连接(DXGI 独占,见上)
let _ = tokio::task::spawn_blocking(move || done_rx.recv_timeout(Duration::from_secs(2))).await;
println!("[agent] session closed");
Ok(())
}
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "android-ffi"
version = "0.1.0"
edition = "2021"
# 安卓主控的 JNI 薄层:把 viewer-core 的异步 Session 包成 Kotlin 可同步调用的句柄。
# 2026-10-29 裁决变更:原方案 A2 选 UniFFI,但其实训运行时不在 Maven Central /
# Mozilla maven(查证 404),需自编译发布,成本超过收益。改为裸 JNI——阶段 0
# (D:\working\android\phase0)已验证该链路真机可跑。接口刻意保持窄(连接/取帧/
# 发输入/销毁),将来若引入 UniFFI 也只是替换本层。
[lib]
name = "androidffi"
crate-type = ["cdylib"]
[dependencies]
viewer-core = { path = "../viewer-core" }
common = { path = "../common" }
anyhow.workspace = true
tokio.workspace = true
jni = "0.21"
+355
View File
@@ -0,0 +1,355 @@
//! android-ffi:viewer-core 的 JNI 薄层(阶段 1)。
//!
//! 线程模型(决定接口形态的原因):
//! - Kotlin 解码线程调用 `nativeNextFrame` 同步取帧(内部 block_on,一次一帧);
//! - Kotlin UI 线程调用 `nativeSend*` 发输入——tokio unbounded channel 入队不阻塞;
//! - 共用一个 current-thread runtime:`block_on(next_frame)` 期间 runtime 仍在驱动
//! QUIC 收发与 Ping;输入入队只是 channel send,不需要 runtime 参与。
//!
//! **锁纪律**:`nativeNextFrame` 在等帧期间**不得持有**会话锁——桌面静止时帧间隔
//! 可达 500ms,持锁会让 UI 线程的输入调用同步卡住。做法:短暂持锁克隆
//! `Arc<Session>` 与 `&'static Runtime` 引用后立即释放,再 block_on。
//! 代价:block_on 期间 destroy 会 use-after-free——阶段 1 由 Kotlin 侧
//! 「先停解码线程、后 destroy」的顺序纪律保证(登记在方案风险清单)。
//!
//! 句柄约定:`nativeConnect` 返回 Box 裸指针(永不为 0)作为 jlong;
//! `nativeDestroy` 释放。句柄释放后不得再使用,Kotlin 侧用置 0 保护。
//!
//! 帧返回约定:byte[],布局 = [1 标志][8 seq LE][8 ts LE][4 w LE][4 h LE][payload]。
//! 定长头 25 字节;Kotlin 读 buf[0] 判断 keyframe(标志位复用为:0=P 帧, 1=IDR 帧,
//! 会话结束则整个返回 null)。
use common::InputEvent;
use jni::objects::{JCharArray, JClass, JObject, JString};
use jni::sys::{jbyteArray, jint, jlong, jstring};
use jni::JNIEnv;
use std::sync::{Arc, Mutex};
use viewer_core::{ConnectConfig, Session};
/// 帧头长度:keyframe 标志(1) + seq(8) + ts(8) + w(4) + h(4)
const FRAME_HEADER_LEN: usize = 25;
struct SessionBox {
session: Arc<Session>,
runtime: tokio::runtime::Runtime,
}
/// 连接被控端。成功返回句柄(>0),失败返回 0;错误消息经 `nativeLastError()` 取。
#[no_mangle]
pub extern "system" fn Java_com_verify_remote_RustBridge_nativeConnect<'local>(
mut env: JNIEnv<'local>,
_class: JClass<'local>,
host: JString<'local>,
port: jint,
code: JString<'local>,
) -> jlong {
let host_s = match env.get_string(&host) {
Ok(s) => s.to_string_lossy().into_owned(),
Err(_) => {
set_last_error("host 参数不是合法字符串");
return 0;
}
};
let code_s = match env.get_string(&code) {
Ok(s) => s.to_string_lossy().into_owned(),
Err(_) => {
set_last_error("code 参数不是合法字符串");
return 0;
}
};
let built = std::panic::catch_unwind(|| {
let runtime = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(r) => r,
Err(e) => return Err(format!("runtime init: {e}")),
};
let cfg = ConnectConfig::direct(host_s, port as u16, code_s);
match runtime.block_on(Session::connect(cfg)) {
Ok(session) => Ok(SessionBox {
session: Arc::new(session),
runtime,
}),
Err(e) => Err(format!("{e:#}")),
}
});
match built {
Ok(Ok(boxed)) => {
set_last_error("");
Box::into_raw(Box::new(Mutex::new(boxed))) as jlong
}
Ok(Err(msg)) => {
set_last_error(&msg);
0
}
Err(_) => {
set_last_error("panic in nativeConnect");
0
}
}
}
fn set_last_error(msg: &str) {
let mut slot = LAST_ERROR.lock().unwrap();
if msg.is_empty() {
*slot = None;
} else {
*slot = Some(msg.to_string());
}
}
static LAST_ERROR: Mutex<Option<String>> = Mutex::new(None);
/// 取最近一次 nativeConnect 的错误消息(无错误返回 null)。
#[no_mangle]
pub extern "system" fn Java_com_verify_remote_RustBridge_nativeLastError<'local>(
env: JNIEnv<'local>,
_class: JClass<'local>,
) -> jstring {
let msg = LAST_ERROR.lock().unwrap().take();
match msg {
Some(m) => env
.new_string(m)
.map(|s| s.into_raw())
.unwrap_or(std::ptr::null_mut()),
None => std::ptr::null_mut(),
}
}
/// 取下一帧(阻塞直到有帧或会话结束)。返回 null = 会话结束。
/// 仅允许解码线程调用;destroy 前必须先停止本线程(见文件头锁纪律)。
#[no_mangle]
pub extern "system" fn Java_com_verify_remote_RustBridge_nativeNextFrame<'local>(
env: JNIEnv<'local>,
_class: JClass<'local>,
handle: jlong,
) -> jbyteArray {
// 短暂持锁:克隆 Arc 与拿 runtime 引用,随后立即释放——等帧不能挡输入。
let (session, runtime) = {
let Some(boxed) = handle_to_box(handle) else {
return std::ptr::null_mut();
};
let guard = lock_box(boxed);
(guard.session.clone(), &guard.runtime as *const tokio::runtime::Runtime)
};
// SAFETY: 见文件头「锁纪律」。Kotlin 保证解码线程存活期间不 destroy。
let rt = unsafe { &*runtime };
match rt.block_on(session.next_frame()) {
None => std::ptr::null_mut(),
Some(f) => {
let mut buf = vec![0u8; FRAME_HEADER_LEN + f.payload.len()];
buf[0] = f.keyframe as u8;
buf[1..9].copy_from_slice(&f.seq.to_le_bytes());
buf[9..17].copy_from_slice(&f.ts_ms.to_le_bytes());
buf[17..21].copy_from_slice(&f.width.to_le_bytes());
buf[21..25].copy_from_slice(&f.height.to_le_bytes());
buf[FRAME_HEADER_LEN..].copy_from_slice(&f.payload);
match env.byte_array_from_slice(&buf) {
Ok(a) => a.into_raw(),
Err(_) => std::ptr::null_mut(),
}
}
}
}
/// 会话统计打包:高 32 位 rtt_ms,低 32 位 frames_in 低 32 位。-1 = 句柄无效。
#[no_mangle]
pub extern "system" fn Java_com_verify_remote_RustBridge_nativeStats(
_env: JNIEnv,
_class: JClass,
handle: jlong,
) -> jlong {
let Some(boxed) = handle_to_box(handle) else {
return -1;
};
let guard = lock_box(boxed);
let st = guard.session.stats();
// 打包进 i64:高 32 位 rtt_ms、低 32 位 frames_in,Kotlin 侧移位还原
(((st.rtt_ms & 0xFFFF_FFFF) << 32) | (st.frames_in & 0xFFFF_FFFF)) as jlong
}
/// 被控端桌面尺寸,打包成 i64:高 32 位宽、低 32 位高。0 = 句柄无效。
/// Kotlin 侧必须在建解码器之前调用,用返回值定 SurfaceView 宽高比。
#[no_mangle]
pub extern "system" fn Java_com_verify_remote_RustBridge_nativeDesktopSize(
_env: JNIEnv,
_class: JClass,
handle: jlong,
) -> jlong {
let Some(boxed) = handle_to_box(handle) else {
return 0;
};
let guard = lock_box(boxed);
let (w, h) = guard.session.desktop_size();
(((w as u64 & 0xFFFF_FFFF) << 32) | (h as u64 & 0xFFFF_FFFF)) as jlong
}
#[no_mangle]
pub extern "system" fn Java_com_verify_remote_RustBridge_nativeSendMouseMove(
_env: JNIEnv,
_class: JClass,
handle: jlong,
x: jint,
y: jint,
) {
with_session(handle, |s| {
s.send_input(InputEvent::MouseMove {
x: x.clamp(0, 65535) as u16,
y: y.clamp(0, 65535) as u16,
})
});
}
/// button: 0=左 1=右 其他=中;down: 1=按下 0=抬起
#[no_mangle]
pub extern "system" fn Java_com_verify_remote_RustBridge_nativeSendMouseButton(
_env: JNIEnv,
_class: JClass,
handle: jlong,
button: jint,
down: jint,
) {
use common::MouseButton;
let b = match button {
0 => MouseButton::Left,
1 => MouseButton::Right,
_ => MouseButton::Middle,
};
with_session(handle, |s| {
s.send_input(if down != 0 {
InputEvent::MouseDown(b)
} else {
InputEvent::MouseUp(b)
})
});
}
#[no_mangle]
pub extern "system" fn Java_com_verify_remote_RustBridge_nativeSendWheel(
_env: JNIEnv,
_class: JClass,
handle: jlong,
dy: jint,
) {
with_session(handle, |s| s.send_input(InputEvent::Wheel { dy }));
}
#[no_mangle]
pub extern "system" fn Java_com_verify_remote_RustBridge_nativeSendKey(
_env: JNIEnv,
_class: JClass,
handle: jlong,
scancode: jint,
extended: jint,
down: jint,
) {
with_session(handle, |s| {
s.send_input(if down != 0 {
InputEvent::KeyDown {
scancode: scancode as u16,
extended: extended != 0,
}
} else {
InputEvent::KeyUp {
scancode: scancode as u16,
extended: extended != 0,
}
})
});
}
/// 协议 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,
_class: JClass,
handle: jlong,
) {
with_session(handle, |s| s.request_keyframe());
}
/// 释放会话。Kotlin 侧调用后必须把句柄置 0,且先停解码线程。
#[no_mangle]
pub extern "system" fn Java_com_verify_remote_RustBridge_nativeDestroy(
_env: JNIEnv,
_class: JClass,
handle: jlong,
) {
if handle == 0 {
return;
}
let real = handle as *mut Mutex<SessionBox>;
unsafe {
let _ = Box::from_raw(real);
}
}
fn handle_to_box(handle: jlong) -> Option<&'static Mutex<SessionBox>> {
if handle == 0 {
return None;
}
let real = handle as *mut Mutex<SessionBox>;
// 悬垂指针无法在 Rust 侧检测:阶段 1 由 Kotlin「destroy 后置 0 + 先停线程」
// 的顺序纪律保证。违反即 UB,登记在方案风险清单(阶段 2 可加代际表消除)。
// NonNull::new 是安全函数;解引用才是 unsafe 点
std::ptr::NonNull::new(real).map(|p| unsafe { p.as_ref() })
}
/// 锁中毒容忍:SessionBox 内部无跨线程共享可变状态(Session 各方法内部自带
/// 并发原语),中毒后继续用是安全的。
fn lock_box(b: &Mutex<SessionBox>) -> std::sync::MutexGuard<'_, SessionBox> {
match b.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
}
}
fn with_session(handle: jlong, f: impl FnOnce(&Session)) {
let Some(boxed) = handle_to_box(handle) else {
return;
};
let guard = lock_box(boxed);
f(&guard.session);
}
+15
View File
@@ -0,0 +1,15 @@
# Gradle / Android 构建产物
.gradle/
build/
app/build/
local.properties
# Rust 交叉编译产物(由 build-android.ps1 生成到 app/libs)
app/libs/
# 验证截图
evidence/
# IDE
.idea/
*.iml
+56
View File
@@ -0,0 +1,56 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.verify.remote"
compileSdk = 34
defaultConfig {
applicationId = "com.verify.remote"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "0.1-phase1"
// 阶段 1 只测真机 arm64;x86_64 留给模拟器回归
ndk {
abiFilters += listOf("arm64-v8a")
}
}
buildTypes {
release {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = "11"
}
// .so 由外部脚本(build-android.ps1)预先拷到 app/libs/arm64-v8a/,
// 不让 Gradle 调 cargo——构建职责分离:Rust 侧失败与 Gradle 侧失败可立刻区分。
sourceSets {
getByName("main") {
jniLibs.srcDirs("libs")
}
}
packaging {
jniLibs {
// AGP 默认已按 useLegacyPackaging 处理;保持默认压缩+extractNativeLibs=false
// 由 AGP 保证 STORED+对齐,手动链的坑(lessons 记录)在 Gradle 下不存在
useLegacyPackaging = false
}
}
}
dependencies {
// 阶段 1 刻意零第三方 UI 依赖:纯 View 代码布局,减少变量。
// AndroidX 在阶段 2 做正式 UI 时再引入。
}
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 阶段 1:局域网连 agent.exe 需要网络 -->
<uses-permission android:name="android.permission.INTERNET" />
<application
android:label="Remote Viewer"
android:hasCode="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:keepScreenOn="true"
android:windowSoftInputMode="adjustPan"
android:configChanges="orientation|screenSize|keyboardHidden">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,159 @@
package com.verify.remote
import android.media.MediaCodec
import android.media.MediaFormat
import android.util.Log
import android.view.Surface
import java.nio.ByteBuffer
/**
* MediaCodec H.264 解码器封装(阶段 1:同步模式 + Surface 直出)。
*
* 两条实测教训直接写进实现(来源见个人层 lessons / baseline):
* 1. Exynos 等设备带 requires-allocate-on-input/output-ports Quirk,
* 必须用 getInputBuffer(i)/getOutputBuffer(i),不用已废弃的数组版 API。
* 2. 参考帧链断裂后 P 帧必然解不出:解码线程维护 awaitingIdr 状态,
* 只喂 IDR 之后的帧;错误后 flush + requestKeyframe 并等 IDR(R-3)。
*
* MediaFormat 不设 csd:码流每个 IDR 自带 SPS/PPS(dump_stream 实测
* "SPS PPS IDR" 结构),解码器从码流内联参数集自举。
*/
class H264Decoder(private val surface: Surface) {
@Volatile
var stopped = false
private set
private var codec: MediaCodec? = null
private val bufferInfo = MediaCodec.BufferInfo()
/** 等待 IDR 标志:true 时 P 帧一律不喂解码器 */
private var awaitingIdr = true
private var configuredW = 0
private var configuredH = 0
/** 解码一帧。返回 false = 该帧被丢弃(等 IDR 中或缓冲满),不视为错误。 */
fun queue(frame: RustBridge.Frame): Boolean {
// 等待 IDR:参考链未建立/已断裂时,P 帧喂进去只会产生错误
if (awaitingIdr && !frame.keyframe) return false
if (frame.keyframe) awaitingIdr = false
val c = ensureCodec(frame) ?: return false
return try {
val idx = c.dequeueInputBuffer(INPUT_TIMEOUT_US)
if (idx < 0) return false // 输入满:阶段 1 直接丢,由消费方决定
val inBuf: ByteBuffer = c.getInputBuffer(idx) ?: return false
inBuf.clear()
inBuf.put(frame.payload)
c.queueInputBuffer(idx, 0, frame.payload.size, frame.tsMs * 1000, 0)
drainOnce(c)
true
} catch (e: MediaCodec.CodecException) {
Log.e(TAG, "codec error isRecoverable=${e.isRecoverable}", e)
invalidate()
false
} catch (e: IllegalStateException) {
Log.e(TAG, "codec illegal state", e)
invalidate()
false
}
}
/** 解码错误/跳帧后调用:flush 复位解码器(三级恢复第一级),
* 置等待标志;调用方负责 requestKeyframe。 */
fun invalidate() {
awaitingIdr = true
try {
codec?.flush()
} catch (e: Exception) {
// flush 失败说明解码器不可恢复:销毁,等下一个 IDR 重建
Log.w(TAG, "flush failed, releasing codec", e)
releaseCodec()
}
}
/** 确保解码器就绪。只在 keyframe 到来时才配置/重建(首配与重配同路径)。 */
private fun ensureCodec(frame: RustBridge.Frame): MediaCodec? {
val c = codec
if (c != null && configuredW == frame.width && configuredH == frame.height) {
return c
}
if (c != null) {
Log.i(TAG, "size changed ${configuredW}x$configuredH -> ${frame.width}x${frame.height}, reconfigure")
releaseCodec()
}
if (codec == null && frame.keyframe) {
val name = pickDecoderName()
val nc = if (name.isEmpty()) MediaCodec.createDecoderByType(MIME)
else MediaCodec.createByCodecName(name)
val fmt = MediaFormat.createVideoFormat(MIME, frame.width, frame.height)
nc.configure(fmt, surface, null, 0)
nc.start()
codec = nc
configuredW = frame.width
configuredH = frame.height
Log.i(TAG, "decoder configured ${frame.width}x${frame.height} name=${nc.name}")
}
return codec
}
/** 优先硬解;查不到就返回空串让系统自选(避免选中软件解码器)。 */
private fun pickDecoderName(): String {
return try {
android.media.MediaCodecList(android.media.MediaCodecList.REGULAR_CODECS)
.codecInfos
.firstOrNull { info ->
!info.isEncoder &&
info.supportedTypes.any { t -> t.equals(MIME, true) } &&
!info.name.startsWith("OMX.google.") &&
!info.name.startsWith("c2.android.")
}?.name ?: ""
} catch (e: Exception) {
""
}
}
private fun drainOnce(c: MediaCodec) {
while (!stopped) {
val idx = c.dequeueOutputBuffer(bufferInfo, 0)
when {
idx == MediaCodec.INFO_TRY_AGAIN_LATER -> return
idx == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED ->
Log.i(TAG, "output format: ${c.outputFormat}")
idx >= 0 -> {
// Surface 直出:渲染由解码器完成,不取图像数据
c.releaseOutputBuffer(idx, true)
}
else -> return
}
}
}
fun release() {
stopped = true
releaseCodec()
}
private fun releaseCodec() {
try {
codec?.stop()
} catch (e: Exception) {
Log.w(TAG, "stop ignored", e)
}
try {
codec?.release()
} catch (e: Exception) {
Log.w(TAG, "release ignored", e)
}
codec = null
configuredW = 0
configuredH = 0
}
companion object {
private const val TAG = "H264Decoder"
private const val MIME = "video/avc"
private const val INPUT_TIMEOUT_US = 4_000L
}
}
@@ -0,0 +1,499 @@
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
/**
* 安卓主控端(阶段 2b):正式连接界面 + 远控画面 + 触摸操作 + 软键盘文本输入。
*
* 两态:
* - 连接态:host / port / code 表单 + 状态文本(阶段 1/2a 只能靠 `am start --es host`
* 传参,本界面对它补位;intent extras 仍保留且优先级更高,构建脚本继续可用)。
* - 远控态:letterbox 画面 + 底部状态/操作条(帧数、RTT、诊断、键盘、断开)。
*
* 线程模型(与 android-ffi 的锁纪律对应):
* - UI 线程:触摸/按键/IME 提交 → nativeSend*(入队不阻塞);连接/断开
* - 解码线程:nativeNextFrame 阻塞取帧 → H264Decoder.queue
* - 断开顺序:**先置 stopDecoding 并 join 解码线程,再 destroy**
* (Rust 侧明确:解码线程存活期间 destroy 是 UB)
*
* 持久化:只存 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
@Volatile
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
/** 最近一次输入事件(触摸/IME)诊断:logcat 被限流,状态栏是唯一可靠通道 */
@Volatile
private var lastTouchDbg = "-"
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Log.i(TAG, "onCreate start")
surfaceView = RemoteSurfaceView(this).apply {
listener = this@MainActivity
onDebug = { msg -> lastTouchDbg = msg; refreshStatus() }
holder.addCallback(object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) {}
override fun surfaceChanged(h: SurfaceHolder, f: Int, w: Int, ht: Int) {}
override fun surfaceDestroyed(holder: SurfaceHolder) {
// 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(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 里拿到的
// MeasureSpec 就成了 EXACTLY 全尺寸,letterbox 比例收缩被直接忽略
// (2026-10-30 实测:视图被撑成 1080x2001,触摸按 2001 归一化 → 坐标全错)。
// MATCH_PARENT + gravity=center 时父容器传 AT_MOST,onMeasure 的
// setMeasuredDimension 才真正决定尺寸,再被 gravity 居中摆放。
val stage = FrameLayout(this).apply {
setBackgroundColor(0xFF000000.toInt())
addView(
surfaceView,
FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT,
Gravity.CENTER,
),
)
}
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, LayoutParams(LayoutParams.MATCH_PARENT, dp(64)))
addView(bar, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT))
}
}
private fun showConnectState(msg: String) {
connecting = false
remotePanelVisible(false)
connectStatus.text = msg
}
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 ----
override fun sendMouseMove(x: Int, y: Int) {
val h = handle
if (h != 0L) RustBridge.nativeSendMouseMove(h, x, y)
}
override fun sendMouseButton(button: Int, down: Boolean) {
val h = handle
if (h != 0L) RustBridge.nativeSendMouseButton(h, button, if (down) 1 else 0)
}
override fun sendWheel(dy: Int) {
val h = handle
if (h != 0L) RustBridge.nativeSendWheel(h, dy)
}
/** 协议 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
/**
* 按键转发(物理键盘 / adb 注入 / 无 IME 时的软键盘):
* - 返回键:先收 IME,否则断开回表单(阶段 2a 语义延续);
* - **可打印**的 `unicodeChar`(0x20 起、排除 0x7F)→ v3 **Text** 通道:
* 字符注入与远端键盘布局无关。注意 `getUnicodeChar()` 给的是单个
* UTF-16 code unit 而非码点,所以这条路只覆盖 BMP;增补平面字符由
* IME `commitText` 成对送达,不经这里;
* - 控制值(Enter/Tab/方向键…)与其余控制键 → Set-1 扫描码通道(down/up 配对):
* 远端应用期待的是按键事件,不是 WM_CHAR;
* - 自动重复(`repeatCount > 0`,长按)只发一次 down:重复由远端键盘驱动,
* 本地重发会打爆消息队列。
*
* 与 IME 路径互斥:IME 显示时按键先被输入法消费(走 commitText),不会到这里;
* IME 隐藏时才走本方法。两条路同时生效会重复注入。
*
* 诊断:vivo logcat 被限流(lessons R-6),路由结果写状态栏,只报 keyCode
* 与去向,不报字符内容。
*/
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
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)
}
val h = handle
if (h == 0L || event == null) return super.onKeyDown(keyCode, event)
// 只报 keyCode 与路由结果,不报字符内容(诊断通道会显示在屏幕上)
val cp = event.unicodeChar
if (cp >= 0x20 && cp != 0x7F) {
if (event.repeatCount == 0) RustBridge.nativeSendText(h, charArrayOf(cp.toChar()))
lastTouchDbg = "key code=$keyCode -> text 1u"
runOnUiThread { refreshStatus() }
return true
}
val key = WinScancode.of(keyCode) ?: run {
lastTouchDbg = "key code=$keyCode 未映射"
runOnUiThread { refreshStatus() }
return super.onKeyDown(keyCode, event)
}
if (event.repeatCount == 0) {
RustBridge.nativeSendKey(h, key.scancode, if (key.extended) 1 else 0, 1)
}
lastTouchDbg = "key code=$keyCode -> scan ${key.scancode} ext=${key.extended}"
runOnUiThread { refreshStatus() }
return true
}
override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean {
val h = handle
if (keyCode == KeyEvent.KEYCODE_BACK || h == 0L || event == null) {
return super.onKeyUp(keyCode, event)
}
// 字符事件没有「抬起」语义(Text 通道是整段提交),只回扫控制键。
// 门槛与 onKeyDown 一致:不可打印的 unicodeChar(Enter/Tab)仍需发 key up,
// 否则 onKeyDown 发了 down、这里不发 up,远端会留下按住未释放的键。
val cp = event.unicodeChar
if (cp >= 0x20 && cp != 0x7F) return true
val key = WinScancode.of(keyCode) ?: return super.onKeyUp(keyCode, event)
RustBridge.nativeSendKey(h, key.scancode, if (key.extended) 1 else 0, 0)
return true
}
// ---- 连接 / 解码 ----
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 {
val h = RustBridge.connect(host, port, code)
// 尺寸必须在解码前拿到:用它定 SurfaceView 宽高比。
// 若等首帧再改,视图会重新测量,把刚建好的解码器 surface 打掉。
val (w, ht) = RustBridge.desktopSize(h)
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
}
refreshStatus()
startDecodeLoop(h)
}
} catch (e: Exception) {
Log.e(TAG, "connect failed", e)
val msg = e.message ?: "连接失败"
runOnUiThread {
connectButton.isEnabled = true
handle = 0L
showConnectState("连接失败:$msg")
}
}
}
}
private fun startDecodeLoop(h: Long) {
stopDecoding = false
decodeThread = thread(name = "decode") {
val surface = surfaceView.holder.surface
var tries = 0
while (!surface.isValid && tries++ < 100) Thread.sleep(50)
if (!surface.isValid) {
Log.e(TAG, "surface never became valid")
return@thread
}
val dec = H264Decoder(surface)
var frames = 0L
var lastKeyReq = 0L
while (!stopDecoding) {
val f = try {
RustBridge.nextFrame(h)
} catch (e: Exception) {
Log.e(TAG, "nextFrame threw", e)
null
} ?: break
val fed = dec.queue(f)
if (!fed && !f.keyframe && System.currentTimeMillis() - lastKeyReq > 200) {
RustBridge.nativeRequestKeyframe(h) // 等 IDR 中:限频请求
lastKeyReq = System.currentTimeMillis()
}
frames++
if (frames % 120 == 0L) {
lastFrames = frames
lastRtt = RustBridge.rttMs(h)
runOnUiThread { refreshStatus() }
}
}
dec.release()
Log.i(TAG, "decode loop ended, frames=$frames")
runOnUiThread { refreshStatus() }
}
}
private fun disconnect() {
if (handle == 0L) return
stopDecoding = true
// 顺序纪律:等解码线程退出后才能 destroy(见 android-ffi 文件头)。
// 卡帧风险:agent 静止时 500ms 一帧、QUIC idle 超时后流必关,6s 足够覆盖。
decodeThread?.join(6000)
decodeThread = null
val h = handle
handle = 0L
remoteW = 0
remoteH = 0
lastFrames = 0
lastRtt = 0
RustBridge.destroy(h)
showConnectState("已断开")
}
override fun onDestroy() {
super.onDestroy()
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,121 @@
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
}
// 字符事件优先于扫描码:多数软键盘发的是 keyCode=KEYCODE_UNKNOWN +
// unicodeChar(adb `input text` 同款形态)。这一分支必须排在 WinScancode
// 查表**之前**——否则 UNKNOWN(0) 查不到表会被当「未映射」吞掉,字就丢了。
// 注意 `getUnicodeChar()` 返回的是单个 UTF-16 code unit(不是码点),
// 所以这条路只覆盖 BMP;增补平面字符由 `commitText` 成对送达。
// 只收**可打印**字符(空格起、排除 0x7F):控制值(Enter/Tab)必须走扫描码,
// 远端应用期待的是按键事件而非 WM_CHAR。见 MainActivity.onKeyDown 同款门槛。
val cp = event.unicodeChar
if (event.action == KeyEvent.ACTION_DOWN && cp >= 0x20 && cp != 0x7F) {
sendText(charArrayOf(cp.toChar()))
debug("keychar 1u")
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
}
}
@@ -0,0 +1,278 @@
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),把触摸手势映射为远端鼠标事件,
* 并作为软键盘的宿主(协议 v3 文本通道 + 扫描码控制键)。
*
* 为什么视图尺寸必须等于画面尺寸:触摸坐标按视图宽高归一化到远端 0..=65535。
* 若视图被拉伸(阶段 1 用 MATCH_PARENT),画面与视图不再一一对应,手指位置与
* 远端指针位置就会错位;letterbox 后黑边不属于本视图,触摸天然只在画面内产生事件。
*
* 手势映射(桌面远控的通行方案,参考 Moonlight / Scrcpy 的交互形态,
* 实现为本项目独立编写):
* - 单指轻点 = 左键单击
* - 单指按下并移动 = 左键拖拽(窗口拖动、文字选中)
* - 双指轻点 = 右键
* - 双指上下滑动 = 滚轮
*
* 刻意不做「手指即光标」的绝对定位:手指会完全挡住目标,桌面精细操作不可用。
*
* **IME 接线(阶段 2b)**:本视图是文本编辑器(`onCheckIsTextEditor`),
* 软键盘的提交经 `RemoteInputConnection` 上行。`isFocusableInTouchMode` 必须为
* true,否则触摸态拿不到焦点、系统不建立 InputConnection(阶段 2a 只有
* `isFocusable`,这就是当时软键盘弹不出来的原因之一)。
*/
class RemoteSurfaceView(context: Context) : SurfaceView(context) {
/** 由 Activity 实现,转发到 RustBridge */
interface Listener {
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
/** 视频宽高比(w/h)。>0 时视图按此比例测量。 */
var videoAspectRatio = 0f
set(value) {
if (value > 0f && field != value) {
field = value
requestLayout()
}
}
private enum class Mode { IDLE, TAP, DRAG, TWO_FINGER }
private var mode = Mode.IDLE
private var downX = 0f
private var downY = 0f
private var lastX = 0f
private var lastY = 0f
private var downTime = 0L
private var twoFingerStartY = 0f
private var twoFingerDownTime = 0L
private var twoFingerMoved = false
private var wheelAccum = 0f
/** 判定「拖拽 vs 点击」的位移阈值:12dp */
private val dragSlopPx = resources.displayMetrics.density * 12f
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) },
)
}
/**
* letterbox 测量:在父容器给出的可用空间内按视频宽高比取最大内接矩形。
*
* 关键:父容器给 WRAP_CONTENT 时 MeasureSpec 是 AT_MOST,getSize() 是
* **上限**而非实际可用尺寸。据此算出的尺寸必须自己 setMeasuredDimension,
* 否则父容器会把视图撑满(2026-10-30 实测:视图变成 1080x2001 而非
* 1080x607,触摸按 2001 归一化 → 坐标全错,点中心落到画面外)。
*/
override fun onMeasure(widthSpec: Int, heightSpec: Int) {
val ar = videoAspectRatio
if (ar <= 0f) {
super.onMeasure(widthSpec, heightSpec)
return
}
val availW = MeasureSpec.getSize(widthSpec)
val availH = MeasureSpec.getSize(heightSpec)
if (availW <= 0 || availH <= 0) {
super.onMeasure(widthSpec, heightSpec)
return
}
var w = availW
var h = (availW / ar).toInt()
if (h > availH) {
h = availH
w = (availH * ar).toInt()
}
setMeasuredDimension(w.coerceAtLeast(1), h.coerceAtLeast(1))
}
private fun normX(x: Float) = (x / width * 65535).toInt().coerceIn(0, 65535)
private fun normY(y: Float) = (y / height * 65535).toInt().coerceIn(0, 65535)
private fun moved(fromX: Float, fromY: Float, toX: Float, toY: Float) =
hypot((toX - fromX).toDouble(), (toY - fromY).toDouble()) > dragSlopPx
private fun midX(e: MotionEvent) =
if (e.pointerCount >= 2) (e.getX(0) + e.getX(1)) / 2f else e.x
private fun midY(e: MotionEvent) =
if (e.pointerCount >= 2) (e.getY(0) + e.getY(1)) / 2f else e.y
/** 诊断回调:把触摸情况显示到状态栏(本机 logcat 被系统限流,日志不可靠) */
var onDebug: ((String) -> Unit)? = null
override fun onTouchEvent(event: MotionEvent): Boolean {
val l = listener ?: run {
onDebug?.invoke("touch: listener=null")
return false
}
if (width == 0 || height == 0) return false
val act = when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> "DOWN"
MotionEvent.ACTION_MOVE -> "MOVE"
MotionEvent.ACTION_UP -> "UP"
MotionEvent.ACTION_POINTER_DOWN -> "P2DOWN"
MotionEvent.ACTION_POINTER_UP -> "P2UP"
MotionEvent.ACTION_CANCEL -> "CANCEL"
else -> event.actionMasked.toString()
}
onDebug?.invoke(
"$act view=(${event.x.toInt()},${event.y.toInt()}) " +
"norm=(${normX(event.x)},${normY(event.y)}) ${width}x$height"
)
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
downX = event.x; downY = event.y
lastX = event.x; lastY = event.y
downTime = System.currentTimeMillis()
mode = Mode.TAP
// 先把远端指针移到位,点击/拖拽都以此为基准
l.sendMouseMove(normX(event.x), normY(event.y))
}
MotionEvent.ACTION_POINTER_DOWN -> {
// 第二指落下 = 双指手势开始。若已进入拖拽(远端鼠标按着),
// 必须先抬起,否则远端会留下一个被按住未释放的左键。
if (mode == Mode.DRAG) l.sendMouseButton(BTN_LEFT, false)
mode = Mode.TWO_FINGER
twoFingerMoved = false
wheelAccum = 0f
twoFingerStartY = midY(event)
lastX = midX(event); lastY = midY(event)
twoFingerDownTime = System.currentTimeMillis()
}
MotionEvent.ACTION_MOVE -> when (mode) {
Mode.TAP -> if (moved(downX, downY, event.x, event.y)) {
mode = Mode.DRAG
l.sendMouseButton(BTN_LEFT, true)
lastX = event.x; lastY = event.y
l.sendMouseMove(normX(event.x), normY(event.y))
}
Mode.DRAG -> {
lastX = event.x; lastY = event.y
l.sendMouseMove(normX(event.x), normY(event.y))
}
Mode.TWO_FINGER -> {
val cx = midX(event); val cy = midY(event)
if (abs(cy - twoFingerStartY) > dragSlopPx || moved(lastX, lastY, cx, cy)) {
twoFingerMoved = true
}
// 手指下移(cy 增大)= 内容向上翻 = Windows 滚轮向下 → dy 取负
wheelAccum += (cy - lastY)
val notches = (wheelAccum / WHEEL_PX_PER_NOTCH).toInt()
if (notches != 0) {
l.sendWheel(-notches * WHEEL_DELTA)
wheelAccum -= notches * WHEEL_PX_PER_NOTCH
}
lastX = cx; lastY = cy
}
Mode.IDLE -> {}
}
MotionEvent.ACTION_POINTER_UP -> {
// 双指之一抬起:先判定右键,再把剩余手指的移动视为已结束手势
if (mode == Mode.TWO_FINGER) {
maybeRightClick(l)
mode = Mode.IDLE
}
}
MotionEvent.ACTION_UP -> {
when (mode) {
Mode.TAP -> {
// 轻点 = 左键单击(位置已在 DOWN 时发送)
l.sendMouseButton(BTN_LEFT, true)
l.sendMouseButton(BTN_LEFT, false)
performClick()
}
Mode.DRAG -> l.sendMouseButton(BTN_LEFT, false)
Mode.TWO_FINGER -> maybeRightClick(l)
Mode.IDLE -> {}
}
mode = Mode.IDLE
}
MotionEvent.ACTION_CANCEL -> {
if (mode == Mode.DRAG) l.sendMouseButton(BTN_LEFT, false)
mode = Mode.IDLE
}
}
return true
}
/** 双指未移动且按住时间短 → 右键 */
private fun maybeRightClick(l: Listener) {
if (twoFingerMoved) return
if (System.currentTimeMillis() - twoFingerDownTime > TWO_FINGER_TAP_MAX_MS) return
l.sendMouseMove(normX(lastX), normY(lastY))
l.sendMouseButton(BTN_RIGHT, true)
l.sendMouseButton(BTN_RIGHT, false)
performClick()
}
@Suppress("ClickableViewAccessibility")
override fun performClick(): Boolean {
super.performClick()
return true
}
companion object {
const val BTN_LEFT = 0
const val BTN_RIGHT = 1
const val BTN_MIDDLE = 2
private const val WHEEL_DELTA = 120
private const val WHEEL_PX_PER_NOTCH = 60f
private const val TWO_FINGER_TAP_MAX_MS = 500L
}
}
@@ -0,0 +1,109 @@
package com.verify.remote
/**
* viewer-core(Rust)的 JNI 声明。符号约定:Java_com_verify_remote_RustBridge_*,
* 与 android-ffi/src/lib.rs 一一对应。
*
* 句柄纪律(Rust 侧文件头有完整说明):
* - nativeConnect 返回 >0 句柄;0 表示失败,错误消息取 [lastError]
* - destroy 后必须置 0;**destroy 前必须先停掉解码线程**(否则 use-after-free)
*/
object RustBridge {
private const val FRAME_HEADER_LEN = 25
init {
System.loadLibrary("androidffi")
}
private external fun nativeConnect(host: String, port: Int, code: String): Long
external fun nativeLastError(): String?
/** 返回 null = 会话结束;否则 [FRAME_HEADER_LEN] 字节头 + Annex-B 载荷 */
external fun nativeNextFrame(handle: Long): ByteArray?
external fun nativeStats(handle: Long): Long
external fun nativeDesktopSize(handle: Long): Long
external fun nativeSendMouseMove(handle: Long, x: Int, y: 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)
/**
* 协议 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)
/** 连接。失败抛 IllegalStateException(带 Rust 侧错误消息)。 */
fun connect(host: String, port: Int, code: String): Long {
val h = nativeConnect(host, port, code)
if (h == 0L) {
throw IllegalStateException(nativeLastError() ?: "connect failed")
}
return h
}
fun destroy(handle: Long) {
if (handle != 0L) nativeDestroy(handle)
}
/** 解出的帧(头字段已拆,payload 可直接喂 MediaCodec) */
data class Frame(
val keyframe: Boolean,
val seq: Long,
val tsMs: Long,
val width: Int,
val height: Int,
val payload: ByteArray,
)
/** 取帧 + 拆头。null = 会话结束。 */
fun nextFrame(handle: Long): Frame? {
val raw = nativeNextFrame(handle) ?: return null
if (raw.size < FRAME_HEADER_LEN) return null
return Frame(
keyframe = raw[0] != 0.toByte(),
seq = readLong(raw, 1),
tsMs = readLong(raw, 9),
width = readInt(raw, 17),
height = readInt(raw, 21),
payload = raw.copyOfRange(FRAME_HEADER_LEN, raw.size),
)
}
fun rttMs(handle: Long): Long {
val packed = nativeStats(handle)
return if (packed < 0) 0 else (packed ushr 32)
}
fun framesIn(handle: Long): Long {
val packed = nativeStats(handle)
return if (packed < 0) 0 else packed and 0xFFFF_FFFF
}
/**
* 被控端桌面尺寸 (w, h)。连接成功后、建解码器之前调用。
* 返回 (0,0) 表示句柄无效。
*/
fun desktopSize(handle: Long): Pair<Int, Int> {
val packed = nativeDesktopSize(handle)
if (packed == 0L) return 0 to 0
val w = ((packed ushr 32) and 0xFFFF_FFFF).toInt()
val h = (packed and 0xFFFF_FFFF).toInt()
return w to h
}
private fun readLong(b: ByteArray, off: Int): Long {
var v = 0L
for (i in 0 until 8) v = v or ((b[off + i].toLong() and 0xFF) shl (8 * i))
return v
}
private fun readInt(b: ByteArray, off: Int): Int {
return (b[off].toInt() and 0xFF) or
((b[off + 1].toInt() and 0xFF) shl 8) or
((b[off + 2].toInt() and 0xFF) shl 16) or
((b[off + 3].toInt() and 0xFF) shl 24)
}
}
@@ -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
}
}
+85
View File
@@ -0,0 +1,85 @@
# Phase 1 build chain: Rust .so -> jniLibs -> Gradle debug APK -> install -> launch
#
# Keep this file ASCII-only (PS 5.1 reads BOM-less .ps1 as GBK; see lessons).
#
# Usage (from apps/android-viewer/):
# powershell -ExecutionPolicy Bypass -File build-android.ps1 [-Host_ 192.168.1.5] [-Port 47800] [-Code t] [-SkipRust]
# (param is Host_ because $Host is a PowerShell automatic variable)
#
# Prereqs (Windows dev box):
# - Rust + Android targets + cargo-ndk + NDK (see personal docs/baseline.md toolchain table)
# - JDK 17 + Gradle 8.14 (paths in $vars below, adjust to your machine)
# - local.properties with sdk.dir (gitignored; template: sdk.dir=<android sdk path>)
param(
[string]$Host_ = "192.168.1.5",
[int]$Port = 47800,
[string]$Code = "t",
[switch]$SkipRust
)
$ErrorActionPreference = "Stop"
# repo root = two levels up from this script (apps/android-viewer -> repo)
$proj = $PSScriptRoot
$repo = (Resolve-Path "$proj\..\..").Path
$env_ = "D:\working\environment"
$gradle = "D:\working\android\gradle\gradle-8.14.3\bin\gradle.bat"
$adb = "D:\working\android\sdk\platform-tools\adb.exe"
$env:RUSTUP_HOME = "$env_\rust"
$env:CARGO_HOME = "$env_\cargo"
$env:PATH = "$env_\mingw64\bin;$env_\cargo\bin;$env:PATH"
$env:ANDROID_HOME = "D:\working\android\sdk"
$env:ANDROID_NDK_HOME = "D:\working\android\sdk\ndk\30.0.16248370"
$env:GRADLE_USER_HOME = "D:\working\android\gradle-home"
$env:JAVA_HOME = "D:\working\java\jdk17"
if (-not (Test-Path "$proj\local.properties")) {
Set-Content "$proj\local.properties" 'sdk.dir=D\:\\working\\android\\sdk' -Encoding ascii
Write-Host "generated local.properties (sdk.dir)"
}
if (-not $SkipRust) {
Write-Host "=== 1/4 cargo ndk: android-ffi (arm64 release) ===" -ForegroundColor Cyan
Push-Location $repo
# cargo writes progress to stderr; PS 5.1 + ErrorActionPreference=Stop turns
# every stderr line into a terminating error. Relax for native calls and
# judge by exit code (same for gradle below).
$ErrorActionPreference = "Continue"
cargo ndk -t arm64-v8a build -p android-ffi --release 2>&1 |
Select-String "error|Finished|Building"
if ($LASTEXITCODE -ne 0) { throw "cargo ndk build failed (exit $LASTEXITCODE)" }
$ErrorActionPreference = "Stop"
Pop-Location
}
Write-Host "=== 2/4 copy .so into app/libs ===" -ForegroundColor Cyan
$so = "$repo\target\aarch64-linux-android\release\libandroidffi.so"
if (-not (Test-Path $so)) { throw "missing $so (run without -SkipRust)" }
New-Item -ItemType Directory -Force -Path "$proj\app\libs\arm64-v8a" | Out-Null
Copy-Item $so "$proj\app\libs\arm64-v8a\libandroidffi.so" -Force
Write-Host (" libandroidffi.so: {0:N0} KB" -f ((Get-Item $so).Length/1KB))
Write-Host "=== 3/4 gradle assembleDebug ===" -ForegroundColor Cyan
Push-Location $proj
$ErrorActionPreference = "Continue"
& $gradle :app:assembleDebug --console=plain 2>&1 | Select-Object -Last 25
$code = $LASTEXITCODE
$ErrorActionPreference = "Stop"
Pop-Location
if ($code -ne 0) { throw "gradle build failed (exit $code)" }
$apk = "$proj\app\build\outputs\apk\debug\app-debug.apk"
if (-not (Test-Path $apk)) { throw "apk not found: $apk" }
Write-Host (" APK: {0:N0} KB" -f ((Get-Item $apk).Length/1KB))
Write-Host "=== 4/4 install + launch ===" -ForegroundColor Cyan
$ErrorActionPreference = "Continue"
& $adb install -r $apk
if ($LASTEXITCODE -ne 0) { throw "install failed (phone must allow USB install)" }
& $adb logcat -c | Out-Null
& $adb shell am start -n com.verify.remote/.MainActivity `
--es host $Host_ --ei port $Port --es code $Code | Out-Null
$ErrorActionPreference = "Stop"
Write-Host "launched. watch: adb logcat -s MainActivity:H264Decoder RustBridge:* viewer-core:* AndroidRuntime:E"
Write-Host "after 10s capture screen: adb exec-out screencap -p > screen.png"
+4
View File
@@ -0,0 +1,4 @@
plugins {
id("com.android.application") version "8.9.2" apply false
id("org.jetbrains.kotlin.android") version "2.1.20" apply false
}
+4
View File
@@ -0,0 +1,4 @@
# 固定 JDK 17(不动全局 JAVA_HOME,遵守 2026-10-29 环境约束)
org.gradle.java.home=D\:\\working\\java\\jdk17
android.useAndroidX=false
org.gradle.jvmargs=-Xmx2g
+17
View File
@@ -0,0 +1,17 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "remote-viewer"
include(":app")
+127 -3
View File
@@ -16,7 +16,10 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt};
///
/// - v1:初版(无版本字段,隐式)
/// - v2:`Auth`/`AuthOk` 携带版本;帧头增加 `seq`/`keyframe`
pub const PROTO_VERSION: u16 = 2;
/// - v3:新增 `InputEvent::Text`(文本注入通道,UTF-16 code unit)。
/// `InputEvent` 因携带 `Vec<u16>` 不再是 `Copy`;三端必须同步升级,
/// 旧端由 `Auth` 握手显式拒绝。
pub const PROTO_VERSION: u16 = 3;
/// 控制通道消息(双向)
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -47,9 +50,12 @@ pub enum MouseButton {
Middle,
}
/// 输入事件。鼠标坐标为虚拟桌面归一化 0..=65535;
/// 输入事件。鼠标坐标为**被采集显示器**(主屏)归一化 0..=65535
/// (协议 v2 语义,v3 未改;`agent/src/input.rs` 的注入空间与此一致,见个人层 lessons R-4);
/// 按键为 Set-1 扫描码,extended 标记扩展键(方向键/Home 等,需要 EXTENDEDKEY 标志)。
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
///
/// v3 起本类型不再 `Copy`:`Text` 携带堆上 `Vec<u16>`。需要复制的地方用 `.clone()`。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum InputEvent {
MouseMove { x: u16, y: u16 },
MouseDown(MouseButton),
@@ -57,6 +63,42 @@ pub enum InputEvent {
Wheel { dy: i32 },
KeyDown { scancode: u16, extended: bool },
KeyUp { scancode: u16, extended: bool },
/// v3 新增:**文本注入通道**。`units` 为 UTF-16 code unit 序列。
///
/// 为什么不是 `String`:Windows 注入侧(`KEYEVENTF_UNICODE`)与 Java 字符串的
/// 原生表示都是 UTF-16 code unit;用 `String` 要走 UTF-8 往返,遇到 IME 提交边界
/// 产生的落单代理项会**有损**。`Vec<u16>` 忠实透传,由接收端按 Win32 语义处理。
///
/// 为什么整段携带、而不是逐 code unit 一条消息:一次 IME 候选确认常是多个汉字;
/// 代理对(emoji 等增补平面字符)**必须在同一次注入批次里成对提交**才能被目标
/// 应用合成成一个字符,拆开注入必现乱码。
///
/// 上限 [`MAX_TEXT_UNITS`]:超长输入截断且两侧打警告日志(不静默丢弃)。
/// 另一道防线是 [`read_control`] 的 1 MiB 消息长度闸。
///
/// 日志纪律:中间层与注入端只允许打印 `units.len()`,**不得**打印用户输入内容。
Text { units: Vec<u16> },
}
/// [`InputEvent::Text`] 单次携带的 code unit 上限。
///
/// 与 `read_control` 的 1 MiB 消息闸是两道独立防线:前者防恶意对端把接收端分配打爆,
/// 后者防合法但异常长的 IME 提交把注入队列刷爆。4096 unit ≈ 8 KiB,
/// 远超正常 IME 一次提交(数十 unit),又足够小使单次 `SendInput` 批量仍可接受。
pub const MAX_TEXT_UNITS: usize = 4096;
/// 把文本输入截断到 [`MAX_TEXT_UNITS`],返回 `(可注入的 units, 是否发生截断)`。
///
/// 调用方**必须**在截断为真时打印日志(可见后果);本函数只负责机械截断,
/// 不自己打印——因为 `common` 同时被主控端与被控端复用,日志口径由调用方决定。
pub fn clamp_text_units(units: Vec<u16>) -> (Vec<u16>, bool) {
if units.len() > MAX_TEXT_UNITS {
let mut trimmed = units;
trimmed.truncate(MAX_TEXT_UNITS);
(trimmed, true)
} else {
(units, false)
}
}
/// 视频帧头。载荷为 H.264 Annex-B 码流(含 00 00 00 01 起始码)。
@@ -269,6 +311,88 @@ mod tests {
}
}
// ---- v3 文本注入通道 ----
/// 版本守卫:v3 是本轮契约变更的登记点,改动必须显式经过这里。
#[test]
fn proto_version_is_three_after_text_event() {
assert_eq!(PROTO_VERSION, 3, "新增 InputEvent::Text 后必须递增到 3");
}
#[test]
fn text_event_roundtrips_utf16_units() {
// 「你好」+ 一个 emoji 的代理对(U+1F600 = D83D DE00)
let units: Vec<u16> = vec![0x4F60, 0x597D, 0xD83D, 0xDE00];
let m = ControlMsg::Input(InputEvent::Text {
units: units.clone(),
});
let bytes = encode_control(&m);
let back: ControlMsg = bincode::deserialize(&bytes[4..]).unwrap();
match back {
ControlMsg::Input(InputEvent::Text { units: got }) => assert_eq!(got, units),
other => panic!("期望 Input::Text,实际 {other:?}"),
}
}
/// 落单代理项必须原样往返:IME 提交边界可能给出孤立高/低代理项,
/// 用 `String` 承载会在这里有损(这正是选 `Vec<u16>` 的理由)。
#[test]
fn text_event_keeps_lone_surrogate() {
let units = vec![0xD83Du16];
let m = ControlMsg::Input(InputEvent::Text { units: units.clone() });
let bytes = encode_control(&m);
let back: ControlMsg = bincode::deserialize(&bytes[4..]).unwrap();
match back {
ControlMsg::Input(InputEvent::Text { units: got }) => assert_eq!(got, units),
other => panic!("期望 Input::Text,实际 {other:?}"),
}
}
#[test]
fn text_units_clamped_at_boundary() {
// 边界:恰好上限不截断;超一个才截断
let exact = vec![0x61u16; MAX_TEXT_UNITS];
let (kept, truncated) = clamp_text_units(exact);
assert_eq!(kept.len(), MAX_TEXT_UNITS);
assert!(!truncated);
let over = vec![0x61u16; MAX_TEXT_UNITS + 1];
let (kept, truncated) = clamp_text_units(over);
assert_eq!(kept.len(), MAX_TEXT_UNITS);
assert!(truncated, "超限必须返回截断标志,让调用方打印警告");
let (empty, truncated) = clamp_text_units(vec![]);
assert!(empty.is_empty());
assert!(!truncated);
}
/// 空 `Text` 也必须能往返(IME 可能提交空串;调用方负责决定忽略还是报错)。
#[test]
fn text_event_empty_roundtrips() {
let m = ControlMsg::Input(InputEvent::Text { units: vec![] });
let bytes = encode_control(&m);
let back: ControlMsg = bincode::deserialize(&bytes[4..]).unwrap();
match back {
ControlMsg::Input(InputEvent::Text { units }) => assert!(units.is_empty()),
other => panic!("期望 Input::Text,实际 {other:?}"),
}
}
/// 上限内的 `Text` 必须能通过 `read_control` 的长度闸;
/// 反向验证:伪造一个超限的声明长度必须在分配前被拒。
#[tokio::test]
async fn text_within_cap_passes_control_length_gate() {
let (mut a, mut b) = duplex(1 << 20);
let msg = ControlMsg::Input(InputEvent::Text {
units: vec![0x4F60; MAX_TEXT_UNITS],
});
write_control(&mut a, &msg).await.unwrap();
match read_control(&mut b).await.unwrap() {
ControlMsg::Input(InputEvent::Text { units }) => assert_eq!(units.len(), MAX_TEXT_UNITS),
other => panic!("期望 Input::Text,实际 {other:?}"),
}
}
// ---- 视频帧头 ----
#[test]
+143
View File
@@ -0,0 +1,143 @@
# 2b evidence capture: byte-safe device screenshots of each acceptance state.
# ASCII-only source. Screenshots go through tools\shot.ps1 because PS 5.1 `>`
# writes UTF-16 and corrupts PNG bytes (see that file's header).
# Text/state assertions use uiautomator (lessons R-6: vivo logcat is throttled).
$ErrorActionPreference = "Stop"
$repo = (Resolve-Path "$PSScriptRoot\..").Path
$ev = "D:\working\projects\yuankong\evidence\2b"
$adb = "D:\working\android\sdk\platform-tools\adb.exe"
$phoneIp = "192.168.1.180"
function Adb-Shell {
param([Parameter(Mandatory)][string[]]$Cmd)
$old = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
$out = & $adb shell @Cmd 2>$null
return @{ out = ($out -join "`n"); code = $LASTEXITCODE }
}
finally { $ErrorActionPreference = $old }
}
function Get-Ui {
[void](Adb-Shell @("uiautomator", "dump", "/sdcard/ui.xml"))
$old = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try { & $adb pull /sdcard/ui.xml "$env:TEMP\cap-ui.xml" 2>$null | Out-Null }
finally { $ErrorActionPreference = $old }
if (-not (Test-Path "$env:TEMP\cap-ui.xml")) { return "" }
return [System.IO.File]::ReadAllText("$env:TEMP\cap-ui.xml", [System.Text.Encoding]::UTF8)
}
function Wait-Live([int]$Seconds) {
$deadline = (Get-Date).AddSeconds($Seconds)
while ((Get-Date) -lt $deadline) {
Start-Sleep -Milliseconds 900
$x = Get-Ui
if ($x -match "RTT=") { return $x }
}
return ""
}
function Shot([string]$name) {
$out = Join-Path $ev $name
& powershell -NoProfile -ExecutionPolicy Bypass -File "$repo\tools\shot.ps1" -Out $out
}
New-Item -ItemType Directory -Force -Path $ev | Out-Null
# drop the earlier broken captures (PS 5.1 '>' wrote them as UTF-16)
Get-ChildItem $ev -Filter "*.png" | ForEach-Object {
$b = [System.IO.File]::ReadAllBytes($_.FullName)
if ($b.Length -lt 4 -or ($b[0] -ne 0x89)) { Remove-Item $_.FullName -Force }
}
Write-Host "=== ensure fresh v3 agent ==="
$agentExe = "$repo\target\release\agent.exe"
$ag = Get-Process agent -ErrorAction SilentlyContinue
if ($ag -and (Get-Item $agentExe).LastWriteTime -gt $ag.StartTime) {
Stop-Process -Name agent -Force -ErrorAction SilentlyContinue
Start-Sleep -Milliseconds 800; $ag = $null
}
if (-not $ag) {
Start-Process $agentExe -ArgumentList "--port","47800","--code","t" -WindowStyle Hidden
Start-Sleep -Seconds 1
}
Write-Host "=== 1. connect form (V4 entry state) ==="
[void](Adb-Shell @("am","force-stop","com.verify.remote"))
[void](Adb-Shell @("am","start","-n","com.verify.remote/.MainActivity"))
Start-Sleep -Seconds 3
$ui = Get-Ui
if ($ui -notmatch "android\.widget\.EditText") { throw "form fields missing" }
Shot "4b-form.png"
Write-Host "=== 2. failure path (V5): unreachable host via intent ==="
[void](Adb-Shell @("am","force-stop","com.verify.remote"))
[void](Adb-Shell @("am","start","-n","com.verify.remote/.MainActivity",
"--es","host","192.168.1.199","--ei","port","47800","--es","code","t"))
# Measured 2026-10-30: an unreachable host returns to the form with a concrete
# reason after ~32s (quinn's default connect timeout). Budget 50s, not 30s -
# the earlier 30s budget made this step look like a failure when it was only
# still inside the timeout. UX follow-up (logged in the result record): during
# those 30s the panel says only "connecting" with no cancel affordance.
$deadline = (Get-Date).AddSeconds(50)
$err = ""
while ((Get-Date) -lt $deadline) {
Start-Sleep -Milliseconds 1000
$ui = Get-Ui
if ($ui -match "android\.widget\.EditText") {
$shown = [regex]::Matches($ui, 'text="([^"]+)"') | ForEach-Object { $_.Groups[1].Value }
$err = $shown -join " | "
break
}
}
if ($err) { Shot "4b-v5-error.png" } else { throw "V5: never returned to form with an error" }
Write-Host " error state visible: yes"
Write-Host "=== 3. live session (V4 via intent) ==="
[void](Adb-Shell @("am","force-stop","com.verify.remote"))
[void](Adb-Shell @("am","start","-n","com.verify.remote/.MainActivity",
"--es","host",$phoneIp,"--ei","port","47800","--es","code","t"))
$ui = Wait-Live 25
if (-not $ui) { throw "session never became live" }
Shot "4b-connected-stream.png"
Write-Host " $([regex]::Match($ui,'text="([^"]*RTT=[^"]*)"').Groups[1].Value)"
Write-Host "=== 4. keyboard shown (real IME bound to our InputConnection) ==="
$btns = [regex]::Matches($ui, '<node[^>]*class="android\.widget\.Button"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"')
if ($btns.Count -lt 1) { throw "no keyboard button" }
$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"))
Start-Sleep -Seconds 3
$ime = (Adb-Shell @("dumpsys","input_method")).out
if ($ime -notmatch "mInputShown=true") { throw "IME not shown" }
if ($ime -notmatch "mHaveConnection=true") { throw "no InputConnection bound" }
Shot "4b-ime-shown.png"
Write-Host "=== 5. IME commit reaches the pipeline (diagnostic shows commit) ==="
[void](Adb-Shell @("input","text","hello"))
Start-Sleep -Milliseconds 1200
[void](Adb-Shell @("input","keyevent","62")) # SPACE: accept any pending candidate
Start-Sleep -Seconds 2
$ui = Get-Ui
$diag = [regex]::Match($ui, 'text="([^"]*RTT=[^"]*)"').Groups[1].Value
Write-Host " phone status: $diag"
Shot "4b-ime-committed.png"
Write-Host "=== 6. disconnect (back key) ==="
[void](Adb-Shell @("input","keyevent","4")) # dismiss keyboard
Start-Sleep -Milliseconds 800
[void](Adb-Shell @("input","keyevent","4")) # disconnect
Start-Sleep -Seconds 2
$ui = Get-Ui
if ($ui -match "RTT=") { throw "still connected after two BACK presses" }
Shot "4b-disconnected.png"
Write-Host "=== CAPTURE COMPLETE ==="
Get-ChildItem $ev -Filter "4b-*.png" | ForEach-Object {
$b = [System.IO.File]::ReadAllBytes($_.FullName)
$sig = ($b[0..3] | ForEach-Object { $_.ToString("X2") }) -join " "
"{0,-28} {1,9} {2}" -f $_.Name, $b.Length, $sig
}
+182
View File
@@ -0,0 +1,182 @@
# 2b V6/V7 on-device IME path: does a real IME's commitText reach the remote
# desktop? ASCII-only source (see personal lessons on PS 5.1 + BOM-less .ps1).
#
# Difference from phone_chain_proof.ps1: that one drives Activity.onKeyDown with
# adb keyevents (IME hidden). Here the IME is SHOWN, so keys are consumed by the
# input method and reach us only through RemoteInputConnection.commitText /
# sendKeyEvent. That is the path Chinese candidates take, and it is the risk R1
# item in the plan (vivo ROM + third-party IME behaviour was never observed).
#
# What this proves mechanically: IME is bound to our InputConnection and its
# committed text lands in a Windows window we own, via protocol v3.
# What it cannot prove: human candidate-list selection (needs a finger); see
# the result record - V6/V7 full judgement is listed as manual acceptance.
#
# Usage:
# powershell -ExecutionPolicy Bypass -File tools\ime_commit_proof.ps1
param(
[string]$PhoneIp = "192.168.1.180",
[int]$Port = 47800,
[string]$Code = "t",
[string]$Text = "hi"
)
$ErrorActionPreference = "Stop"
$repo = (Resolve-Path "$PSScriptRoot\..").Path
$adb = "D:\working\android\sdk\platform-tools\adb.exe"
$tmp = "$env:TEMP\remote-ime-proof"
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
Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
public static class FgApi2 {
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
}
"@
function Adb {
param([Parameter(Mandatory)][string[]]$AdbArgs)
$old = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
$out = & $adb @AdbArgs 2>$null
return @{ out = ($out -join "`n"); code = $LASTEXITCODE }
}
finally { $ErrorActionPreference = $old }
}
function Adb-Shell { param([Parameter(Mandatory)][string[]]$Cmd) return Adb -AdbArgs (@("shell") + $Cmd) }
function Wait-File {
param([string]$Path, [int]$TimeoutMs = 20000)
$t = 0
while (-not (Test-Path $Path) -and $t -lt ($TimeoutMs / 150)) { Start-Sleep -Milliseconds 150; $t++ }
if (-not (Test-Path $Path)) { throw "timeout waiting for $Path" }
}
function Assert-Foreground([IntPtr]$hwnd, [string]$where) {
$deadline = (Get-Date).AddMilliseconds(2000)
while ((Get-Date) -lt $deadline) {
if ([FgApi2]::GetForegroundWindow() -eq $hwnd) { return }
Start-Sleep -Milliseconds 30
}
throw "$where : foreground=$([FgApi2]::GetForegroundWindow()) want=$($hwnd.ToInt64()) - refusing to inject"
}
function Get-PhoneUiText {
[void](Adb-Shell @("uiautomator", "dump", "/sdcard/ui.xml"))
$r = Adb -AdbArgs @("pull", "/sdcard/ui.xml", "$tmp\ui.xml")
if ($r.code -ne 0 -or -not (Test-Path "$tmp\ui.xml")) { return "" }
return [System.IO.File]::ReadAllText("$tmp\ui.xml", [System.Text.Encoding]::UTF8)
}
function Read-Received {
if (-not (Test-Path $resultFile)) { return "" }
$b = [System.IO.File]::ReadAllBytes($resultFile)
$s = [System.Text.Encoding]::UTF8.GetString($b)
if ($s.Length -gt 0 -and $s[0] -eq [char]0xFEFF) { $s = $s.Substring(1) }
return $s
}
Write-Host "=== 0. retire earlier targets (touch only existing stop flags) ==="
$flags = @(Get-ChildItem -Path $env:TEMP -Recurse -Filter "stop.flag" -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -like "*remote*proof*" })
if ($flags.Count -gt 0) {
Write-Host " retiring $($flags.Count)"
foreach ($f in $flags) { (Get-Item $f.FullName).LastWriteTime = Get-Date }
Start-Sleep -Seconds 3
}
Write-Host "=== 1. fresh target window ==="
Start-Process powershell.exe -ArgumentList "-NoProfile","-ExecutionPolicy","Bypass",`
"-File","$repo\tools\text_target.ps1","-PidFile",$pidFile,`
"-ResultFile",$resultFile,"-StopFile",$stopFile,"-GrabForeground"
Wait-File $pidFile
$parts = @((Get-Content $pidFile -Raw) -split "`n")
$tid = [int]$parts[0].Trim()
$hwnd = [IntPtr][long]$parts[1].Trim()
Assert-Foreground $hwnd "after launch"
Write-Host " target pid=$tid hwnd=$($hwnd.ToInt64())"
Write-Host "=== 2. agent freshness + phone session ==="
$agentExe = "$repo\target\release\agent.exe"
$ag = Get-Process agent -ErrorAction SilentlyContinue
if ($ag -and (Get-Item $agentExe).LastWriteTime -gt $ag.StartTime) {
Stop-Process -Name agent -Force -ErrorAction SilentlyContinue
Start-Sleep -Milliseconds 800; $ag = $null
}
if (-not $ag) {
Start-Process $agentExe -ArgumentList "--port",$Port,"--code",$Code -WindowStyle Hidden
Start-Sleep -Seconds 1
}
[void](Adb-Shell @("am", "force-stop", "com.verify.remote"))
[void](Adb-Shell @("am", "start", "-n", "com.verify.remote/.MainActivity",
"--es", "host", $PhoneIp, "--ei", "port", "$Port", "--es", "code", $Code))
$deadline = (Get-Date).AddSeconds(25)
$xml = ""
while ((Get-Date) -lt $deadline) { Start-Sleep -Milliseconds 900; $xml = Get-PhoneUiText; if ($xml -match "RTT=") { break } }
if ($xml -notmatch "RTT=") { throw "phone did not connect" }
Write-Host " $([regex]::Match($xml,'text="([^"]*RTT=[^"]*)"').Groups[1].Value)"
Write-Host "=== 3. show the soft keyboard (tap the app's keyboard button) ==="
# Button coordinates come from the live hierarchy. Our remote-state bar adds
# "keyboard" before "disconnect", so index 0 is the keyboard button - asserted
# rather than assumed by checking the count and printing what we tapped.
$btns = [regex]::Matches($xml, '<node[^>]*class="android\.widget\.Button"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"')
if ($btns.Count -lt 1) { throw "no buttons found in remote state" }
$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)
Write-Host " tapping first button ($kx,$ky)"
[void](Adb-Shell @("input", "tap", "$kx", "$ky"))
Start-Sleep -Seconds 3
$ime = (Adb-Shell @("dumpsys", "input_method")).out
$shown = ($ime -match "mInputShown=true")
$bound = ($ime -match "mHaveConnection=true")
$curId = [regex]::Match($ime, "mCurId=(\S+)").Groups[1].Value
Write-Host " IME: shown=$shown boundToUs=$bound cur=$curId"
if (-not $shown) { throw "soft keyboard did not appear" }
if (-not $bound) { throw "no InputConnection bound - our onCheckIsTextEditor/onCreateInputConnection path failed" }
Write-Host "=== 4. type through the IME, expect commitText -> v3 Text -> Windows ==="
$before = Read-Received
Assert-Foreground $hwnd "before IME text"
[void](Adb-Shell @("input", "text", $Text))
Start-Sleep -Milliseconds 1500
# Latin input in most Chinese IMEs commits without a candidate pick; if the IME
# instead left it composing, nudge with SPACE to force a commit.
$got = Read-Received
if ($got -notmatch [regex]::Escape($Text)) {
Write-Host " not committed yet (got [$got]); sending SPACE to accept candidate"
[void](Adb-Shell @("input", "keyevent", "62")) # KEYCODE_SPACE
Start-Sleep -Milliseconds 1500
$got = Read-Received
}
Write-Host " target received: [$got] (expected to contain [$Text])"
$diag = [regex]::Match((Get-PhoneUiText), 'text="([^"]*RTT=[^"]*)"').Groups[1].Value
Write-Host " phone status: $diag"
if ($got -notmatch [regex]::Escape($Text)) {
throw "IME COMMIT FAIL: nothing from the IME reached the Windows target"
}
Write-Host " PASS: real IME -> RemoteInputConnection -> v3 Text -> Windows window"
Write-Host "=== 5. back key must close keyboard first, not the session ==="
[void](Adb-Shell @("input", "keyevent", "4"))
Start-Sleep -Seconds 2
$ime2 = (Adb-Shell @("dumpsys", "input_method")).out
$xml2 = Get-PhoneUiText
$stillLive = ($xml2 -match "RTT=")
$kbHidden = ($ime2 -match "mInputShown=false")
Write-Host " after BACK: session live=$stillLive keyboard hidden=$kbHidden"
if (-not ($kbHidden -and $stillLive)) { throw "BACK did not behave as keyboard-first-then-session" }
Write-Host " PASS: first BACK dismissed the keyboard, session kept"
Write-Host "=== 6. cleanup ==="
New-Item -ItemType File -Force -Path $stopFile | Out-Null
Start-Sleep -Milliseconds 500
Write-Host "=== IME COMMIT PROOF COMPLETE ==="
+259
View File
@@ -0,0 +1,259 @@
# 2b full-chain proof (phone side): Android key -> v3 Text channel -> agent
# SendInput -> dedicated Windows target window, read back from that window.
# ASCII-only source (PS 5.1 + BOM-less = GBK corruption; see personal lessons).
# State checks use ASCII markers ("RTT=" / EditText presence) rather than
# matching the app's Chinese labels, for the same reason.
#
# Why ONE atomic script: SendInput lands on the FOREGROUND window, and Windows'
# foreground lock means a background process cannot simply reclaim it. This
# box's interactive GUI steals foreground between separate tool calls, so
# launch -> assert foreground -> drive phone -> read back must run without
# yielding. Every injection is preceded by a foreground assertion: if the target
# lost foreground we ABORT rather than inject into some other application.
#
# Prereqs: vivo device online via adb, app installed, v3 agent build present.
# Usage:
# powershell -ExecutionPolicy Bypass -File tools\phone_chain_proof.ps1
param(
[string]$PhoneIp = "192.168.1.180",
[int]$Port = 47800,
[string]$Code = "t"
)
$ErrorActionPreference = "Stop"
$repo = (Resolve-Path "$PSScriptRoot\..").Path
$adb = "D:\working\android\sdk\platform-tools\adb.exe"
$tmp = "$env:TEMP\remote-phone-proof"
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
Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
public static class FgApi {
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
}
"@
function Adb {
param([Parameter(Mandatory)][string[]]$AdbArgs)
# adb writes progress ("1 file pulled...") to stderr; PS 5.1 with
# $ErrorActionPreference=Stop turns every stderr line into a terminating
# error. Same workaround build-android.ps1 documents: relax for native
# calls, judge by exit code and captured stdout.
$old = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
$out = & $adb @AdbArgs 2>$null
return @{ out = ($out -join "`n"); code = $LASTEXITCODE }
}
finally { $ErrorActionPreference = $old }
}
function Adb-Shell {
param([Parameter(Mandatory)][string[]]$Cmd)
return Adb -AdbArgs (@("shell") + $Cmd)
}
function Wait-File {
param([string]$Path, [int]$TimeoutMs = 20000)
$t = 0
while (-not (Test-Path $Path) -and $t -lt ($TimeoutMs / 150)) { Start-Sleep -Milliseconds 150; $t++ }
if (-not (Test-Path $Path)) { throw "timeout waiting for $Path" }
}
function Assert-Foreground([IntPtr]$hwnd, [string]$where) {
# Bounded wait, not a single instant check: the target re-asserts itself
# every 60ms in proof mode, so a momentary steal by the interactive desktop
# is recoverable. Still refuse to inject if it never comes back - a stray
# injection lands in whatever window DOES hold foreground.
$deadline = (Get-Date).AddMilliseconds(1500)
while ((Get-Date) -lt $deadline) {
if ([FgApi]::GetForegroundWindow() -eq $hwnd) { return }
Start-Sleep -Milliseconds 30
}
throw "$where : foreground=$([FgApi]::GetForegroundWindow()) want=$hwnd after 1.5s - refusing to inject"
}
function Read-Received {
Wait-File $resultFile 8000
$b = [System.IO.File]::ReadAllBytes($resultFile)
$s = [System.Text.Encoding]::UTF8.GetString($b)
if ($s.Length -gt 0 -and $s[0] -eq [char]0xFEFF) { $s = $s.Substring(1) }
return $s
}
function Get-PhoneUiText {
[void](Adb-Shell @("uiautomator", "dump", "/sdcard/ui.xml"))
$r = Adb -AdbArgs @("pull", "/sdcard/ui.xml", "$tmp\ui.xml")
if ($r.code -ne 0 -or -not (Test-Path "$tmp\ui.xml")) { return "" }
return [System.IO.File]::ReadAllText("$tmp\ui.xml", [System.Text.Encoding]::UTF8)
}
function Wait-For-LiveSession([int]$Seconds, [ref]$outXml) {
$deadline = (Get-Date).AddSeconds($Seconds)
while ((Get-Date) -lt $deadline) {
Start-Sleep -Milliseconds 900
$x = Get-PhoneUiText
if ($x -match "RTT=") { $outXml.Value = $x; return $true }
}
$outXml.Value = (Get-PhoneUiText)
return $false
}
Write-Host "=== 0. stale-agent guard (same rule as the desktop proof) ==="
$agentExe = "$repo\target\release\agent.exe"
$ag = Get-Process agent -ErrorAction SilentlyContinue
if ($ag -and (Get-Item $agentExe).LastWriteTime -gt $ag.StartTime) {
Write-Host " running agent predates current build; restarting it"
Stop-Process -Name agent -Force -ErrorAction SilentlyContinue
Start-Sleep -Milliseconds 800
$ag = $null
}
if (-not $ag) {
Start-Process $agentExe -ArgumentList "--port",$Port,"--code",$Code -WindowStyle Hidden
Start-Sleep -Seconds 1
Write-Host " agent started (v3)"
}
Write-Host "=== 1. launch target window (own process, empty field) ==="
# Retire leftovers from earlier runs WITHOUT killing them. Killing a foreground
# window hands foreground to the next app (this box's interactive GUI), and the
# foreground lock then refuses a brand-new background process any claim - the
# new target could never take foreground back.
# Signal only stop files that ALREADY EXIST: creating a fresh one here would
# hit the stop path of the run we are about to start, and that target would
# close itself the moment it launched (seen 2026-10-30: pid reported then gone).
$existingFlags = @(Get-ChildItem -Path $env:TEMP -Recurse -Filter "stop.flag" -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -like "*remote*proof*" })
if ($existingFlags.Count -gt 0) {
Write-Host " retiring $($existingFlags.Count) earlier target(s) via their own stop file"
foreach ($f in $existingFlags) { (Get-Item $f.FullName).LastWriteTime = Get-Date }
Start-Sleep -Seconds 3
}
Start-Process powershell.exe -ArgumentList "-NoProfile","-ExecutionPolicy","Bypass",`
"-File","$repo\tools\text_target.ps1","-PidFile",$pidFile,`
"-ResultFile",$resultFile,"-StopFile",$stopFile,"-GrabForeground"
Wait-File $pidFile
# pid file carries "pid<n><hwnd>": the target writes its own handle once the
# form is realized, which removes the Get-Process MainWindowHandle race.
$parts = @((Get-Content $pidFile -Raw) -split "`n")
$tid = [int]$parts[0].Trim()
$hwnd = [IntPtr][long]$parts[1].Trim()
if ($hwnd -eq 0) { throw "target reported a null window handle" }
Assert-Foreground $hwnd "after launch"
Write-Host " target pid=$tid hwnd=$($hwnd.ToInt64()) foreground=yes"
Write-Host "=== 2. start app, connect via intent extras ==="
[void](Adb-Shell @("am", "force-stop", "com.verify.remote"))
[void](Adb-Shell @("am", "start", "-n", "com.verify.remote/.MainActivity",
"--es", "host", $PhoneIp, "--ei", "port", "$Port", "--es", "code", $Code))
$xmlRef = ""
if (-not (Wait-For-LiveSession 25 ([ref]$xmlRef))) { throw "phone never reported a live session (no RTT marker)" }
$script:xml = $xmlRef
Write-Host " phone: $([regex]::Match($script:xml,'text="([^"]*RTT=[^"]*)"').Groups[1].Value)"
Assert-Foreground $hwnd "after connect"
Write-Host "=== 3. phone keys A B C -> Activity.onKeyDown -> v3 Text channel ==="
# 29/30/31 = KEYCODE_A/B/C. adb-injected keys arrive with a printable
# unicodeChar, so the app routes them to nativeSendText (IME hidden). That is
# the product path introduced in 2b, not a stand-in for it.
foreach ($k in 29,30,31) {
Assert-Foreground $hwnd "before keyevent $k"
[void](Adb-Shell @("input", "keyevent", "$k"))
Start-Sleep -Milliseconds 250
}
$deadline = (Get-Date).AddSeconds(10)
$got = ""
while ((Get-Date) -lt $deadline) {
$got = Read-Received
if ($got -eq "abc") { break }
Start-Sleep -Milliseconds 300
}
Write-Host " target received: [$got]"
if ($got -ne "abc") {
Write-Host " foreground now: $([FgApi]::GetForegroundWindow()) target: $hwnd"
throw "FULL-CHAIN FAIL: expected [abc] got [$got]"
}
Write-Host " PASS: Android key -> v3 Text -> agent KEYEVENTF_UNICODE -> Windows window"
Write-Host "=== 4. phone ENTER (66): control key uses the scancode channel ==="
# Enter carries no printable unicodeChar, so it must go down the WinScancode
# path. Observable acceptance: session survives and keeps streaming (a
# single-line TextBox swallows Enter, so glyph-level proof is not possible).
Assert-Foreground $hwnd "before ENTER"
[void](Adb-Shell @("input", "keyevent", "66"))
Start-Sleep -Milliseconds 900
$xmlRef = ""
if (-not (Wait-For-LiveSession 6 ([ref]$xmlRef))) { throw "session died after ENTER" }
Write-Host " session alive: $([regex]::Match($xmlRef,'text="([^"]*RTT=[^"]*)"').Groups[1].Value)"
Write-Host "=== 5. phone BACK (4): disconnect and return to form ==="
[void](Adb-Shell @("input", "keyevent", "4"))
Start-Sleep -Seconds 2
$xml2 = Get-PhoneUiText
if ($xml2 -match "RTT=") { throw "BACK did not disconnect (still live session)" }
if ($xml2 -notmatch "android\.widget\.EditText") { throw "BACK did not return to the form" }
Write-Host " PASS: disconnected, form visible again"
Write-Host "=== 6. connect again FROM THE FORM (V4 through real UI, not intent) ==="
# Form typing uses keyevent, not 'adb input text': with the Chinese IME active,
# text injection gets composed (ASCII '.' becomes U+3002). We disable the IME,
# restore it in finally, and never assume a Chinese IME is absent.
$origIme = (Adb-Shell @("settings", "get", "secure", "default_input_method")).out.Trim()
Write-Host " original IME: [$origIme]"
if ($origIme -and $origIme -ne "null") { [void](Adb-Shell @("ime", "disable", $origIme)) }
try {
$xml3 = Get-PhoneUiText
$fields = [regex]::Matches($xml3, '<node[^>]*class="android\.widget\.EditText"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"')
if ($fields.Count -lt 3) { throw "expected 3 form fields, found $($fields.Count)" }
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
return @(([int](($a + $c) / 2)), ([int](($b + $d) / 2)))
}
$ipC = Center $fields[0]; $codeC = Center $fields[2]
# KEYCODE_0=7..KEYCODE_9=16, PERIOD=56, DEL=67, T=48 (A=29 -> T=29+19;
# all from javap against android-34, not memory)
[void](Adb-Shell @("input", "tap", "$($ipC[0])", "$($ipC[1])"))
Start-Sleep -Milliseconds 400
for ($i = 0; $i -lt 24; $i++) { [void](Adb-Shell @("input", "keyevent", "67")) }
foreach ($k in @(8,16,9,56,8,13,15,56,8,56,8,15,7)) { # 192.168.1.180
[void](Adb-Shell @("input", "keyevent", "$k"))
}
[void](Adb-Shell @("input", "tap", "$($codeC[0])", "$($codeC[1])"))
Start-Sleep -Milliseconds 400
for ($i = 0; $i -lt 6; $i++) { [void](Adb-Shell @("input", "keyevent", "67")) }
[void](Adb-Shell @("input", "keyevent", "48"))
$xml3 = Get-PhoneUiText
$vals = [regex]::Matches($xml3, '<node[^>]*class="android\.widget\.EditText"[^>]*text="([^"]*)"') |
ForEach-Object { $_.Groups[1].Value }
Write-Host " form now: $($vals -join ' | ')"
$btn = [regex]::Match($xml3, '<node[^>]*class="android\.widget\.Button"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"')
if (-not $btn.Success) { throw "connect button not found" }
$bc = Center $btn
Assert-Foreground $hwnd "before form connect"
[void](Adb-Shell @("input", "tap", "$($bc[0])", "$($bc[1])"))
$xmlRef = ""
if (-not (Wait-For-LiveSession 25 ([ref]$xmlRef))) {
$shown = [regex]::Matches($xmlRef, 'text="([^"]+)"') | ForEach-Object { $_.Groups[1].Value }
throw "V4 FAIL: form connect did not reach a live session. screen=[$($shown -join ' | ')]"
}
Write-Host " V4 PASS via form UI: $([regex]::Match($xmlRef,'text="([^"]*RTT=[^"]*)"').Groups[1].Value)"
}
finally {
if ($origIme -and $origIme -ne "null") {
[void](Adb-Shell @("ime", "enable", $origIme))
[void](Adb-Shell @("ime", "set", $origIme))
Write-Host " IME restored: $((Adb-Shell @("settings", "get", "secure", "default_input_method")).out.Trim())"
}
}
Write-Host "=== 7. stop target ==="
New-Item -ItemType File -Force -Path $stopFile | Out-Null
Start-Sleep -Milliseconds 600
Write-Host "=== PHONE CHAIN PROOF COMPLETE ==="
+23
View File
@@ -0,0 +1,23 @@
# Byte-safe device screenshot helper.
# ASCII-only. PS 5.1 `>` redirection writes UTF-16, which corrupts PNG bytes
# (measured 2026-10-30: `adb exec-out screencap -p > x.png` produced a file
# starting with FF FE). Capture on-device and pull instead.
param(
[Parameter(Mandatory = $true)][string]$Out,
[string]$Device = "/sdcard/shot.png"
)
$ErrorActionPreference = "Stop"
$adb = "D:\working\android\sdk\platform-tools\adb.exe"
$old = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
& $adb shell screencap -p $Device | Out-Null
if ($LASTEXITCODE -ne 0) { throw "screencap failed ($LASTEXITCODE)" }
& $adb pull $Device $Out | Out-Null
if ($LASTEXITCODE -ne 0) { throw "pull failed ($LASTEXITCODE)" }
}
finally { $ErrorActionPreference = $old }
$b = [System.IO.File]::ReadAllBytes($Out)
$sig = ($b[0..3] | ForEach-Object { $_.ToString("X2") }) -join " "
if ($sig -ne "89 50 4E 47") { throw "not a PNG (sig=$sig): $Out" }
Write-Host ("OK {0} ({1:N0} bytes)" -f (Split-Path $Out -Leaf), $b.Length)
+171
View File
@@ -0,0 +1,171 @@
# 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 root derived from this script's location (tools/ is one level under the
# repo root) so the proof runs on any clone, not just the author's D: drive.
$repo = (Resolve-Path "$PSScriptRoot\..").Path
$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
# target.pid holds "pid<n><hwnd>" (see text_target.ps1 Shown handler); take line 1.
$parts = @((Get-Content $pidFile -Raw) -split "`n")
$tid = [int]$parts[0].Trim()
$tp = Get-Process -Id $tid
Start-Sleep -Milliseconds 1200
$hwnd = [IntPtr][long]$parts[1].Trim()
if ($hwnd -eq 0) { throw "target reported a null 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))] ==="
+110
View File
@@ -0,0 +1,110 @@
# 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,
# SendInput lands on whatever is FOREGROUND. When the injector is driven
# from another machine (phone -> agent -> this desktop), nothing in this
# process keeps our window on top, so an unrelated window could eat the
# injection. With this switch the form re-asserts foreground every tick and
# the caller still asserts foreground before injecting.
[switch]$GrabForeground
)
$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)
$script:GrabForeground = $GrabForeground
if ($script:GrabForeground) {
Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
public static class OwnForeground {
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, IntPtr pid);
[DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId();
[DllImport("user32.dll")] public static extern bool AttachThreadInput(uint a, uint b, bool f);
// Deliberately NO keybd_event(VK_MENU). The usual 'tap ALT to beat the
// foreground lock' trick corrupts exactly what we measure: after ALT, Windows
// treats the next character as a menu MNEMONIC, so the first injected
// character is eaten. Measured 2026-10-30: ALT re-asserting every 60ms turned
// three injected characters into only 'b' in the target.
// Attaching input queues is a lock workaround that synthesises no key input.
public static bool Claim(IntPtr h) {
uint fgThread = GetWindowThreadProcessId(GetForegroundWindow(), IntPtr.Zero);
uint myThread = GetCurrentThreadId();
AttachThreadInput(myThread, fgThread, true);
bool ok = SetForegroundWindow(h);
AttachThreadInput(myThread, fgThread, false);
return ok && GetForegroundWindow() == h;
}
public static IntPtr Now() { return GetForegroundWindow(); }
}
"@
}
$timer = New-Object System.Windows.Forms.Timer
$timer.Interval = 150
$timer.Add_Tick({
if (Test-Path $script:StopFile) {
$form.Close()
return
}
if ($script:GrabForeground) {
# Re-assert only when actually lost (unconditional claiming every tick
# would fight the user for the desktop outside a proof run).
if ([OwnForeground]::Now() -ne $form.Handle) {
[void]$form.Activate()
[void][OwnForeground]::Claim($form.Handle)
}
}
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()
# Report BOTH pid and window handle: a proof caller resolving the handle via
# Get-Process races with window creation and can read 0. Handle is stable
# here because the form is realized by the time Shown fires.
[System.IO.File]::WriteAllText($script:PidFile, "$PID`n" + $form.Handle.ToInt64())
})
$timer.Start()
[void]$form.ShowDialog()
$timer.Stop()
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "viewer-core"
version = "0.1.0"
edition = "2021"
# 阶段 1 先做纯 Rust 库并在桌面 headless 验证;
# UniFFI / cdylib 在下一步(Android 接入)再加,隔离未知项。
[lib]
name = "viewer_core"
crate-type = ["lib"]
[dependencies]
common = { path = "../common" }
anyhow.workspace = true
tokio.workspace = true
quinn.workspace = true
rustls.workspace = true
[dev-dependencies]
# headless_check 用软解交叉验证收到的码流合法(库本身刻意不依赖 openh264,
# 解码在 Android 侧由 MediaCodec 承担)
openh264 = { version = "0.9.9", features = ["source"] }
+149
View File
@@ -0,0 +1,149 @@
//! viewer-core 的桌面 headless 验证工具(阶段 1 验收用,不依赖 Android)。
//!
//! 证明三件事,逐条打印:
//! 1. viewer-core 能完成 QUIC 连接 + 协议 v2 认证握手;
//! 2. 帧流按契约交付(seq 单调递增、keyframe 标志与码流实际 NAL 一致);
//! 3. 收到的码流能被 openh264 解出画面(即:Android 侧 MediaCodec 将拿到同样
//! 合法的 Annex-B——解码器不同,但码流合法性在这里就证明了)。
//!
//! 用法:
//! 终端1: target\release\agent.exe --port 47900 --code t
//! 终端2: cargo run -p viewer-core --bin headless_check -- --port 47900 --code t --frames 60
use anyhow::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()
}
/// 与 dump_stream 同款:扫 Annex-B 起始码拿 NAL 类型,交叉校验 keyframe 标志
fn has_idr(payload: &[u8]) -> bool {
let mut i = 0usize;
while i + 3 < payload.len() {
let sc = if payload[i..].starts_with(&[0, 0, 0, 1]) {
4
} else if payload[i..].starts_with(&[0, 0, 1]) {
3
} else {
i += 1;
continue;
};
match payload.get(i + sc) {
Some(&b) if b & 0x1F == 5 => return true,
Some(_) => i += sc + 1,
None => break,
}
}
false
}
#[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());
// --host:默认 127.0.0.1;填局域网 IP 可顺带验证防火墙入站路径
let host = arg("--host").unwrap_or_else(|| "127.0.0.1".into());
let frames_wanted: u64 = arg("--frames")
.map(|s| s.parse())
.transpose()?
.unwrap_or(60);
let mut session = Session::connect(ConnectConfig::direct(&host, port, code)).await?;
println!("[check] 连接成功 host={host} peer={}", session.peer_addr());
let mut decoder = openh264::decoder::Decoder::new()?;
let mut last_seq: Option<u64> = None;
let mut got = 0u64;
let mut seq_violations = 0u64;
let mut kf_mismatches = 0u64;
let mut decoded = 0u64;
let mut decode_failed_before_idr = 0u64;
let mut awaiting_idr = true; // 与 Android 侧相同的状态机
let mut rtt_shown = false;
let deadline = std::time::Instant::now() + Duration::from_secs(30);
while got < frames_wanted && std::time::Instant::now() < deadline {
let Some(f) = tokio::time::timeout(Duration::from_secs(10), session.next_frame())
.await
.ok()
.flatten()
else {
break;
};
got += 1;
// 契约 2a:seq 单调递增
if let Some(prev) = last_seq {
if f.seq <= prev {
seq_violations += 1;
println!("[check] ⚠️ seq 回退: {prev} -> {}", f.seq);
}
}
last_seq = Some(f.seq);
// 契约 2b:keyframe 标志与码流一致
let real_kf = has_idr(&f.payload);
if f.keyframe != real_kf {
kf_mismatches += 1;
println!(
"[check] ⚠️ 帧头 keyframe={} 但码流实际 IDR={}(seq={})",
f.keyframe, real_kf, f.seq
);
}
// 契约 3:与 Android 相同的状态机下解码必须成功
if awaiting_idr && !f.keyframe {
continue; // 等 IDR,P 帧不喂(这是 R-3 教训的实现)
}
if f.keyframe {
awaiting_idr = false;
}
match decoder.decode(&f.payload) {
Ok(_) => decoded += 1,
Err(e) => {
decode_failed_before_idr += 1;
println!("[check] ❌ seq={} 解码失败: {e}", f.seq);
awaiting_idr = true;
session.request_keyframe();
}
}
// RTT 出现即记录一次
if !rtt_shown {
let s = session.stats();
if s.rtt_ms > 0 {
println!("[check] RTT={}ms frames_in={} bytes_in={}", s.rtt_ms, s.frames_in, s.bytes_in);
rtt_shown = true;
}
}
}
let s = session.stats();
println!("\n========== 阶段 1 headless 验收 ==========");
println!("收到帧: {got}(目标 {frames_wanted})");
println!("seq 违例: {seq_violations}");
println!("keyframe 标志不符: {kf_mismatches}");
println!("解码成功: {decoded} / 解码失败: {decode_failed_before_idr}");
println!("统计: rtt={}ms frames_in={} bytes_in={}", s.rtt_ms, s.frames_in, s.bytes_in);
let ok = got >= frames_wanted.min(30)
&& seq_violations == 0
&& kf_mismatches == 0
&& decoded > 0
&& decode_failed_before_idr == 0;
println!(
"结论: {}",
if ok { "✅ viewer-core 收帧链路通过" } else { "❌ 存在未满足项" }
);
session.close().await;
Ok(())
}
+90
View File
@@ -0,0 +1,90 @@
//! 协议 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 缺失或为空:本工具要验证的是「有文本送出并被注入」,\
空输入没有验证价值(空 Text 在产品路径上由 Session::send_text 拦掉)"
);
}
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(())
}
+454
View File
@@ -0,0 +1,454 @@
//! viewer-core:安卓主控的连接 / 协议 / 收帧层(纯 Rust,平台无关)。
//!
//! 设计边界(对齐《多平台架构设计》与 2026-10-29 方案裁决):
//! - **只做「怎么连 + 收发什么」**:QUIC 会话、认证握手、视频帧流、输入上行、
//! Ping/Pong、KeyFrameReq。解码(MediaCodec)、渲染(SurfaceView)、触摸映射
//! 全在 Kotlin 侧,本 crate 不碰像素,也不引入 openh264。
//! - **连接层是唯一的可替换点**:`PeerTarget::Direct` 现在只支持 `IP:端口` 局域网
//! 直连;将来设备码 + 信令 + 打洞只换 `connect()` 内部。**不定义 Relay 变体**
//! (中继已被团队裁决排除,见《多平台架构设计》§6.1)。
//! - 帧经 mpsc 通道交付,**不丢帧**:消费方(Kotlin)按 `keyframe` 标志自行决定
//! 跳帧策略,且跳帧后必须 `request_keyframe()` 并等 IDR(协议 v2 提供该字段)。
//! 丢帧决策放在这里会让 Rust 层承担它看不到的解码器状态。
//!
//! 安全现状:`insecure_skip_verify` 默认 true(与桌面 viewer 的 SkipVerify 一致,
//! 仅限局域网测试)。产品化前必须换成信令下发的证书指纹钉扎(`signaling.rs`
//! 已有 `CertFingerprint`),该项登记在方案阶段 4。
use anyhow::{bail, Context, Result};
use common::{
read_control, read_video_frame, write_control, ControlMsg, InputEvent, VideoFrameHeader,
PROTO_VERSION,
};
use rustls::client::danger::{
HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier,
};
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
use rustls::{DigitallySignedStruct, SignatureScheme};
use std::net::ToSocketAddrs;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::mpsc;
/// 连接目标。当前只有局域网直连;设备码 + 打洞在后续阶段替换 `connect()` 内部实现。
#[derive(Debug, Clone)]
pub enum PeerTarget {
/// 局域网 / 可直连地址
Direct { host: String, port: u16 },
}
/// 一次会话的参数
#[derive(Debug, Clone)]
pub struct ConnectConfig {
pub target: PeerTarget,
/// 被控端验证码
pub code: String,
/// 仅测试用:跳过服务端证书校验(与桌面 viewer 行为一致)。
/// 公网/产品形态必须为 false 并启用证书钉扎。
pub insecure_skip_verify: bool,
}
impl ConnectConfig {
/// 局域网测试的便捷构造
pub fn direct(host: impl Into<String>, port: u16, code: impl Into<String>) -> Self {
Self {
target: PeerTarget::Direct {
host: host.into(),
port,
},
code: code.into(),
insecure_skip_verify: true,
}
}
}
/// 交付给消费方的一帧视频。字段语义与 `common::VideoFrameHeader` 一致,
/// payload 为标准 Annex-B H.264(可直接喂 MediaCodec)。
#[derive(Debug, Clone)]
pub struct Frame {
pub width: u32,
pub height: u32,
pub ts_ms: u64,
pub seq: u64,
pub keyframe: bool,
pub payload: Vec<u8>,
}
impl From<(VideoFrameHeader, Vec<u8>)> for Frame {
fn from((h, payload): (VideoFrameHeader, Vec<u8>)) -> Self {
Self {
width: h.width,
height: h.height,
ts_ms: h.ts_ms,
seq: h.seq,
keyframe: h.keyframe,
payload,
}
}
}
/// 会话状态快照(供 UI 显示,不做 EMA——那属于 Kotlin 的 HUD 层)
#[derive(Debug, Clone, Copy, Default)]
pub struct SessionStats {
/// 最近一次 Ping 往返(ms),0 = 还没测出
pub rtt_ms: u64,
/// 收到的帧数
pub frames_in: u64,
/// 收到的字节数(视频载荷累计)
pub bytes_in: u64,
}
/// MVP:跳过服务端证书校验(自签证书,仅局域网)。
#[derive(Debug)]
struct SkipVerify;
impl ServerCertVerifier for SkipVerify {
fn verify_server_cert(
&self,
_end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp_response: &[u8],
_now: UnixTime,
) -> std::result::Result<ServerCertVerified, rustls::Error> {
Ok(ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_certificate: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_certificate: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
vec![
SignatureScheme::RSA_PKCS1_SHA256,
SignatureScheme::ECDSA_NISTP256_SHA256,
SignatureScheme::ED25519,
SignatureScheme::RSA_PSS_SHA256,
]
}
}
/// 输入/控制命令(写入流 2)
enum Command {
Input(InputEvent),
KeyFrameReq,
Ping(u64),
}
/// 已建立的会话。Drop 时后台任务随连接关闭自然退出;显式 `close()` 更干净。
pub struct Session {
tx: mpsc::UnboundedSender<Command>,
/// 帧接收端。通道关闭 = 会话结束(被控端断开或流错误)。
/// Mutex 包一层是为了让 `next_frame` 取 `&self`:JNI 层里解码线程独占 recv,
/// 输入线程并发调 `send_input`(走无锁的 unbounded tx),两者互不阻塞。
frames: Arc<tokio::sync::Mutex<mpsc::Receiver<Frame>>>,
stats: Arc<std::sync::Mutex<SessionStats>>,
/// 被控端桌面尺寸(AuthOk 携带)。主控端据此在解码前定好视图宽高比——
/// 若等首帧再改尺寸会触发 SurfaceView 重新测量,把刚创建的解码器 surface 打掉。
desktop: (u32, u32),
/// 保持 Endpoint 存活(drop 会销毁连接)
_endpoint: quinn::Endpoint,
conn: quinn::Connection,
}
impl Session {
/// 建立连接并完成认证握手 + 首帧关键帧请求。
pub async fn connect(cfg: ConnectConfig) -> Result<Self> {
rustls::crypto::ring::default_provider()
.install_default()
.ok();
let tls = if cfg.insecure_skip_verify {
rustls::ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(SkipVerify))
.with_no_client_auth()
} else {
// 证书钉扎尚未接入(方案阶段 4)。这里明确报错而不是静默放行。
bail!("insecure_skip_verify=false 需要证书钉扎,尚未实现(见方案阶段 4)");
};
let qc: quinn::crypto::rustls::QuicClientConfig = Arc::new(tls)
.try_into()
.context("build QUIC client config")?;
// 接收窗口放大:视频下行大流量,quinn 默认 512KB 流窗口会卡吞吐
// (与桌面 viewer / agent 的实测调优一致)
let mut transport = quinn::TransportConfig::default();
transport.receive_window(quinn::VarInt::from_u32(16 * 1024 * 1024));
transport.stream_receive_window(quinn::VarInt::from_u32(8 * 1024 * 1024));
let mut client_config = quinn::ClientConfig::new(Arc::new(qc));
client_config.transport_config(Arc::new(transport));
let mut endpoint = quinn::Endpoint::client("0.0.0.0:0".parse()?)?;
endpoint.set_default_client_config(client_config);
let PeerTarget::Direct { host, port } = &cfg.target;
let addr = (host.as_str(), *port)
.to_socket_addrs()?
.next()
.with_context(|| format!("resolve {host}:{port}"))?;
let conn = endpoint
.connect(addr, "localhost")?
.await
.with_context(|| format!("QUIC connect {addr}"))?;
// ---- 流 1:认证 + 视频下行 ----
let (mut v_send, mut v_recv) = conn.open_bi().await?;
write_control(
&mut v_send,
&ControlMsg::Auth {
code: cfg.code.clone(),
proto_version: PROTO_VERSION,
},
)
.await
.context("send Auth")?;
let desktop: (u32, u32);
match read_control(&mut v_recv).await.context("read AuthOk")? {
ControlMsg::AuthOk {
width,
height,
proto_version,
} => {
if proto_version != PROTO_VERSION {
bail!(
"protocol mismatch: agent={proto_version}, viewer-core={PROTO_VERSION}"
);
}
desktop = (width, height);
log(&format!("auth ok, remote desktop {width}x{height}"));
}
ControlMsg::AuthFail(e) => bail!("auth failed: {e}"),
m => bail!("unexpected first message: {m:?}"),
}
// ---- 流 2:输入 + 控制 + Ping/Pong ----
let (mut ctl_send, mut ctl_recv) = conn.open_bi().await?;
// 入会即请求 IDR:从 GOP 中间接入时裸 P 帧解不出来(桌面端同款教训)
write_control(&mut ctl_send, &ControlMsg::KeyFrameReq).await?;
let stats = Arc::new(std::sync::Mutex::new(SessionStats::default()));
// 帧通道:容量 2 足够——消费方(Kotlin)每帧都要喂解码器,
// 积压超过 2 帧说明该跳帧的是消费方的决策,不是这里的。
let (frame_tx, frames) = mpsc::channel::<Frame>(2);
{
let stats = stats.clone();
tokio::spawn(async move {
loop {
match read_video_frame(&mut v_recv).await {
Ok((h, payload)) => {
{
let mut s = stats.lock().unwrap();
s.frames_in += 1;
s.bytes_in += payload.len() as u64;
}
let frame: Frame = (h, payload).into();
if frame_tx.send(frame).await.is_err() {
break; // 消费方已退出
}
}
Err(e) => {
log(&format!("video stream ended: {e}"));
break;
}
}
}
});
}
// 命令通道 → 流 2 写任务
let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel::<Command>();
tokio::spawn(async move {
loop {
let Some(cmd) = cmd_rx.recv().await else {
break;
};
let msg = match cmd {
Command::Input(ev) => ControlMsg::Input(ev),
Command::KeyFrameReq => ControlMsg::KeyFrameReq,
Command::Ping(t) => ControlMsg::Ping(t),
};
if write_control(&mut ctl_send, &msg).await.is_err() {
break;
}
}
});
// 流 2 读任务:Pong → RTT
{
let stats = stats.clone();
tokio::spawn(async move {
loop {
match read_control(&mut ctl_recv).await {
Ok(ControlMsg::Pong { t, .. }) => {
let rtt = monotonic_ms().saturating_sub(t);
stats.lock().unwrap().rtt_ms = rtt;
}
Ok(_) => {}
Err(e) => {
log(&format!("control stream ended: {e}"));
break;
}
}
}
});
}
// Ping 任务:500ms 一次(与桌面 viewer 一致)
{
let cmd_tx_ping = cmd_tx.clone();
tokio::spawn(async move {
loop {
tokio::time::sleep(Duration::from_millis(500)).await;
if cmd_tx_ping.send(Command::Ping(monotonic_ms())).is_err() {
break;
}
}
});
}
Ok(Self {
tx: cmd_tx,
frames: Arc::new(tokio::sync::Mutex::new(frames)),
stats,
desktop,
_endpoint: endpoint,
conn,
})
}
/// 被控端桌面尺寸 `(width, height)`,来自 AuthOk,解码前即可用。
pub fn desktop_size(&self) -> (u32, u32) {
self.desktop
}
/// 取下一帧。`None` = 会话结束。
/// `&self`:JNI 层里解码线程独占 recv,输入线程可并发 `send_input`。
pub async fn next_frame(&self) -> Option<Frame> {
self.frames.lock().await.recv().await
}
/// 发送一个输入事件(非阻塞入队,由写任务串行发出)。
pub fn send_input(&self, ev: InputEvent) {
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);
}
/// 当前会话统计快照。
pub fn stats(&self) -> SessionStats {
*self.stats.lock().unwrap()
}
/// 被控端地址(日志/UI 用)
pub fn peer_addr(&self) -> String {
self.conn.remote_address().to_string()
}
/// 主动结束会话。
pub async fn close(self) {
let _ = self.conn.close(0u32.into(), b"viewer closing");
}
}
/// 单调毫秒时钟(进程启动起算)。Ping/Pong 两端共用同一口径即可测 RTT。
fn monotonic_ms() -> u64 {
use std::sync::OnceLock;
static T0: OnceLock<Instant> = OnceLock::new();
T0.get_or_init(Instant::now).elapsed().as_millis() as u64
}
/// 轻量日志出口。阶段 2 接 UniFFI 时替换为 Kotlin 侧回调。
fn log(msg: &str) {
eprintln!("[viewer-core] {msg}");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn frame_from_header_preserves_contract_fields() {
let h = VideoFrameHeader {
width: 1920,
height: 1080,
ts_ms: 42,
seq: 7,
keyframe: true,
};
let payload = vec![0u8; 16];
let f: Frame = (h.clone(), payload.clone()).into();
assert_eq!((f.width, f.height), (1920, 1080));
assert_eq!(f.seq, 7);
assert!(f.keyframe);
assert_eq!(f.payload, payload);
}
#[test]
fn direct_config_defaults_to_insecure_lan_mode() {
let c = ConnectConfig::direct("192.168.1.5", 47800, "abc");
assert!(c.insecure_skip_verify);
match c.target {
PeerTarget::Direct { host, port } => {
assert_eq!(host, "192.168.1.5");
assert_eq!(port, 47800);
}
}
}
#[tokio::test]
async fn strict_verify_mode_is_explicit_unimplemented_error() {
// 安全红线不能静默:显式要求校验时必须是明确错误,而不是悄悄放行。
// 不用 unwrap_err():它要求 Ok 类型(Session)实现 Debug,而连接对象
// 的 Debug 输出没有验证价值。
let cfg = ConnectConfig {
target: PeerTarget::Direct {
host: "127.0.0.1".into(),
port: 1,
},
code: "x".into(),
insecure_skip_verify: false,
};
match Session::connect(cfg).await {
Err(e) => assert!(e.to_string().contains("证书钉扎"), "实际错误: {e}"),
Ok(_) => panic!("insecure_skip_verify=false 必须明确报错,不得静默放行"),
}
}
}