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 打的是前台, 靶窗不在前台时的注入会进别的应用,宁可失败也不误注入。
This commit is contained in:
parent
6c28a3f7e3
commit
936986a940
167
tools/text_injection_proof.ps1
Normal file
167
tools/text_injection_proof.ps1
Normal file
@ -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 <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 = "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))] ==="
|
||||
62
tools/text_target.ps1
Normal file
62
tools/text_target.ps1
Normal file
@ -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()
|
||||
87
viewer-core/examples/text_check.rs
Normal file
87
viewer-core/examples/text_check.rs
Normal file
@ -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<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 为空:本轮要验证的是「有文本送出并被注入」,空输入没有验证价值");
|
||||
}
|
||||
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(())
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user