diff --git a/docs/source-discovery/2026-07-11-a-route-open-real-chat-window.md b/docs/source-discovery/2026-07-11-a-route-open-real-chat-window.md new file mode 100644 index 0000000..e71b825 --- /dev/null +++ b/docs/source-discovery/2026-07-11-a-route-open-real-chat-window.md @@ -0,0 +1,165 @@ +# A-route 离线打开真实 frmP2PChat 会话窗口 + +日期:2026-07-11 +分支:`codex/a-route-open-real-chat-window` +目标:不登录、不连服务端,在本机离线样本中打开真实 `IMPP.Client.Business.ChatManager.SingleChat.frmP2PChat` 窗口,供后续 RPA 对真实窗口做 UIA 控制。 + +## 结论 + +已经在未登录/离线环境下打开真实 `frmP2PChat`。 + +成功命令: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File scripts\open-offline-real-frmP2PChat.ps1 -RuntimeMode Deps -ShowWindow -HoldSeconds 1 +``` + +成功产物: + +- 结果 JSON:`E:\coding\codex\isphere-ai-bridge\runs\offline-chat-window\real-frmP2PChat-result-20260711-205312.json` +- HWND:`0xC0CB4` +- 截图:`E:\coding\codex\isphere-ai-bridge\runs\offline-chat-window\real-frmP2PChat-20260711-205312.png` +- UIA dump:`E:\coding\codex\isphere-ai-bridge\runs\offline-chat-window\uia-dump-real-frmP2PChat-20260711-205312.json` +- UIA root: + - `automation_id = frmP2PChat` + - `control_type = Window` + - `framework_id = WinForm` + - `visible = true` + +UIA dump 已确认可见关键控件: + +- `rtbRecvMessage` +- `rtbSendMessage` +- `btnSend` + +窗口内显示“您已处于离线状态,无法发送消息,请上线后再次尝试!”。这是预期状态;本轮目标不是发送消息,也不是上传文件,只是打开真实会话窗口。 + +## 新增脚本 + +### 1. 真实窗口打开脚本 + +`scripts/open-offline-real-frmP2PChat.ps1` + +关键能力: + +- 自动切到 32-bit PowerShell,避免 x86 客户端程序集 `BadImageFormatException`。 +- 只读加载真实客户端目录: + `runs/offline-real-client-window/full/zyl/Impp` +- 使用 `AssemblyResolve` 从真实客户端目录解析依赖。 +- 构造真实类型: + `IMPP.Client.Business.ChatManager.SingleChat.frmP2PChat` +- 支持不 Show 的构造验证,也支持 `-ShowWindow` 真实显示、截图和 UIA dump。 +- 捕获 WinForms `ThreadException`,输出 IL offset、堆栈和 JSON 证据,避免 .NET 异常弹窗卡死。 + +### 2. 构造函数 IL / NullRisk 提取脚本 + +`scripts/extract-frmP2PChat-il-risk.ps1` + +输出: + +- 完整构造函数 IL: + `runs/offline-chat-window/frmP2PChat-ctor.il.txt` +- `callvirt` / `ldfld` NullRisk CSV: + `runs/offline-chat-window/frmP2PChat-ctor-null-risk.csv` +- 风险摘要: + `runs/offline-chat-window/frmP2PChat-ctor-null-risk.md` + +提取结果: + +- 构造函数源 IL 行:`406271-406860` +- 构造函数行数:`590` +- `callvirt` / `ldfld` 风险项:`150` + +## 关键 IL 证据 + +构造函数签名: + +```text +frmP2PChat(com.vision.smack.Chat chat, string pluginInfo, string extendTabJson) +``` + +高信号 NullRisk: + +| IL | 证据 | 需要补的运行态 | +|---|---|---| +| `IL_00f6` | `XMPPConnection::add_OnConnectError` | `IMPPManager.Instance.Connection` / backing field | +| `IL_0111` | `XMPPConnection::add_OnReConnectOk` | 同上 | +| `IL_013b` / `IL_0140` | `P2PChat::get_OtherJid()` / `Jid::getBareJid()` | `P2PChat.OtherJid` | +| `IL_0156` | `IMPPManager::get_UserInfo()` | `IMPPManager._userInfo` | +| `IL_0575` | `MessageCenter::add_OnTcpMessageArrived` | `IMPPManager.MessageCenter` | +| `IL_0614` | `RosterManager::UpdateStrangerStatus` | `IMPPManager.RosterManager` | +| `IL_0631` | `BaseConfig::VisualPhoneEnable()` | `Config.BaseConfig` | + +Show 阶段新增证据: + +| IL | 证据 | 处理 | +|---|---|---| +| `frmP2PChat_Load IL_004d/0058` | `Config::get_BaseConfig()` 后 `BaseConfig::RemoteControlVisible()` | 补 `Config.k__BackingField` | +| `frmChatBase_Activated IL_0000/0005` | `IMPPManager.get_Instance().get_frmMain().BringModalFormToFront()` | 补 `IMPPManager.k__BackingField` | +| `frmP2PChat_Load IL_137f` | `UserInfoManager.getUserInfo(otherBareJid,true)` 后使用 `UserInfo::get_CompanyID()` | 构造完成后预热 `UserInfoCache` | + +注意:`UserInfoCache` 不能在 `frmP2PChat` 构造前预热。过早预热会让构造函数进入 `UCUserInformation.LoadUserInfo(UserInfo)`,在 `UCUserInformation::LoadUserInfo IL_0085` 触发新的 NRE。当前脚本是在真实窗体构造完成后、`Show()` 前预热缓存。 + +## 最小 fake runtime 清单 + +当前成功路径最小补齐如下: + +1. `IMPPManager.Instance` + - `k__BackingField` + - `k__BackingField` + - `LogonUser` + - `_userInfo` + - `_rosterUserInfoList` +2. `SmarkManager` + - `k__BackingField` +3. `XMPPConnection` + - `Jid` + - `UserName` + - `LoginState` + - `CompanyID` + - `Resource` + - `_packetListeners` + - `_packetCollectors` +4. `IMPPManager` inherited manager fields/properties + - `Connection` and `k__BackingField` + - `IsConnected` and `k__BackingField` + - `PresenceManager` and backing field + - `P2PChatManager` and backing field + - `RosterManager` and backing field +5. Config + - `ConfigSystemManager.config` + - `Config.k__BackingField` + - `Config.k__BackingField` + - `Config.k__BackingField` +6. `UserInfoCache` + - 在 `frmP2PChat` 构造后、`Show()` 前加入自己和对端的 JID/ID 映射。 +7. Chat 对象 + - 真实 `com.vision.smack.P2PChat` + - `OtherJid = codex-probe@offline.local` + +## 验证命令 + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File scripts\test-extract-frmP2PChat-il-risk.ps1 +powershell -NoProfile -ExecutionPolicy Bypass -File scripts\extract-frmP2PChat-il-risk.ps1 -OutDir runs/offline-chat-window +powershell -NoProfile -ExecutionPolicy Bypass -File scripts\test-open-offline-real-frmP2PChat-contract.ps1 +powershell -NoProfile -ExecutionPolicy Bypass -File scripts\open-offline-real-frmP2PChat.ps1 -RuntimeMode Deps +powershell -NoProfile -ExecutionPolicy Bypass -File scripts\open-offline-real-frmP2PChat.ps1 -RuntimeMode Deps -ShowWindow -HoldSeconds 1 +``` + +## 当前边界 + +- 已成功打开真实 `frmP2PChat`。 +- UIA 可 dump 到真实 `frmP2PChat`、`rtbRecvMessage`、`rtbSendMessage`、`btnSend`。 +- 离线环境下窗口会提示无法发送消息,这是客户端真实离线状态表现,不影响后续 RPA 窗口定位。 +- 本轮没有处理真实发送、真实上传、真实登录态。 +- `btnSendFile` 在这次真实离线 UIA dump 中没有以 `btnSendFile` automation id 暴露;后续如果 RPA 需要上传文件,需要另开节点定位真实工具栏按钮或菜单路径。 + +## 下一轮建议 + +1. 把 A-route RPA 的窗口定位目标从 synthetic probe 切到真实 `frmP2PChat`: + - root `automation_id=frmP2PChat` + - send box `automation_id=rtbSendMessage` + - send button `automation_id=btnSend` +2. 如果弹出“企业信息获取失败”影响自动化,单独加一个非阻塞关闭弹窗步骤。 +3. 如果后续需要文件按钮,再专门分析真实工具栏图标按钮、菜单项和 `ToolStrip` 命中路径。 diff --git a/scripts/extract-frmP2PChat-il-risk.ps1 b/scripts/extract-frmP2PChat-il-risk.ps1 new file mode 100644 index 0000000..5f97606 --- /dev/null +++ b/scripts/extract-frmP2PChat-il-risk.ps1 @@ -0,0 +1,118 @@ +param( + [string]$IlFile = "runs/offline-evidence-intake/zyl-qqfile-20260709/metadata/c28-il/IMPlatformClient.exe.il", + [string]$OutDir = "runs/offline-chat-window" +) + +$ErrorActionPreference = "Stop" +$repo = Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..") + +function Resolve-RepoPath([string]$PathValue) { + if ([System.IO.Path]::IsPathRooted($PathValue)) { + return $PathValue + } + return (Join-Path $repo $PathValue) +} + +$ilFull = Resolve-RepoPath $IlFile +$outFull = Resolve-RepoPath $OutDir +if (-not (Test-Path -LiteralPath $ilFull)) { + throw "IL file not found: $ilFull" +} +New-Item -ItemType Directory -Force -Path $outFull | Out-Null + +$lines = Get-Content -LiteralPath $ilFull +$endIndex = $null +for ($i = 0; $i -lt $lines.Count; $i++) { + if ($lines[$i] -match 'end of method frmP2PChat::\.ctor') { + $endIndex = $i + break + } +} +if ($null -eq $endIndex) { + throw "frmP2PChat::.ctor end marker not found" +} + +$startIndex = $null +for ($i = $endIndex; $i -ge 0; $i--) { + if ($lines[$i] -match '^\s*\.method\s+public\s+hidebysig\s+specialname\s+rtspecialname') { + $startIndex = $i + break + } +} +if ($null -eq $startIndex) { + throw "frmP2PChat::.ctor method start not found before line $($endIndex + 1)" +} + +$ctorIlFile = Join-Path $outFull "frmP2PChat-ctor.il.txt" +$riskCsvFile = Join-Path $outFull "frmP2PChat-ctor-null-risk.csv" +$riskMdFile = Join-Path $outFull "frmP2PChat-ctor-null-risk.md" + +$ctorLines = New-Object System.Collections.Generic.List[string] +for ($i = $startIndex; $i -le $endIndex; $i++) { + $ctorLines.Add(("{0}: {1}" -f ($i + 1), $lines[$i])) +} +$ctorLines | Set-Content -LiteralPath $ctorIlFile -Encoding UTF8 + +$riskRows = New-Object System.Collections.Generic.List[object] +foreach ($entry in $ctorLines) { + if ($entry -notmatch '\b(callvirt|ldfld)\b') { + continue + } + $offset = $null + if ($entry -match '(IL_[0-9a-fA-F]{4})') { + $offset = $matches[1].ToLowerInvariant().Replace("il_", "IL_") + } + $op = if ($entry -match '\b(callvirt|ldfld)\b') { $matches[1] } else { $null } + $target = ($entry -replace '^.*?\b(callvirt|ldfld)\b\s+', '').Trim() + $riskRows.Add([pscustomobject]@{ + source_line = [int]($entry -replace '^(\d+):.*$', '$1') + il_offset = $offset + opcode = $op + target = $target + null_risk = if ($op -eq "callvirt") { "receiver_null_possible" } else { "instance_field_owner_null_possible" } + evidence = $entry.Trim() + }) +} + +$riskRows | Sort-Object source_line | Export-Csv -LiteralPath $riskCsvFile -NoTypeInformation -Encoding UTF8 + +$topEvidence = $riskRows | + Where-Object { + $_.target -match 'Connection::add_OnConnectError|Connection::add_OnReConnectOk|P2PChat::get_OtherJid|Jid::getBareJid|IMPPManager::get_UserInfo|UCChatSendMessageBox::GetChatRichTextBox|UCChatSendMessageBox::GetReceiptCheckBox|MessageCenter::add_OnTcpMessageArrived|RosterManager::UpdateStrangerStatus|BaseConfig::VisualPhoneEnable' + } | + Select-Object -First 40 + +$md = New-Object System.Collections.Generic.List[string] +$md.Add("# frmP2PChat constructor IL NullRisk") +$md.Add("") +$md.Add("- Source IL: $ilFull") +$md.Add("- Constructor source lines: $($startIndex + 1)-$($endIndex + 1)") +$md.Add("- Constructor line count: $($ctorLines.Count)") +$md.Add("- callvirt / ldfld risk rows: $($riskRows.Count)") +$md.Add("") +$md.Add("## High-signal risks") +$md.Add("") +$md.Add("| source_line | il_offset | opcode | target |") +$md.Add("|---:|---|---|---|") +foreach ($row in $topEvidence) { + $target = ($row.target -replace '\|', '\\|') + $md.Add("| $($row.source_line) | $($row.il_offset) | $($row.opcode) | $target |") +} +$md.Add("") +$md.Add("## Files") +$md.Add("") +$md.Add("- Full constructor IL: $ctorIlFile") +$md.Add("- Full NullRisk CSV: $riskCsvFile") +$md | Set-Content -LiteralPath $riskMdFile -Encoding UTF8 + +[pscustomobject]@{ + ok = $true + il_file = $ilFull + ctor_line_start = $startIndex + 1 + ctor_line_end = $endIndex + 1 + ctor_line_count = $ctorLines.Count + risk_count = $riskRows.Count + ctor_il_file = $ctorIlFile + risk_csv_file = $riskCsvFile + risk_md_file = $riskMdFile +} | ConvertTo-Json -Compress diff --git a/scripts/open-offline-real-frmP2PChat.ps1 b/scripts/open-offline-real-frmP2PChat.ps1 new file mode 100644 index 0000000..29c46d7 --- /dev/null +++ b/scripts/open-offline-real-frmP2PChat.ps1 @@ -0,0 +1,545 @@ +param( + [string]$ClientDir = "runs/offline-real-client-window/full/zyl/Impp", + [string]$OutDir = "runs/offline-chat-window", + [ValidateSet("Minimal", "Deps")] + [string]$RuntimeMode = "Deps", + [switch]$SelfChat, + [switch]$SkipRosterManager, + [switch]$ShowWindow, + [string]$HelperExe = "runs/win-helper/ISphereWinHelper.exe", + [switch]$NoScreenshot, + [int]$HoldSeconds = 8, + [int]$X = 180, + [int]$Y = 180, + [int]$Width = 900, + [int]$Height = 680 +) + +$ErrorActionPreference = "Stop" + +if ([Environment]::Is64BitProcess) { + $x86PowerShell = Join-Path $env:WINDIR "SysWOW64\WindowsPowerShell\v1.0\powershell.exe" + if (-not (Test-Path -LiteralPath $x86PowerShell)) { + throw "32-bit PowerShell not found: $x86PowerShell" + } + + $relayArgs = @("-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $PSCommandPath) + foreach ($entry in $PSBoundParameters.GetEnumerator()) { + $name = [string]$entry.Key + $value = $entry.Value + if ($value -is [System.Management.Automation.SwitchParameter]) { + if ($value.IsPresent) { + $relayArgs += "-$name" + } + } + else { + $relayArgs += "-$name" + $relayArgs += [string]$value + } + } + + & $x86PowerShell @relayArgs + exit $LASTEXITCODE +} + +$repo = Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..") + +function Resolve-RepoPath([string]$PathValue) { + if ([System.IO.Path]::IsPathRooted($PathValue)) { + return $PathValue + } + return (Join-Path $repo $PathValue) +} + +function Get-TypeMemberInHierarchy([Type]$Type, [string]$Name, [string]$Kind) { + $flags = [System.Reflection.BindingFlags]"Public,NonPublic,Instance,Static" + $cursor = $Type + while ($null -ne $cursor) { + if ($Kind -eq "Property") { + $member = $cursor.GetProperty($Name, $flags) + } + else { + $member = $cursor.GetField($Name, $flags) + } + if ($member) { + return $member + } + $cursor = $cursor.BaseType + } + return $null +} + +function Set-PropInHierarchy([object]$Object, [Type]$Type, [string]$Name, [object]$Value) { + $prop = Get-TypeMemberInHierarchy -Type $Type -Name $Name -Kind "Property" + if ($prop -and $prop.CanWrite) { + $target = if ($prop.GetSetMethod($true).IsStatic) { $null } else { $Object } + $prop.SetValue($target, $Value, $null) + return $true + } + return $false +} + +function Set-FieldInHierarchy([object]$Object, [Type]$Type, [string]$Name, [object]$Value) { + $field = Get-TypeMemberInHierarchy -Type $Type -Name $Name -Kind "Field" + if ($field) { + $target = if ($field.IsStatic) { $null } else { $Object } + $field.SetValue($target, $Value) + return $true + } + return $false +} + +function New-ByConstructor([Type]$Type, [Type[]]$ParameterTypes, [object[]]$Arguments) { + $ctor = $Type.GetConstructor($ParameterTypes) + if (-not $ctor) { + $sig = ($ParameterTypes | ForEach-Object { $_.FullName }) -join ", " + throw "constructor not found: $($Type.FullName)($sig)" + } + return $ctor.Invoke($Arguments) +} + +function Convert-ExceptionToRecord([Exception]$Exception) { + $records = @() + $cursor = $Exception + $depth = 0 + while ($cursor) { + $trace = New-Object System.Diagnostics.StackTrace($cursor, $true) + $frames = @() + for ($i = 0; $i -lt $trace.FrameCount; $i++) { + $frame = $trace.GetFrame($i) + $method = $frame.GetMethod() + $offset = $frame.GetILOffset() + $frames += [pscustomobject]@{ + index = $i + method_type = if ($method.DeclaringType) { $method.DeclaringType.FullName } else { $null } + method_name = $method.Name + il_offset = $offset + il_offset_hex = if ($offset -ge 0) { "IL_{0:x4}" -f $offset } else { $null } + native_offset = $frame.GetNativeOffset() + file = $frame.GetFileName() + line = $frame.GetFileLineNumber() + } + } + + $records += [pscustomobject]@{ + depth = $depth + type = $cursor.GetType().FullName + message = $cursor.Message + target_site = if ($cursor.TargetSite) { "$($cursor.TargetSite.DeclaringType.FullName)::$($cursor.TargetSite.Name)" } else { $null } + frames = $frames + } + $cursor = $cursor.InnerException + $depth++ + } + return $records +} + +function Get-FirstRelevantFrame([object[]]$ExceptionRecords) { + foreach ($record in $ExceptionRecords) { + foreach ($frame in $record.frames) { + if ($frame.method_type -and + $frame.method_type -notlike "System.*" -and + $frame.method_type -notlike "Microsoft.*" -and + $frame.method_type -notlike "MS.*") { + return $frame + } + } + } + return $null +} + +function Find-IlLine([string]$OutDirFull, [string]$IlOffsetHex) { + if ([string]::IsNullOrWhiteSpace($IlOffsetHex)) { + return $null + } + $ctorIl = Join-Path $OutDirFull "frmP2PChat-ctor.il.txt" + if (-not (Test-Path -LiteralPath $ctorIl)) { + return $null + } + $match = Select-String -LiteralPath $ctorIl -Pattern $IlOffsetHex -SimpleMatch | Select-Object -First 1 + if ($match) { + return @{ + path = $ctorIl + line_number = $match.LineNumber + text = $match.Line.Trim() + } + } + return $null +} + +function Invoke-HelperJson([string]$HelperPath, [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 | & $HelperPath --json + try { + return $output | ConvertFrom-Json + } + catch { + throw "helper output was not JSON: $output" + } +} + +function Save-WindowScreenshot([string]$OutputPath, [int]$ScreenX, [int]$ScreenY, [int]$CaptureWidth, [int]$CaptureHeight) { + $bitmap = New-Object System.Drawing.Bitmap $CaptureWidth, $CaptureHeight + $graphics = [System.Drawing.Graphics]::FromImage($bitmap) + try { + $graphics.CopyFromScreen($ScreenX, $ScreenY, 0, 0, $bitmap.Size) + $bitmap.Save($OutputPath, [System.Drawing.Imaging.ImageFormat]::Png) + } + finally { + $graphics.Dispose() + $bitmap.Dispose() + } +} + +function New-UserInfo([Type]$UserInfoType, [string]$Id, [string]$Jid, [string]$Name, [string]$CompanyID) { + $user = [Activator]::CreateInstance($UserInfoType) + foreach ($kv in @( + @("Id", $Id), + @("Jid", $Jid), + @("LoginName", $Id), + @("Domain", "offline.local"), + @("Name", $Name), + @("CompanyID", $CompanyID), + @("DeptID", "codex-dept"), + @("DeptName", "Codex Dept"), + @("Enable", "1"), + @("chatAble", "1"), + @("Mobile", ""), + @("Tel", ""), + @("Email", "") + )) { + Set-PropInHierarchy -Object $user -Type $UserInfoType -Name $kv[0] -Value $kv[1] | Out-Null + } + return $user +} + +$clientFull = Resolve-RepoPath $ClientDir +$outFull = Resolve-RepoPath $OutDir +$helperFull = Resolve-RepoPath $HelperExe +New-Item -ItemType Directory -Force -Path $outFull | Out-Null + +if (-not (Test-Path -LiteralPath (Join-Path $clientFull "IMPlatformClient.exe"))) { + throw "IMPlatformClient.exe not found under $clientFull" +} + +Set-Location -LiteralPath $clientFull + +[AppDomain]::CurrentDomain.add_AssemblyResolve({ + param($sender, $eventArgs) + $assemblyName = New-Object System.Reflection.AssemblyName($eventArgs.Name) + foreach ($extension in @(".dll", ".exe")) { + $candidate = Join-Path $script:ClientDirectoryForResolve ($assemblyName.Name + $extension) + if (Test-Path -LiteralPath $candidate) { + return [System.Reflection.Assembly]::LoadFrom($candidate) + } + } + return $null +}) +$script:ClientDirectoryForResolve = $clientFull + +$timestamp = Get-Date -Format "yyyyMMdd-HHmmss" +$resultFile = Join-Path $outFull ("real-frmP2PChat-result-{0}.json" -f $timestamp) + +try { + Add-Type -AssemblyName System.Windows.Forms + Add-Type -AssemblyName System.Drawing + $script:RealFrmP2PThreadExceptions = New-Object System.Collections.ArrayList + [System.Windows.Forms.Application]::SetUnhandledExceptionMode([System.Windows.Forms.UnhandledExceptionMode]::CatchException) + [System.Windows.Forms.Application]::add_ThreadException({ + param($sender, $eventArgs) + [void]$script:RealFrmP2PThreadExceptions.Add($eventArgs.Exception) + }) + + $asm = [System.Reflection.Assembly]::LoadFrom((Join-Path $clientFull "IMPlatformClient.exe")) + $smack = [System.Reflection.Assembly]::LoadFrom((Join-Path $clientFull "smack.dll")) + $common = [System.Reflection.Assembly]::LoadFrom((Join-Path $clientFull "IMPP.Common.dll")) + + $mgrType = $asm.GetType("IMPP.Client.IMPPManager", $true) + $mcType = $asm.GetType("IMPP.Client.control.MessageCenter", $true) + $configType = $asm.GetType("IMPP.Client.Config", $true) + $baseConfigType = $asm.GetType("IMPP.Client.BaseConfig", $true) + $configMgrType = $asm.GetType("IMPP.Client.ConfigSystemManager", $true) + $configModelType = $asm.GetType("IMPP.Client.ConfigSystemModel", $true) + $userInfoCacheType = $asm.GetType("IMPP.Client.Framework.Configuration.UserInfoCache", $true) + $loginInfoType = $asm.GetType("IMPP.Client.LoginInfo", $true) + $p2pFormType = $asm.GetType("IMPP.Client.Business.ChatManager.SingleChat.frmP2PChat", $true) + $chatBaseType = $asm.GetType("IMPP.Client.Forms.frmChatBase", $true) + $frmMainType = $asm.GetType("IMPP.Client.frmMain", $true) + $connType = $smack.GetType("com.vision.smack.XMPPConnection", $true) + $jidType = $smack.GetType("com.vision.smack.Jid", $true) + $chatType = $smack.GetType("com.vision.smack.Chat", $true) + $p2pChatType = $smack.GetType("com.vision.smack.P2PChat", $true) + $rosterType = $smack.GetType("com.vision.smack.RosterManager", $true) + $presenceType = $smack.GetType("com.vision.smack.PresenceManager", $true) + $p2pMgrType = $smack.GetType("com.vision.smack.P2PChatManager", $true) + $bookMarkType = $smack.GetType("com.vision.smack.muc.BookMarkManager", $true) + $packetCollectorType = $smack.GetType("com.vision.smack.PacketCollector", $true) + $userInfoType = $common.GetType("IMPP.Common.UserInfo", $true) + + $mgr = $mgrType.GetProperty("Instance", [System.Reflection.BindingFlags]"Public,Static").GetValue($null, $null) + + $fakeMessageCenter = [System.Runtime.Serialization.FormatterServices]::GetUninitializedObject($mcType) + Set-FieldInHierarchy -Object $mgr -Type $mgrType -Name "k__BackingField" -Value $fakeMessageCenter | Out-Null + $fakeFrmMain = [System.Runtime.Serialization.FormatterServices]::GetUninitializedObject($frmMainType) + Set-FieldInHierarchy -Object $mgr -Type $mgrType -Name "k__BackingField" -Value $fakeFrmMain | Out-Null + + $login = [Activator]::CreateInstance($loginInfoType) + Set-PropInHierarchy -Object $login -Type $loginInfoType -Name "Trait" -Value "codex_probe" | Out-Null + Set-PropInHierarchy -Object $login -Type $loginInfoType -Name "TrueTraid" -Value "codex_probe" | Out-Null + Set-PropInHierarchy -Object $login -Type $loginInfoType -Name "Status" -Value "online" | Out-Null + Set-PropInHierarchy -Object $mgr -Type $mgrType -Name "LogonUser" -Value $login | Out-Null + + $companyId = "codex-company" + $otherBareJid = "codex-probe@offline.local" + $myBareJid = if ($SelfChat) { $otherBareJid } else { "codex-user@offline.local" } + $myName = if ($SelfChat) { "Codex Probe" } else { "Codex User" } + + $jidCtor3 = $jidType.GetConstructor(@([string], [string], [string])) + $myJid = $jidCtor3.Invoke(@(($myBareJid -split "@")[0], "offline.local", "pc")) + + $fakeConn = [System.Runtime.Serialization.FormatterServices]::GetUninitializedObject($connType) + Set-PropInHierarchy -Object $fakeConn -Type $connType -Name "Jid" -Value $myJid | Out-Null + Set-PropInHierarchy -Object $fakeConn -Type $connType -Name "UserName" -Value (($myBareJid -split "@")[0]) | Out-Null + Set-PropInHierarchy -Object $fakeConn -Type $connType -Name "LoginState" -Value "online" | Out-Null + Set-PropInHierarchy -Object $fakeConn -Type $connType -Name "CompanyID" -Value $companyId | Out-Null + Set-PropInHierarchy -Object $fakeConn -Type $connType -Name "Resource" -Value "pc" | Out-Null + Set-FieldInHierarchy -Object $fakeConn -Type $connType -Name "_packetListeners" -Value (New-Object System.Collections.ArrayList) | Out-Null + $packetCollectorListType = [System.Collections.Generic.List``1].MakeGenericType($packetCollectorType) + Set-FieldInHierarchy -Object $fakeConn -Type $connType -Name "_packetCollectors" -Value ([Activator]::CreateInstance($packetCollectorListType)) | Out-Null + + Set-PropInHierarchy -Object $mgr -Type $mgrType -Name "Connection" -Value $fakeConn | Out-Null + Set-FieldInHierarchy -Object $mgr -Type $mgrType -Name "k__BackingField" -Value $fakeConn | Out-Null + Set-PropInHierarchy -Object $mgr -Type $mgrType -Name "IsConnected" -Value $true | Out-Null + Set-FieldInHierarchy -Object $mgr -Type $mgrType -Name "k__BackingField" -Value $true | Out-Null + Set-FieldInHierarchy -Object $null -Type ($smack.GetType("com.vision.smack.SmarkManager", $true)) -Name "k__BackingField" -Value $mgr | Out-Null + + if ($RuntimeMode -eq "Deps") { + $myUser = New-UserInfo -UserInfoType $userInfoType -Id (($myBareJid -split "@")[0]) -Jid $myBareJid -Name $myName -CompanyID $companyId + $otherUser = New-UserInfo -UserInfoType $userInfoType -Id "codex-probe" -Jid $otherBareJid -Name "Codex Probe" -CompanyID $companyId + Set-FieldInHierarchy -Object $mgr -Type $mgrType -Name "_userInfo" -Value $myUser | Out-Null + + $stringDictType = [System.Collections.Generic.Dictionary``2].MakeGenericType([string], [string]) + $baseConfigDict = [Activator]::CreateInstance($stringDictType, [StringComparer]::OrdinalIgnoreCase) + foreach ($kv in @( + @("Remote_Control_Visible", "false"), + @("VisualPhone_enable", "false"), + @("showacctno", "no"), + @("ShowDepartment", "NO"), + @("StrucPPLNo", "NO"), + @("MaintenanceAssistant", ""), + @("CustomerServiceSuffix", ""), + @("CustomerCN", ""), + @("CustomerPhone", "") + )) { + $baseConfigDict[$kv[0]] = $kv[1] + $baseConfigDict[$kv[0].ToLowerInvariant()] = $kv[1] + } + $baseConfigRawDict = $baseConfigDict.PSObject.BaseObject + $baseConfigCtor = $baseConfigType.GetConstructor(@($baseConfigRawDict.GetType())) + if (-not $baseConfigCtor) { + throw "constructor not found: $($baseConfigType.FullName)($($baseConfigRawDict.GetType().FullName))" + } + $baseConfig = $baseConfigCtor.Invoke([object[]]@($baseConfigRawDict)) + Set-FieldInHierarchy -Object $null -Type $configType -Name "k__BackingField" -Value $baseConfig | Out-Null + Set-FieldInHierarchy -Object $null -Type $configType -Name "k__BackingField" -Value $myUser | Out-Null + Set-FieldInHierarchy -Object $null -Type $configType -Name "k__BackingField" -Value $login | Out-Null + + $userListType = [System.Collections.Generic.List``1].MakeGenericType($userInfoType) + $rosterUsers = [Activator]::CreateInstance($userListType) + $rosterUsers.Add($myUser) | Out-Null + $rosterUsers.Add($otherUser) | Out-Null + Set-FieldInHierarchy -Object $mgr -Type $mgrType -Name "_rosterUserInfoList" -Value $rosterUsers | Out-Null + + $cfg = [Activator]::CreateInstance($configModelType) + foreach ($kv in @( + @("Font", "Microsoft YaHei UI"), + @("Size", [single]9), + @("FontStyle", "Regular"), + @("Color", "Black"), + @("SendMsgKeyPress", "Enter"), + @("SendFilePath", $outFull), + @("SendFileSize", 10), + @("SendFileConf", $false), + @("AllEnable", $true) + )) { + Set-PropInHierarchy -Object $cfg -Type $configModelType -Name $kv[0] -Value $kv[1] | Out-Null + } + $configMgrType.GetField("config", [System.Reflection.BindingFlags]"NonPublic,Static").SetValue($null, $cfg) + + $presence = New-ByConstructor -Type $presenceType -ParameterTypes @($connType) -Arguments @($fakeConn) + Set-PropInHierarchy -Object $mgr -Type $mgrType -Name "PresenceManager" -Value $presence | Out-Null + Set-FieldInHierarchy -Object $mgr -Type $mgrType -Name "k__BackingField" -Value $presence | Out-Null + + $bookmark = [System.Runtime.Serialization.FormatterServices]::GetUninitializedObject($bookMarkType) + $p2pManager = New-ByConstructor -Type $p2pMgrType -ParameterTypes @($connType, $bookMarkType) -Arguments @($fakeConn, $bookmark) + Set-PropInHierarchy -Object $mgr -Type $mgrType -Name "P2PChatManager" -Value $p2pManager | Out-Null + Set-FieldInHierarchy -Object $mgr -Type $mgrType -Name "k__BackingField" -Value $p2pManager | Out-Null + + if (-not $SkipRosterManager) { + $roster = New-ByConstructor -Type $rosterType -ParameterTypes @($connType) -Arguments @($fakeConn) + Set-PropInHierarchy -Object $mgr -Type $mgrType -Name "RosterManager" -Value $roster | Out-Null + Set-FieldInHierarchy -Object $mgr -Type $mgrType -Name "k__BackingField" -Value $roster | Out-Null + } + else { + Set-PropInHierarchy -Object $mgr -Type $mgrType -Name "RosterManager" -Value $null | Out-Null + Set-FieldInHierarchy -Object $mgr -Type $mgrType -Name "k__BackingField" -Value $null | Out-Null + } + } + + $p2pChat = New-ByConstructor -Type $p2pChatType -ParameterTypes @($connType, [string]) -Arguments @($fakeConn, $otherBareJid) + $otherJid = $p2pChatType.GetProperty("OtherJid").GetValue($p2pChat, $null) + if ($null -eq $otherJid) { + $otherJid = $jidCtor3.Invoke(@("codex-probe", "offline.local", "")) + Set-FieldInHierarchy -Object $p2pChat -Type $p2pChatType -Name "k__BackingField" -Value $otherJid | Out-Null + } + + $formCtor = $p2pFormType.GetConstructor(@($chatType, [string], [string])) + $form = $formCtor.Invoke(@($p2pChat, "", "")) + if ($RuntimeMode -eq "Deps") { + $userInfoCache = $userInfoCacheType.GetMethod("GetInstance", [System.Reflection.BindingFlags]"Public,Static").Invoke($null, @()) + $addUserInfoCache = $userInfoCacheType.GetMethod("AddCache", [Type[]]@([string], $userInfoType)) + foreach ($cacheEntry in @( + @{ Key = $myBareJid; User = $myUser }, + @{ Key = $myBareJid.ToLowerInvariant(); User = $myUser }, + @{ Key = (($myBareJid -split "@")[0]); User = $myUser }, + @{ Key = $otherBareJid; User = $otherUser }, + @{ Key = $otherBareJid.ToLowerInvariant(); User = $otherUser }, + @{ Key = "codex-probe"; User = $otherUser } + )) { + $addUserInfoCache.Invoke($userInfoCache, [object[]]@([string]$cacheEntry.Key, $cacheEntry.User.PSObject.BaseObject)) + } + } + $form.Name = "frmP2PChat" + $form.Text = "Codex Real frmP2PChat Offline Probe" + $form.StartPosition = [System.Windows.Forms.FormStartPosition]::Manual + $form.Left = $X + $form.Top = $Y + $form.Width = $Width + $form.Height = $Height + + $uiaDumpFile = $null + $uiaRoot = $null + $screenshotFile = $null + + if ($ShowWindow) { + $form.Show() + [System.Windows.Forms.Application]::DoEvents() + Start-Sleep -Milliseconds 800 + [System.Windows.Forms.Application]::DoEvents() + + if ($script:RealFrmP2PThreadExceptions.Count -gt 0) { + $threadExceptionRecords = @() + foreach ($threadException in $script:RealFrmP2PThreadExceptions) { + $threadExceptionRecords += Convert-ExceptionToRecord -Exception $threadException + } + $relevantFrame = Get-FirstRelevantFrame -ExceptionRecords $threadExceptionRecords + $threadResult = [pscustomobject]@{ + ok = $false + stage = "show_thread_exception" + process_bitness = 32 + runtime_mode = $RuntimeMode + self_chat = [bool]$SelfChat + skip_roster_manager = [bool]$SkipRosterManager + client_dir = $clientFull + type = $p2pFormType.FullName + hwnd = if ($form.IsHandleCreated) { "0x" + $form.Handle.ToInt64().ToString("X") } else { $null } + visible = $form.Visible + relevant_frame = $relevantFrame + exceptions = $threadExceptionRecords + result_file = $resultFile + } + $threadResult | ConvertTo-Json -Depth 32 | Set-Content -LiteralPath $resultFile -Encoding UTF8 + $threadResult | ConvertTo-Json -Depth 32 -Compress + try { $form.Close() } catch {} + exit 3 + } + + if ($form.IsHandleCreated -and (Test-Path -LiteralPath $helperFull)) { + $hwndForDump = "0x" + $form.Handle.ToInt64().ToString("X") + $dump = Invoke-HelperJson -HelperPath $helperFull -Op "dump_uia" -OpArgs @{ + hwnd = $hwndForDump + max_depth = 8 + max_children = 250 + include_text = $true + } -RequestId "real-frmP2PChat-dump-uia" + $uiaDumpFile = Join-Path $outFull ("uia-dump-real-frmP2PChat-{0}.json" -f $timestamp) + $dump | ConvertTo-Json -Depth 40 | Set-Content -LiteralPath $uiaDumpFile -Encoding UTF8 + if ($dump.ok) { + $uiaRoot = $dump.data.root + } + } + + if (-not $NoScreenshot) { + $screenshotFile = Join-Path $outFull ("real-frmP2PChat-{0}.png" -f $timestamp) + Save-WindowScreenshot -OutputPath $screenshotFile -ScreenX $form.Left -ScreenY $form.Top -CaptureWidth $form.Width -CaptureHeight $form.Height + } + + $remainingHold = [Math]::Max(0, $HoldSeconds) + if ($remainingHold -gt 0) { + Start-Sleep -Seconds $remainingHold + } + } + + $result = [pscustomobject]@{ + ok = $true + process_bitness = 32 + runtime_mode = $RuntimeMode + self_chat = [bool]$SelfChat + skip_roster_manager = [bool]$SkipRosterManager + client_dir = $clientFull + type = $p2pFormType.FullName + chat_type = $p2pChat.GetType().FullName + hwnd = if ($form.IsHandleCreated) { "0x" + $form.Handle.ToInt64().ToString("X") } else { $null } + visible = $form.Visible + name = $form.Name + text = $form.Text + size = @{ width = $form.Width; height = $form.Height } + result_file = $resultFile + uia_dump_file = $uiaDumpFile + screenshot_file = $screenshotFile + uia_root = $uiaRoot + } + $result | ConvertTo-Json -Depth 24 | Set-Content -LiteralPath $resultFile -Encoding UTF8 + $result | ConvertTo-Json -Depth 24 -Compress +} +catch { + $exceptionRecords = Convert-ExceptionToRecord -Exception $_.Exception + $ctorFrame = $null + foreach ($record in $exceptionRecords) { + foreach ($frame in $record.frames) { + if ($frame.method_type -eq "IMPP.Client.Business.ChatManager.SingleChat.frmP2PChat" -and $frame.method_name -eq ".ctor") { + $ctorFrame = $frame + break + } + } + if ($ctorFrame) { break } + } + $matchedIlLine = if ($ctorFrame) { Find-IlLine -OutDirFull $outFull -IlOffsetHex $ctorFrame.il_offset_hex } else { $null } + + $result = [pscustomobject]@{ + ok = $false + process_bitness = 32 + runtime_mode = $RuntimeMode + self_chat = [bool]$SelfChat + skip_roster_manager = [bool]$SkipRosterManager + client_dir = $clientFull + error = $_.Exception.ToString() + category = $_.CategoryInfo.ToString() + script_stack = $_.ScriptStackTrace + ctor_il_offset_hex = if ($ctorFrame) { $ctorFrame.il_offset_hex } else { $null } + ctor_frame = $ctorFrame + matched_il_line = $matchedIlLine + exceptions = $exceptionRecords + result_file = $resultFile + } + $result | ConvertTo-Json -Depth 32 | Set-Content -LiteralPath $resultFile -Encoding UTF8 + $result | ConvertTo-Json -Depth 32 -Compress + exit 2 +} diff --git a/scripts/test-extract-frmP2PChat-il-risk.ps1 b/scripts/test-extract-frmP2PChat-il-risk.ps1 new file mode 100644 index 0000000..9ca8932 --- /dev/null +++ b/scripts/test-extract-frmP2PChat-il-risk.ps1 @@ -0,0 +1,58 @@ +param( + [string]$ScriptPath = "scripts/extract-frmP2PChat-il-risk.ps1", + [string]$OutDir = "runs/offline-chat-window/il-risk-contract" +) + +$ErrorActionPreference = "Stop" +$repo = Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..") +$target = if ([System.IO.Path]::IsPathRooted($ScriptPath)) { + $ScriptPath +} +else { + Join-Path $repo $ScriptPath +} + +if (-not (Test-Path -LiteralPath $target)) { + throw "script not found: $target" +} + +$outFull = if ([System.IO.Path]::IsPathRooted($OutDir)) { + $OutDir +} +else { + Join-Path $repo $OutDir +} +if (Test-Path -LiteralPath $outFull) { + Remove-Item -LiteralPath $outFull -Recurse -Force +} + +$raw = & powershell -NoProfile -ExecutionPolicy Bypass -File $target -OutDir $OutDir +$result = $raw | ConvertFrom-Json + +if (-not $result.ok) { + throw "extract script returned ok=false" +} +foreach ($path in @($result.ctor_il_file, $result.risk_csv_file, $result.risk_md_file)) { + if (-not (Test-Path -LiteralPath $path)) { + throw "expected output missing: $path" + } +} +if ($result.ctor_line_count -lt 500) { + throw "ctor IL line count too small: $($result.ctor_line_count)" +} +if ($result.risk_count -lt 50) { + throw "risk count too small: $($result.risk_count)" +} +$riskText = Get-Content -LiteralPath $result.risk_csv_file -Raw +foreach ($required in @("IL_00f6", "add_OnConnectError", "P2PChat::get_OtherJid", "IMPPManager::get_UserInfo")) { + if ($riskText -notmatch [regex]::Escape($required)) { + throw "risk csv missing evidence: $required" + } +} + +[pscustomobject]@{ + ok = $true + checked = 4 + ctor_line_count = $result.ctor_line_count + risk_count = $result.risk_count +} | ConvertTo-Json -Compress diff --git a/scripts/test-open-offline-real-frmP2PChat-contract.ps1 b/scripts/test-open-offline-real-frmP2PChat-contract.ps1 new file mode 100644 index 0000000..9c8f6a8 --- /dev/null +++ b/scripts/test-open-offline-real-frmP2PChat-contract.ps1 @@ -0,0 +1,49 @@ +param( + [string]$ScriptPath = "scripts/open-offline-real-frmP2PChat.ps1" +) + +$ErrorActionPreference = "Stop" +$repo = Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..") +$target = if ([System.IO.Path]::IsPathRooted($ScriptPath)) { + $ScriptPath +} +else { + Join-Path $repo $ScriptPath +} + +if (-not (Test-Path -LiteralPath $target)) { + throw "script not found: $target" +} + +$text = Get-Content -LiteralPath $target -Raw +$requiredPatterns = @( + @{ Name = "x86 PowerShell guard"; Pattern = "Environment]::Is64BitProcess" }, + @{ Name = "AssemblyResolve dependency loader"; Pattern = "AssemblyResolve" }, + @{ Name = "fake runtime mode parameter"; Pattern = "RuntimeMode" }, + @{ Name = "self chat variant"; Pattern = "SelfChat" }, + @{ Name = "skip roster variant"; Pattern = "SkipRosterManager" }, + @{ Name = "CLR IL offset extraction"; Pattern = "GetILOffset" }, + @{ Name = "IL offset JSON field"; Pattern = "il_offset_hex" }, + @{ Name = "Config.BaseConfig fake runtime"; Pattern = "k__BackingField" }, + @{ Name = "remote control config key"; Pattern = "Remote_Control_Visible" }, + @{ Name = "fake frmMain activation target"; Pattern = "k__BackingField" }, + @{ Name = "UserInfo cache warmup"; Pattern = "UserInfoCache" }, + @{ Name = "result artifact output"; Pattern = "real-frmP2PChat-result" } +) + +$missing = @() +foreach ($item in $requiredPatterns) { + if ($text -notmatch $item.Pattern) { + $missing += $item.Name + } +} + +if ($missing.Count -gt 0) { + throw "contract missing: $($missing -join ', ')" +} + +[pscustomobject]@{ + ok = $true + script = $target + checked = $requiredPatterns.Count +} | ConvertTo-Json -Compress