From 936986a940e579ef418f5a9e6ee7c71279926708 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=91=E5=8D=9A=E6=96=87?= <1323515022@qq.com> Date: Fri, 30 Oct 2026 12:09:48 +0800 Subject: [PATCH] =?UTF-8?q?test(android-2b):=20=E6=96=87=E6=9C=AC=E6=B3=A8?= =?UTF-8?q?=E5=85=A5=E7=9C=9F=E8=BE=B9=E7=95=8C=E5=B7=A5=E5=85=B7=E2=80=94?= =?UTF-8?q?=E2=80=94viewer-core=20=E7=9B=B4=E5=8F=91=20Text=EF=BC=8C?= =?UTF-8?q?=E9=9D=B6=E7=AA=97=E8=87=AA=E6=8A=A5=E6=94=B6=E5=88=B0=E5=86=85?= =?UTF-8?q?=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit text_check 走的是产品同一条路径(Session::send_text → v3 线格式 → agent SendInput KEYEVENTF_UNICODE),手机掉线也能先把 Windows 半段验实。 参数是十进制 code unit 列表而不是自然语言字符串:命令行到 Rust 的编码往返 会引入与本轮无关的变量,由 PowerShell 从 .NET 字符串取 char[] 更忠实。 proof 脚本刻意用自有 WinForms 靶窗而非记事本:Win11 记事本所有标签页同进程, 本机当时开着用户文档,往里注入等于改用户文件。靶窗自报文本(写文件), 判据不依赖截图和 OCR。注入前后各断言一次前台窗口——SendInput 打的是前台, 靶窗不在前台时的注入会进别的应用,宁可失败也不误注入。 --- tools/text_injection_proof.ps1 | 167 +++++++++++++++++++++++++++++ tools/text_target.ps1 | 62 +++++++++++ viewer-core/examples/text_check.rs | 87 +++++++++++++++ 3 files changed, 316 insertions(+) create mode 100644 tools/text_injection_proof.ps1 create mode 100644 tools/text_target.ps1 create mode 100644 viewer-core/examples/text_check.rs diff --git a/tools/text_injection_proof.ps1 b/tools/text_injection_proof.ps1 new file mode 100644 index 0000000..dc17ff6 --- /dev/null +++ b/tools/text_injection_proof.ps1 @@ -0,0 +1,167 @@ +# 2b V6/V7 proof: v3 text injection into a dedicated WinForms target window, +# read back from that process itself. +# ASCII-only source (PS 5.1 decodes BOM-less .ps1 as GBK; see personal lessons). +# Non-ASCII payloads are built from explicit code points, never literals. +# +# Usage: +# powershell -ExecutionPolicy Bypass -File tools\text_injection_proof.ps1 +# +# Chain: viewer-core text_check --units -> v3 agent SendInput +# KEYEVENTF_UNICODE -> foreground WinForms TextBox -> self-reported text. +# +# Why NOT Notepad (2026-10-30): Windows 11 Notepad hosts all tabs in ONE +# process and this dev box had a user document open in it - injecting "into +# Notepad" would mean injecting into the user's real file. The WinForms target +# is a separate process we own, opened on an empty field. +# +# Foreground handling (measured, not assumed): a form with TopMost=true that +# is activated at startup KEEPS foreground for seconds; AppActivate / +# SetForegroundWindow called from here actually STEALS it away. So we assert +# the target is foreground before injecting, and refuse to inject otherwise - +# a stray injection would land in whatever window was foreground. + +$ErrorActionPreference = "Stop" +$repo = "D:\working\projects\yuankong\remote" +$tmp = "$env:TEMP\remote-text-proof" +$base = "D:\working\environment" + +$env:RUSTUP_HOME = "$base\rust" +$env:CARGO_HOME = "$base\cargo" +$env:PATH = "$base\mingw64\bin;$base\cargo\bin;" + $env:PATH + +Add-Type -TypeDefinition @" +using System; +using System.Runtime.InteropServices; +public static class WinFg { + [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); +} +"@ + +New-Item -ItemType Directory -Force -Path $tmp | Out-Null +$pidFile = "$tmp\target.pid" +$stopFile = "$tmp\stop.flag" +$resultFile = "$tmp\received.txt" +Remove-Item $pidFile,$stopFile,$resultFile -ErrorAction SilentlyContinue + +function Wait-File { + param([string]$Path, [int]$TimeoutMs = 15000) + $tries = 0 + while (-not (Test-Path $Path) -and $tries -lt ($TimeoutMs / 150)) { + Start-Sleep -Milliseconds 150; $tries++ + } + if (-not (Test-Path $Path)) { throw "file did not appear: $Path" } +} + +function Read-Received { + Wait-File $resultFile + $bytes = [System.IO.File]::ReadAllBytes($resultFile) + $text = [System.Text.Encoding]::UTF8.GetString($bytes) + if ($text.Length -gt 0 -and $text[0] -eq [char]0xFEFF) { $text = $text.Substring(1) } + return $text +} + +function CodePointsToString([int[]]$cps) { + -join ($cps | ForEach-Object { [char]$_ }) +} + +function Show-Text([string]$s) { + # ASCII-safe console preview: non-ASCII rendered as U+XXXX + ($s.ToCharArray() | ForEach-Object { + if ([int]$_ -ge 32 -and [int]$_ -lt 127) { $_ } else { "U+{0:X4}" -f [int]$_ } + }) -join "" +} + +function Assert-TargetForeground([IntPtr]$hwnd) { + $fg = [WinFg]::GetForegroundWindow() + if ($fg -ne $hwnd) { + throw "foreground is $fg, not target $hwnd - refusing to inject (would hit another app)" + } +} + +function Send-Units([int[]]$cps, [IntPtr]$hwnd, [int]$delayMs = 1500) { + $units = $cps -join "," + $exe = "$repo\target\debug\examples\text_check.exe" + if (-not (Test-Path $exe)) { throw "missing $exe (cargo build -p viewer-core --example text_check)" } + Assert-TargetForeground $hwnd + & $exe --port 47800 --code t --units $units --delay $delayMs --hold 800 + $code = $LASTEXITCODE + Assert-TargetForeground $hwnd # 注入窗口在 --delay 期间必须仍是靶窗 + if ($code -ne 0) { throw "text_check exited $code" } + Start-Sleep -Milliseconds 500 +} + +Write-Host "=== 0. ensure CURRENT v3 agent is the one running ===" +$ag = Get-Process agent -ErrorAction SilentlyContinue +$agentExe = "$repo\target\release\agent.exe" +if ($ag -and (Get-Item $agentExe).LastWriteTime -gt $ag.StartTime) { + Write-Host " running agent predates current build; restarting" + Stop-Process -Name agent -Force -ErrorAction SilentlyContinue + Start-Sleep -Milliseconds 800 + $ag = $null +} +if (-not $ag) { + Start-Process -FilePath $agentExe -ArgumentList "--port","47800","--code","t" -WindowStyle Hidden + Start-Sleep -Seconds 1 + Write-Host " agent started (v3)" +} + +Write-Host "=== 1. launch WinForms target (own process, empty field) ===" +# NO -WindowStyle Hidden: the hidden host makes the form invisible, and an +# invisible window can never be foreground (measured 2026-10-30). +Start-Process powershell.exe -ArgumentList "-NoProfile","-ExecutionPolicy","Bypass",` + "-File","$repo\tools\text_target.ps1","-PidFile",$pidFile,` + "-ResultFile",$resultFile,"-StopFile",$stopFile +Wait-File $pidFile +$tid = [int](Get-Content $pidFile -Raw).Trim() +$tp = Get-Process -Id $tid +Start-Sleep -Milliseconds 1200 +$hwnd = $tp.MainWindowHandle +if ($hwnd -eq 0) { throw "target has no window handle" } +Write-Host " target pid=$tid hwnd=$hwnd title=[$($tp.MainWindowTitle)]" +Assert-TargetForeground $hwnd +Write-Host " target is foreground: yes" + +Write-Host "=== 2. V6: four CJK BMP characters (Ni Hao Shi Jie) ===" +$cjk = @(0x4F60, 0x597D, 0x4E16, 0x754C) +Send-Units $cjk $hwnd +$got6 = Read-Received +$exp6 = CodePointsToString $cjk +Write-Host (" received [{0}] expected [{1}]" -f (Show-Text $got6), (Show-Text $exp6)) +if ($got6 -ne $exp6) { throw "V6 FAIL" } +Write-Host " V6 PASS - BMP CJK arrived intact through v3 Text + KEYEVENTF_UNICODE" + +Write-Host "=== 3. V7: exclaim + emoji U+1F600 (UTF-16 surrogate pair) appended ===" +$mix = @(0x0021, 0xD83D, 0xDE00) +Send-Units $mix $hwnd +$got7 = Read-Received +$exp7 = $exp6 + (CodePointsToString $mix) +Write-Host (" received len={0} [{1}]" -f $got7.Length, (Show-Text $got7)) +Write-Host (" expected len={0} [{1}]" -f $exp7.Length, (Show-Text $exp7)) +if ($got7 -ne $exp7) { throw "V7 FAIL - mismatch" } +if ($got7.Contains([char]0xFFFD)) { throw "V7 FAIL - U+FFFD present: pair got split" } +Write-Host " V7 PASS - surrogate pair survived as one character, no replacement char" + +Write-Host "=== 4. negative guard: missing --units must NOT inject anything ===" +$before = Read-Received +$exe = "$repo\target\debug\examples\text_check.exe" +# Omit --units entirely (passing an empty string would be eaten by PowerShell +# argument handling and parsed as the next flag). viewer-core's parse_units +# rejects the degenerate input before any session is even opened. +# No 2>&1 here: viewer-core logs go to stderr by design; merging them turns +# every normal log line into a PowerShell error record. +& $exe --port 47800 --code t --delay 100 --hold 100 | Out-Null +$emptyCode = $LASTEXITCODE +Write-Host " exit=$emptyCode (expected non-zero: tool rejects empty --units)" +if ($emptyCode -eq 0) { throw "FAIL - empty units should be rejected by the tool" } +Start-Sleep -Milliseconds 400 +$after = Read-Received +if ($after -ne $before) { throw "FAIL - target text changed unexpectedly: [$after]" } +Write-Host " guard PASS - no injection happened, target text unchanged" + +Write-Host "=== 5. stop target ===" +New-Item -ItemType File -Force -Path $stopFile | Out-Null +$tries = 0 +while ((Get-Process -Id $tid -ErrorAction SilentlyContinue) -and $tries -lt 40) { + Start-Sleep -Milliseconds 150; $tries++ +} +Write-Host "=== PROOF COMPLETE: final target text = [$(Show-Text (Read-Received))] ===" diff --git a/tools/text_target.ps1 b/tools/text_target.ps1 new file mode 100644 index 0000000..31fb0fe --- /dev/null +++ b/tools/text_target.ps1 @@ -0,0 +1,62 @@ +# 2b V6/V7 proof target: a dedicated WinForms window we own. +# ASCII-only source (PS 5.1 decodes BOM-less .ps1 as GBK; see personal lessons). +# +# Launched by text_injection_proof.ps1. Shows a topmost empty TextBox, writes +# its current text to ResultFile (UTF-8 with BOM) on every change, exits when +# StopFile appears. The proof injects through the remote-control pipeline and +# polls ResultFile - so "what arrived on the controlled desktop" is reported +# by the receiving application itself, not by a screenshot or OCR. + +param( + [Parameter(Mandatory = $true)][string]$PidFile, + [Parameter(Mandatory = $true)][string]$ResultFile, + [Parameter(Mandatory = $true)][string]$StopFile +) +$ErrorActionPreference = "Stop" +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing + +# Event handler scriptblocks resolve script-scoped vars; make that explicit +# instead of relying on the param locals staying on a visible scope chain. +$script:PidFile = $PidFile +$script:ResultFile = $ResultFile +$script:StopFile = $StopFile +$script:lastWritten = $null + +$form = New-Object System.Windows.Forms.Form +$form.Text = "RemoteTextTarget" +$form.ClientSize = New-Object System.Drawing.Size(420, 140) +$form.TopMost = $true +$form.StartPosition = "Manual" +$form.Location = New-Object System.Drawing.Point(80, 80) + +$tb = New-Object System.Windows.Forms.TextBox +$tb.Multiline = $true +$tb.Font = New-Object System.Drawing.Font("Segoe UI", 16.0) +$tb.Dock = "Fill" +$form.Controls.Add($tb) + +$timer = New-Object System.Windows.Forms.Timer +$timer.Interval = 150 +$timer.Add_Tick({ + if (Test-Path $script:StopFile) { + $form.Close() + return + } + if ($tb.Text -ne $script:lastWritten) { + $script:lastWritten = $tb.Text + $enc = New-Object System.Text.UTF8Encoding($true) + [System.IO.File]::WriteAllText($script:ResultFile, $tb.Text, $enc) + } + if (-not $tb.Focused) { [void]$tb.Focus() } +}) + +$form.Add_Shown({ + $form.Activate() + [void]$tb.Focus() + [System.IO.File]::WriteAllText($script:PidFile, "$PID") +}) + +$timer.Start() +[void]$form.ShowDialog() +$timer.Stop() diff --git a/viewer-core/examples/text_check.rs b/viewer-core/examples/text_check.rs new file mode 100644 index 0000000..10dfffb --- /dev/null +++ b/viewer-core/examples/text_check.rs @@ -0,0 +1,87 @@ +//! 协议 v3 文本注入链路的桌面真边界验证工具(loop-2b,不依赖 Android)。 +//! +//! 证明的事:`Session::send_text` 经线格式到被控端后,`agent` 的 +//! `KEYEVENTF_UNICODE` 注入把**同一段 UTF-16 code unit** 送进了前台应用。 +//! 判据由外部读取目标窗口的实际文本内容(调用方负责),本工具只负责发送。 +//! +//! 用法: +//! 终端1: target\release\agent.exe --port 47800 --code t +//! 终端2: 把目标应用(记事本)切到前台,然后 +//! cargo run -p viewer-core --example text_check -- \ +//! --port 47800 --code t --units 20320,22909,55357,56832 +//! +//! `--units` 是十进制的 UTF-16 code unit 列表(逗号分隔): +//! 「你好😀」= 20320,22909,55357,56832(😀 是代理对)。 +//! 刻意不收自然语言字符串参数:命令行到 Rust 的编码往返会引入与本轮无关的变量, +//! 由调用方(PowerShell)从 .NET 字符串取 char[] 更忠实。 +//! +//! `--delay`:连接后等待毫秒再注入。给调用方把目标窗口切到前台的时间—— +//! SendInput 注入的是**前台**应用,窗口没在前台等于测了个寂寞。 +//! `--hold`:注入后保持会话的毫秒数,等消息在远端消化完再关连接。 + +use anyhow::{bail, Result}; +use std::time::Duration; +use viewer_core::{ConnectConfig, Session}; + +fn arg(name: &str) -> Option { + let a: Vec = std::env::args().collect(); + a.iter() + .position(|x| x == name) + .and_then(|i| a.get(i + 1)) + .cloned() +} + +fn parse_units(spec: &str) -> Result> { + if spec.trim().is_empty() { + bail!("--units 为空:本轮要验证的是「有文本送出并被注入」,空输入没有验证价值"); + } + spec.split(',') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| { + s.parse::() + .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(()) +}