From 9f0b0742a877a6647069e6d7b8dbd3138c0f4989 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:58:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(android):=20=E9=94=AE=E7=9B=98=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=E5=8F=8C=E9=80=9A=E9=81=93=20+=20=E6=89=8B=E6=9C=BA?= =?UTF-8?q?=E5=88=B0=20Windows=20=E5=85=A8=E9=93=BE=E8=B7=AF=20proof?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真机暴露出 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 精确还原。 --- .../kotlin/com/verify/remote/MainActivity.kt | 56 +++- .../verify/remote/RemoteInputConnection.kt | 13 + tools/phone_chain_proof.ps1 | 259 ++++++++++++++++++ tools/text_injection_proof.ps1 | 8 +- tools/text_target.ps1 | 52 +++- 5 files changed, 380 insertions(+), 8 deletions(-) create mode 100644 tools/phone_chain_proof.ps1 diff --git a/apps/android-viewer/app/src/main/kotlin/com/verify/remote/MainActivity.kt b/apps/android-viewer/app/src/main/kotlin/com/verify/remote/MainActivity.kt index cdb92ee..3766174 100644 --- a/apps/android-viewer/app/src/main/kotlin/com/verify/remote/MainActivity.kt +++ b/apps/android-viewer/app/src/main/kotlin/com/verify/remote/MainActivity.kt @@ -276,8 +276,22 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener { override fun isReadyForInput(): Boolean = handle != 0L /** - * 返回键:软键盘开着时先收键盘(否则一次返回同时关键盘和会话); - * 远控态已连接 = 断开回连接界面;连接态 = 交还系统(退出 App)。 + * 按键转发(物理键盘 / 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) { @@ -291,8 +305,44 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener { disconnect() return true } + return super.onKeyDown(keyCode, event) } - 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 } // ---- 连接 / 解码 ---- diff --git a/apps/android-viewer/app/src/main/kotlin/com/verify/remote/RemoteInputConnection.kt b/apps/android-viewer/app/src/main/kotlin/com/verify/remote/RemoteInputConnection.kt index 8c0944f..db65cd3 100644 --- a/apps/android-viewer/app/src/main/kotlin/com/verify/remote/RemoteInputConnection.kt +++ b/apps/android-viewer/app/src/main/kotlin/com/verify/remote/RemoteInputConnection.kt @@ -43,6 +43,19 @@ class RemoteInputConnection( } 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}") diff --git a/tools/phone_chain_proof.ps1 b/tools/phone_chain_proof.ps1 new file mode 100644 index 0000000..db3dfea --- /dev/null +++ b/tools/phone_chain_proof.ps1 @@ -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": 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, ']*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, ']*class="android\.widget\.EditText"[^>]*text="([^"]*)"') | + ForEach-Object { $_.Groups[1].Value } + Write-Host " form now: $($vals -join ' | ')" + $btn = [regex]::Match($xml3, ']*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 ===" diff --git a/tools/text_injection_proof.ps1 b/tools/text_injection_proof.ps1 index 5bc51df..b092d7c 100644 --- a/tools/text_injection_proof.ps1 +++ b/tools/text_injection_proof.ps1 @@ -114,11 +114,13 @@ Start-Process powershell.exe -ArgumentList "-NoProfile","-ExecutionPolicy","Bypa "-File","$repo\tools\text_target.ps1","-PidFile",$pidFile,` "-ResultFile",$resultFile,"-StopFile",$stopFile Wait-File $pidFile -$tid = [int](Get-Content $pidFile -Raw).Trim() +# target.pid holds "pid" (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 = $tp.MainWindowHandle -if ($hwnd -eq 0) { throw "target has no window handle" } +$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" diff --git a/tools/text_target.ps1 b/tools/text_target.ps1 index 31fb0fe..86a3109 100644 --- a/tools/text_target.ps1 +++ b/tools/text_target.ps1 @@ -10,7 +10,13 @@ param( [Parameter(Mandatory = $true)][string]$PidFile, [Parameter(Mandatory = $true)][string]$ResultFile, - [Parameter(Mandatory = $true)][string]$StopFile + [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 @@ -36,6 +42,37 @@ $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({ @@ -43,6 +80,14 @@ $timer.Add_Tick({ $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) @@ -54,7 +99,10 @@ $timer.Add_Tick({ $form.Add_Shown({ $form.Activate() [void]$tb.Focus() - [System.IO.File]::WriteAllText($script:PidFile, "$PID") + # 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()