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, [int]$WatchSeconds = 0, [int]$PollIntervalMs = 500, [ValidateSet("Visible", "Offscreen", "Minimized")] [string]$WindowMode = "Visible", [switch]$SkipExtract, [switch]$NoLaunch, [switch]$KeepExisting, [switch]$TopMost, [switch]$NoActivate, [switch]$UserSilent, [switch]$NoScreenshot ) $ErrorActionPreference = "Stop" $repo = Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..") $InfoPromptTitle = -join ([char[]](0x4FE1, 0x606F, 0x63D0, 0x793A)) if ($UserSilent) { $WindowMode = "Offscreen" $NoActivate = $true $NoScreenshot = $true $TopMost = $false } $effectiveX = $X $effectiveY = $Y if ($WindowMode -eq "Offscreen") { $effectiveX = -32000 $effectiveY = -32000 } 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 Start-OfflineClient { if ($script:UserSilentMode -or $script:WindowModeValue -ne "Visible" -or $script:NoActivateMode) { return Start-Process -FilePath $script:ClientExePath -WorkingDirectory $script:ClientDirPath -WindowStyle Minimized -PassThru } return Start-Process -FilePath $script:ClientExePath -WorkingDirectory $script:ClientDirPath -PassThru } function Relaunch-OfflineClient { Stop-ExistingOfflineCopies -RootPath $script:ExtractRootPath Start-Sleep -Milliseconds 500 return Start-OfflineClient } function Move-ClientWindows([object[]]$Windows, [int]$MainX, [int]$MainY, [int]$MainWidth, [int]$MainHeight, [bool]$MakeTopMost, [bool]$Activate, [string]$Mode) { $hwndTop = [IntPtr]::Zero $topMostHandle = [IntPtr]::new(-1) $swpShowWindow = 0x0040 $swpNoActivate = 0x0010 $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) { $promptX = if ($Mode -eq "Offscreen") { $MainX } else { $MainX + $MainWidth + 40 } $promptY = if ($Mode -eq "Offscreen") { $MainY + $MainHeight + 40 } else { $MainY + 40 } $flags = $swpShowWindow if (-not $Activate) { $flags = $flags -bor $swpNoActivate } [ISphereOfflineWindowApi]::SetWindowPos($hwnd, $hwndTop, $promptX, $promptY, 300, 190, [uint32]$flags) | Out-Null if ($Activate) { [ISphereOfflineWindowApi]::SetForegroundWindow($hwnd) | Out-Null } if ($Mode -eq "Minimized") { [ISphereOfflineWindowApi]::ShowWindow($hwnd, 6) | Out-Null } $promptWindow = $window } else { $insertAfter = if ($MakeTopMost) { $topMostHandle } else { $hwndTop } $flags = $swpShowWindow if (-not $Activate) { $flags = $flags -bor $swpNoActivate } [ISphereOfflineWindowApi]::SetWindowPos($hwnd, $insertAfter, $MainX, $MainY, $MainWidth, $MainHeight, [uint32]$flags) | Out-Null if ($Activate) { [ISphereOfflineWindowApi]::SetForegroundWindow($hwnd) | Out-Null } if ($Mode -eq "Minimized") { [ISphereOfflineWindowApi]::ShowWindow($hwnd, 6) | Out-Null } $mainWindow = $window } } return @{ main = $mainWindow prompt = $promptWindow } } function Get-ClientWindowsForProcess([object]$Process) { $scanResult = Invoke-HelperJson -Op "scan_windows" -OpArgs @{ include_all_visible = $false } -RequestId "offline-real-client-watch-scan" if (-not $scanResult.ok -or -not $scanResult.data.windows) { return @() } return @($scanResult.data.windows | Where-Object { $_.process_name -eq "IMPlatformClient" -and ((-not $Process) -or $_.pid -eq $Process.Id) }) } function Test-WindowNear([object]$Window, [int]$ExpectedX, [int]$ExpectedY, [int]$ExpectedWidth, [int]$ExpectedHeight) { if (-not $Window -or -not $Window.bounds) { return $false } $tolerance = 3 return ([Math]::Abs([int]$Window.bounds.x - $ExpectedX) -le $tolerance) -and ([Math]::Abs([int]$Window.bounds.y - $ExpectedY) -le $tolerance) -and ([Math]::Abs([int]$Window.bounds.width - $ExpectedWidth) -le $tolerance) -and ([Math]::Abs([int]$Window.bounds.height - $ExpectedHeight) -le $tolerance) } function Repair-ClientWindowPlacement([object[]]$Windows) { Move-ClientWindows -Windows $Windows -MainX $script:EffectiveX -MainY $script:EffectiveY -MainWidth $script:DesiredWidth -MainHeight $script:DesiredHeight -MakeTopMost ([bool]$script:TopMostMode) -Activate (-not [bool]$script:NoActivateMode) -Mode $script:WindowModeValue | Out-Null } 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 $script:ExtractRootPath = $extractFull $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" $script:ClientDirPath = $clientDir $script:ClientExePath = $clientExe $script:UserSilentMode = [bool]$UserSilent $script:NoActivateMode = [bool]$NoActivate $script:WindowModeValue = $WindowMode $script:EffectiveX = $effectiveX $script:EffectiveY = $effectiveY $script:DesiredWidth = $Width $script:DesiredHeight = $Height $script:TopMostMode = [bool]$TopMost 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-OfflineClient } $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)" } $activateWindows = -not [bool]$NoActivate $moveResult = Move-ClientWindows -Windows $windows -MainX $effectiveX -MainY $effectiveY -MainWidth $Width -MainHeight $Height -MakeTopMost ([bool]$TopMost) -Activate $activateWindows -Mode $WindowMode 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 $latePromptX = if ($WindowMode -eq "Offscreen") { $effectiveX } else { $effectiveX + $Width + 40 } $latePromptY = if ($WindowMode -eq "Offscreen") { $effectiveY + $Height + 40 } else { $effectiveY + 40 } $latePromptFlags = 0x0040 if (-not $activateWindows) { $latePromptFlags = $latePromptFlags -bor 0x0010 } [ISphereOfflineWindowApi]::SetWindowPos($promptHwnd, [IntPtr]::Zero, $latePromptX, $latePromptY, 300, 190, [uint32]$latePromptFlags) | Out-Null if ($activateWindows) { [ISphereOfflineWindowApi]::SetForegroundWindow($promptHwnd) | Out-Null } if ($WindowMode -eq "Minimized") { [ISphereOfflineWindowApi]::ShowWindow($promptHwnd, 6) | 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) }) } $watchIterationCount = 0 $repairCount = 0 $relaunchCount = 0 $recoveryActions = @() if ($WatchSeconds -gt 0) { $safePollIntervalMs = [Math]::Max(100, $PollIntervalMs) $watchDeadline = (Get-Date).AddSeconds($WatchSeconds) while ((Get-Date) -lt $watchDeadline) { Start-Sleep -Milliseconds $safePollIntervalMs $watchIterationCount++ $watchedWindows = Get-ClientWindowsForProcess -Process $process $watchedMain = @($watchedWindows | Where-Object { [string]$_.title -ne $InfoPromptTitle }) | Select-Object -First 1 $watchedPrompt = @($watchedWindows | Where-Object { [string]$_.title -eq $InfoPromptTitle }) | Select-Object -First 1 if (-not $watchedMain) { if (-not $NoLaunch) { $process = Relaunch-OfflineClient $relaunchCount++ $recoveryActions += "relaunch_missing_main_window" $relaunchDeadline = (Get-Date).AddSeconds([Math]::Max(1, $WaitSeconds)) do { Start-Sleep -Milliseconds $safePollIntervalMs $watchedWindows = Get-ClientWindowsForProcess -Process $process $watchedMain = @($watchedWindows | Where-Object { [string]$_.title -ne $InfoPromptTitle }) | Select-Object -First 1 } while (-not $watchedMain -and (Get-Date) -lt $relaunchDeadline) if ($watchedWindows.Count -gt 0) { Repair-ClientWindowPlacement -Windows $watchedWindows $repairCount++ $recoveryActions += "repair_after_relaunch" } } continue } $expectedPromptX = if ($WindowMode -eq "Offscreen") { $effectiveX } else { $effectiveX + $Width + 40 } $expectedPromptY = if ($WindowMode -eq "Offscreen") { $effectiveY + $Height + 40 } else { $effectiveY + 40 } $needsRepair = -not (Test-WindowNear -Window $watchedMain -ExpectedX $effectiveX -ExpectedY $effectiveY -ExpectedWidth $Width -ExpectedHeight $Height) if ($watchedPrompt) { $needsRepair = $needsRepair -or -not (Test-WindowNear -Window $watchedPrompt -ExpectedX $expectedPromptX -ExpectedY $expectedPromptY -ExpectedWidth 300 -ExpectedHeight 190) } if ($needsRepair) { Repair-ClientWindowPlacement -Windows $watchedWindows $repairCount++ $recoveryActions += "repair_window_placement" } } $scanAfterMove = Invoke-HelperJson -Op "scan_windows" -OpArgs @{ include_all_visible = $false } -RequestId "offline-real-client-final-scan-after-watch" $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 -and $WindowMode -eq "Visible") { $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 $effectiveX -ScreenY $effectiveY -CaptureWidth $captureWidth -CaptureHeight ($Height + 80) } [pscustomobject]@{ ok = $true user_silent = [bool]$UserSilent window_mode = $WindowMode no_activate = [bool]$NoActivate watch_seconds = $WatchSeconds poll_interval_ms = $PollIntervalMs watch_iteration_count = $watchIterationCount relaunch_count = $relaunchCount repair_count = $repairCount recovery_actions = $recoveryActions effective_bounds = @{ x = $effectiveX y = $effectiveY width = $Width height = $Height } 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