From d1a1e499ea2ac0086d98753ab929b97f9e2ab254 Mon Sep 17 00:00:00 2001 From: zhaoyilun Date: Sat, 11 Jul 2026 11:56:55 +0800 Subject: [PATCH] tools: add offline real client window launcher --- scripts/open-offline-real-client-window.ps1 | 268 ++++++++++++++++++++ 1 file changed, 268 insertions(+) create mode 100644 scripts/open-offline-real-client-window.ps1 diff --git a/scripts/open-offline-real-client-window.ps1 b/scripts/open-offline-real-client-window.ps1 new file mode 100644 index 0000000..291480c --- /dev/null +++ b/scripts/open-offline-real-client-window.ps1 @@ -0,0 +1,268 @@ +param( + [string]$ArchivePath = "runs/offline-evidence-intake/zyl-qqfile-20260709/archives/zyl.rar", + [string]$ExtractDir = "runs/offline-real-client-window/full", + [ValidateSet("Impp", "iSphere")] + [string]$ClientRoot = "Impp", + [string]$HelperExe = "runs/win-helper/ISphereWinHelper.exe", + [int]$WaitSeconds = 10, + [int]$X = 120, + [int]$Y = 120, + [int]$Width = 270, + [int]$Height = 570, + [switch]$SkipExtract, + [switch]$NoLaunch, + [switch]$KeepExisting, + [switch]$TopMost, + [switch]$NoScreenshot +) + +$ErrorActionPreference = "Stop" +$repo = Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..") +$InfoPromptTitle = -join ([char[]](0x4FE1, 0x606F, 0x63D0, 0x793A)) + +function Resolve-RepoPath([string]$PathValue) { + if ([System.IO.Path]::IsPathRooted($PathValue)) { + return $PathValue + } + return (Join-Path $repo $PathValue) +} + +function Find-7Zip { + $candidates = @( + "C:\Program Files\7-Zip\7z.exe", + "C:\Program Files (x86)\7-Zip\7z.exe" + ) + foreach ($candidate in $candidates) { + if (Test-Path -LiteralPath $candidate) { + return $candidate + } + } + $cmd = Get-Command "7z" -ErrorAction SilentlyContinue + if ($cmd) { + return $cmd.Source + } + $cmd = Get-Command "7zr" -ErrorAction SilentlyContinue + if ($cmd) { + return $cmd.Source + } + throw "7-Zip not found. Install 7-Zip or pass -SkipExtract after extracting the archive." +} + +function Invoke-HelperJson([string]$Op, [hashtable]$OpArgs, [string]$RequestId) { + $request = @{ + protocol = "isphere.helper.v1" + request_id = $RequestId + op = $Op + timeout_ms = 5000 + args = $OpArgs + } + $json = $request | ConvertTo-Json -Depth 16 -Compress + $output = $json | & $script:HelperPath --json + try { + return $output | ConvertFrom-Json + } + catch { + throw "helper output was not JSON: $output" + } +} + +function Stop-ExistingOfflineCopies([string]$RootPath) { + $rootFull = [System.IO.Path]::GetFullPath($RootPath).TrimEnd('\') + "\" + $targets = @("IMPlatformClient.exe", "IMPP.ISphere.exe", "IMPlatformClient.Web.exe") + $processes = Get-CimInstance Win32_Process | + Where-Object { $targets -contains $_.Name -and $_.ExecutablePath -and ([System.IO.Path]::GetFullPath($_.ExecutablePath).StartsWith($rootFull, [System.StringComparison]::OrdinalIgnoreCase)) } + foreach ($proc in $processes) { + Stop-Process -Id ([int]$proc.ProcessId) -Force -ErrorAction SilentlyContinue + } +} + +function Add-NativeWindowApi { + Add-Type @" +using System; +using System.Runtime.InteropServices; +public static class ISphereOfflineWindowApi { + [DllImport("user32.dll")] public static extern bool MoveWindow(IntPtr hWnd, int X, int Y, int nWidth, int nHeight, bool bRepaint); + [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); + [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd); + [DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags); +} +"@ -ErrorAction SilentlyContinue +} + +function Move-ClientWindows([object[]]$Windows, [int]$MainX, [int]$MainY, [int]$MainWidth, [int]$MainHeight, [bool]$MakeTopMost) { + $topMostHandle = [IntPtr]::new(-1) + $mainWindow = $null + $promptWindow = $null + foreach ($window in $Windows) { + if ([string]$window.process_name -ne "IMPlatformClient") { + continue + } + $hwndValue = [Convert]::ToInt64(($window.hwnd -replace "^0x", ""), 16) + $hwnd = [IntPtr]::new($hwndValue) + [ISphereOfflineWindowApi]::ShowWindow($hwnd, 9) | Out-Null + if ([string]$window.title -eq $InfoPromptTitle) { + [ISphereOfflineWindowApi]::MoveWindow($hwnd, ($MainX + $MainWidth + 40), ($MainY + 40), 300, 190, $true) | Out-Null + [ISphereOfflineWindowApi]::SetForegroundWindow($hwnd) | Out-Null + $promptWindow = $window + } + else { + if ($MakeTopMost) { + [ISphereOfflineWindowApi]::SetWindowPos($hwnd, $topMostHandle, $MainX, $MainY, $MainWidth, $MainHeight, 0x0040) | Out-Null + } + else { + [ISphereOfflineWindowApi]::MoveWindow($hwnd, $MainX, $MainY, $MainWidth, $MainHeight, $true) | Out-Null + } + [ISphereOfflineWindowApi]::SetForegroundWindow($hwnd) | Out-Null + $mainWindow = $window + } + } + return @{ + main = $mainWindow + prompt = $promptWindow + } +} + +function Save-WindowScreenshot([string]$OutputPath, [int]$ScreenX, [int]$ScreenY, [int]$CaptureWidth, [int]$CaptureHeight) { + Add-Type -AssemblyName System.Drawing + $bitmap = New-Object System.Drawing.Bitmap $CaptureWidth, $CaptureHeight + $graphics = [System.Drawing.Graphics]::FromImage($bitmap) + try { + $graphics.CopyFromScreen(($ScreenX - 30), ($ScreenY - 30), 0, 0, $bitmap.Size) + $bitmap.Save($OutputPath, [System.Drawing.Imaging.ImageFormat]::Png) + } + finally { + $graphics.Dispose() + $bitmap.Dispose() + } +} + +$archiveFull = Resolve-RepoPath $ArchivePath +$extractFull = Resolve-RepoPath $ExtractDir +$script:HelperPath = Resolve-RepoPath $HelperExe +$runOut = Join-Path $repo "runs\offline-real-client-window" +New-Item -ItemType Directory -Force -Path $runOut | Out-Null + +if (-not (Test-Path -LiteralPath $script:HelperPath)) { + throw "WinHelper not found: $script:HelperPath. Run scripts\build-win-helper.ps1 first." +} + +$clientDir = Join-Path $extractFull ("zyl\" + $ClientRoot) +$clientExe = Join-Path $clientDir "IMPlatformClient.exe" +$clientConfig = Join-Path $clientDir "IMPlatformClient.exe.config" +$requiredDependency = Join-Path $clientDir "Utilities.Lib.Base.dll" + +if (-not $SkipExtract -and (-not (Test-Path -LiteralPath $clientExe) -or -not (Test-Path -LiteralPath $clientConfig) -or -not (Test-Path -LiteralPath $requiredDependency))) { + if (-not (Test-Path -LiteralPath $archiveFull)) { + throw "archive not found: $archiveFull" + } + New-Item -ItemType Directory -Force -Path $extractFull | Out-Null + $sevenZip = Find-7Zip + & $sevenZip x $archiveFull "-o$extractFull" "zyl\Impp\*" "zyl\iSphere\*" -y | Out-Host + if ($LASTEXITCODE -ne 0) { + throw "7-Zip extraction failed with exit code $LASTEXITCODE" + } +} + +if (-not (Test-Path -LiteralPath $clientExe)) { + throw "client exe not found after extraction: $clientExe" +} + +Add-NativeWindowApi + +if (-not $KeepExisting) { + Stop-ExistingOfflineCopies -RootPath $extractFull + Start-Sleep -Milliseconds 500 +} + +$process = $null +if (-not $NoLaunch) { + $process = Start-Process -FilePath $clientExe -WorkingDirectory $clientDir -PassThru +} + +$deadline = (Get-Date).AddSeconds([Math]::Max(1, $WaitSeconds)) +$scan = $null +$windows = @() +do { + Start-Sleep -Milliseconds 500 + $scan = Invoke-HelperJson -Op "scan_windows" -OpArgs @{ include_all_visible = $false } -RequestId "offline-real-client-scan" + if ($scan.ok -and $scan.data.windows) { + $windows = @($scan.data.windows | Where-Object { + $_.process_name -eq "IMPlatformClient" -and + ((-not $process) -or $_.pid -eq $process.Id) + }) + } +} while ($windows.Count -eq 0 -and (Get-Date) -lt $deadline) + +if ($windows.Count -eq 0) { + throw "no visible IMPlatformClient window found. Last scan: $($scan | ConvertTo-Json -Depth 12 -Compress)" +} + +$moveResult = Move-ClientWindows -Windows $windows -MainX $X -MainY $Y -MainWidth $Width -MainHeight $Height -MakeTopMost ([bool]$TopMost) +Start-Sleep -Seconds 1 + +$scanAfterMove = Invoke-HelperJson -Op "scan_windows" -OpArgs @{ include_all_visible = $false } -RequestId "offline-real-client-scan-after-move" +$windowsAfterMove = @($scanAfterMove.data.windows | Where-Object { + $_.process_name -eq "IMPlatformClient" -and + ((-not $process) -or $_.pid -eq $process.Id) +}) + +# The offline client can raise a late "信息提示" dialog after the login window is +# already visible. Move it to the right side so the login window remains usable +# for RPA probing, then refresh the final window list. +$latePromptWindows = @($windowsAfterMove | Where-Object { [string]$_.title -eq $InfoPromptTitle }) +if ($latePromptWindows.Count -gt 0) { + foreach ($latePromptWindow in $latePromptWindows) { + $promptHwndValue = [Convert]::ToInt64(($latePromptWindow.hwnd -replace "^0x", ""), 16) + $promptHwnd = [IntPtr]::new($promptHwndValue) + [ISphereOfflineWindowApi]::ShowWindow($promptHwnd, 9) | Out-Null + [ISphereOfflineWindowApi]::MoveWindow($promptHwnd, ($X + $Width + 40), ($Y + 40), 300, 190, $true) | Out-Null + [ISphereOfflineWindowApi]::SetForegroundWindow($promptHwnd) | Out-Null + } + Start-Sleep -Milliseconds 500 + $scanAfterMove = Invoke-HelperJson -Op "scan_windows" -OpArgs @{ include_all_visible = $false } -RequestId "offline-real-client-scan-after-prompt-move" + $windowsAfterMove = @($scanAfterMove.data.windows | Where-Object { + $_.process_name -eq "IMPlatformClient" -and + ((-not $process) -or $_.pid -eq $process.Id) + }) +} + +$mainWindowMatches = @($windowsAfterMove | Where-Object { [string]$_.title -ne $InfoPromptTitle }) +$promptWindowMatches = @($windowsAfterMove | Where-Object { [string]$_.title -eq $InfoPromptTitle }) +$mainWindow = if ($mainWindowMatches.Count -gt 0) { $mainWindowMatches[0] } else { $null } +$promptWindow = if ($promptWindowMatches.Count -gt 0) { $promptWindowMatches[0] } else { $null } + +$dumpFile = $null +$dump = $null +if ($mainWindow) { + $dump = Invoke-HelperJson -Op "dump_uia" -OpArgs @{ + hwnd = $mainWindow.hwnd + max_depth = 8 + max_children = 200 + include_text = $true + } -RequestId "offline-real-client-dump-uia" + $dumpFile = Join-Path $runOut ("uia-dump-open-offline-real-client-{0}.json" -f ((Get-Date).ToString("yyyyMMdd-HHmmss"))) + $dump | ConvertTo-Json -Depth 40 | Set-Content -LiteralPath $dumpFile -Encoding UTF8 +} + +$screenshotFile = $null +if (-not $NoScreenshot) { + $screenshotFile = Join-Path $runOut ("open-offline-real-client-{0}.png" -f ((Get-Date).ToString("yyyyMMdd-HHmmss"))) + $captureWidth = if ($promptWindow) { $Width + 420 } else { $Width + 80 } + Save-WindowScreenshot -OutputPath $screenshotFile -ScreenX $X -ScreenY $Y -CaptureWidth $captureWidth -CaptureHeight ($Height + 80) +} + +[pscustomobject]@{ + ok = $true + archive = $archiveFull + extracted_dir = $extractFull + client_root = $ClientRoot + client_exe = $clientExe + process_id = if ($process) { $process.Id } else { $null } + alive = if ($process) { $null -ne (Get-Process -Id $process.Id -ErrorAction SilentlyContinue) } else { $null } + main_window = $mainWindow + prompt_window = $promptWindow + uia_root = if ($dump -and $dump.ok) { $dump.data.root } else { $null } + uia_dump_file = $dumpFile + screenshot_file = $screenshotFile + windows = $windowsAfterMove +} | ConvertTo-Json -Depth 24