merge: a-route real chat window
This commit is contained in:
@@ -383,3 +383,16 @@ R14 release-candidate report is complete:
|
||||
- Updated `docs\go-mcp-runbook.md` with the R14 business status and package paths.
|
||||
- Verification passed: package builders/verifiers, `git diff --check`, `scripts\verify-business-goals-smoke.ps1`, `go test ./...`, `go build ./cmd/isphere-mcp`, and `scripts\verify-go-mcp.ps1`.
|
||||
- Final R14 business conclusion: search contacts, search groups, receive messages, and receive-file list are usable; send-message preview and send-file preview are usable; production send, real file download, and production file upload remain evidence-blocked.
|
||||
|
||||
|
||||
## A-route UIA send branch
|
||||
|
||||
A-route UIA send branch is in progress on `codex/a-route-rpa-send`:
|
||||
|
||||
- User direction: do not add a product approval gate; implement function first and let the digital employee layer decide when to call it.
|
||||
- Added plan `docs\superpowers\plans\2026-07-11-a-route-rpa-send.md`.
|
||||
- Go connector request now carries raw `ContentText` only to the action connector; response and audit remain redacted.
|
||||
- Added Go `uia-rpa` send connector adapter and env loader.
|
||||
- Added C# helper op `uia_send_message` that sets the send editor text and invokes the send button through UI Automation.
|
||||
- Synthetic local verification in `scripts\verify-win-helper.ps1` proves the helper can write to a WinForms send box and trigger a send button marker.
|
||||
- Default MCP smoke still clears A-route env and remains preview/blocked unless explicitly configured.
|
||||
|
||||
@@ -574,7 +574,38 @@ runs\send-file-sandbox-gate-package.zip
|
||||
|
||||
They are generated under ignored `runs\` and are not committed.
|
||||
|
||||
## 18. Troubleshooting
|
||||
## 18. A-route UIA send connector
|
||||
|
||||
A-route is the UI Automation fallback for text sending when the B-route sidecar cannot be tested. It is function-first: there is no human approval ID gate inside the connector. The digital employee layer is responsible for deciding when to call production mode.
|
||||
|
||||
Local verification uses a synthetic WinForms window:
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-win-helper.ps1
|
||||
```
|
||||
|
||||
Expected output includes helper version `0.5.0` and:
|
||||
|
||||
```json
|
||||
{"synthetic_uia_send_action":"uia_send_message"}
|
||||
```
|
||||
|
||||
To enable A-route in a logged-in desktop session, configure the MCP server environment before startup:
|
||||
|
||||
```powershell
|
||||
$env:ISPHERE_SEND_CONNECTOR_MODE = "uia_rpa"
|
||||
$env:ISPHERE_SEND_UIA_HWND = "0x001A0B2C"
|
||||
$env:ISPHERE_SEND_UIA_EDITOR_AUTOMATION_ID = "rtbSendMessage"
|
||||
$env:ISPHERE_SEND_UIA_BUTTON_AUTOMATION_ID = "btnSend"
|
||||
# optional if not using runs\win-helper\ISphereWinHelper.exe
|
||||
$env:ISPHERE_SEND_UIA_HELPER_PATH = "E:\coding\codex\isphere-ai-bridge\runs\win-helper\ISphereWinHelper.exe"
|
||||
```
|
||||
|
||||
Then call `isphere_send_message` with `execution_mode="production"`. The connector will set the send editor text and invoke the send button through UI Automation. Response metadata uses `connector_mode="uia-rpa"`; the audit still stores hashes and metadata, not the raw message body or raw idempotency key.
|
||||
|
||||
Standard `scripts\verify-go-mcp.ps1` clears all A-route env vars so the deterministic smoke remains preview/blocked by default.
|
||||
|
||||
## 19. Troubleshooting
|
||||
|
||||
- If C# helper build fails, run `scripts\build-win-helper.ps1` directly and check for missing .NET Framework reference assemblies.
|
||||
- If `win_helper_version` fails, rerun `powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-win-helper.ps1` first.
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
# A-route open_conversation 窗口验证说明
|
||||
|
||||
日期:2026-07-11
|
||||
分支:`codex/a-route-rpa-send`
|
||||
|
||||
## 本轮目标
|
||||
|
||||
用户明确当前先不要求真实发送消息,只要求“打开那个会话窗口”,用来验证数字员工是否能:
|
||||
|
||||
1. 识别会话窗口。
|
||||
2. 定位发送编辑框。
|
||||
3. 定位发送按钮/文件按钮。
|
||||
4. 执行一次可审计的点击。
|
||||
|
||||
本地环境没有 iSphere 服务端,无法登录,所以真实会话窗口不能通过正常业务路径打开。
|
||||
|
||||
## 已实现:离线会话窗口探针
|
||||
|
||||
新增脚本:
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File E:\coding\codex\isphere-ai-bridge\scripts\open-offline-chat-window-probe.ps1 -ProbeClick
|
||||
```
|
||||
|
||||
它会打开一个本地 WinForms 会话窗口探针,不连接服务端,不发送真实消息。关键 UIA 标识对齐真实会话窗口/既有 RPA 选择器:
|
||||
|
||||
| 目标 | AutomationId / Name |
|
||||
| --- | --- |
|
||||
| 会话窗口 | `frmP2PChat` |
|
||||
| 联系人搜索框 | `skinAlphaTxt` |
|
||||
| 消息展示区 | `rtbRecvMessage` |
|
||||
| 发送编辑框 | `rtbSendMessage` |
|
||||
| 发送按钮 | `btnSend` |
|
||||
| 文件按钮 | `btnSendFile` |
|
||||
| 离线提示 | `offlineSendBlocker` |
|
||||
|
||||
当传入 `-ProbeClick` 时,脚本会调用当前分支已有的 WinHelper UIA 动作,把文本写入 `rtbSendMessage` 并点击 `btnSend`。按钮点击只写本地 marker 文件,字段固定为:
|
||||
|
||||
- `sent_real_message=false`
|
||||
- `uploaded_real_file=false`
|
||||
|
||||
因此这个动作只验证 UIA 识别、定位、写入、点击链路,不触发真实消息发送。
|
||||
|
||||
## 自动化测试
|
||||
|
||||
新增测试:
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File E:\coding\codex\isphere-ai-bridge\scripts\test-open-offline-chat-window-probe.ps1
|
||||
```
|
||||
|
||||
测试断言:
|
||||
|
||||
- 根窗口 `root_automation_id=frmP2PChat`
|
||||
- 找到 `rtbSendMessage`
|
||||
- 找到 `btnSend`
|
||||
- 找到 `btnSendFile`
|
||||
- `ProbeClick` 后写入 marker
|
||||
- marker 文本与 UIA 写入文本一致
|
||||
- `sent_real_message=false`
|
||||
- `uploaded_real_file=false`
|
||||
|
||||
## 真实 `frmP2PChat` 破解进度
|
||||
|
||||
已继续尝试直接构造真实类型:
|
||||
|
||||
```text
|
||||
IMPP.Client.Business.ChatManager.SingleChat.frmP2PChat
|
||||
```
|
||||
|
||||
构造函数签名:
|
||||
|
||||
```text
|
||||
frmP2PChat(com.vision.smack.Chat chat, string pluginInfo, string extendTabJson)
|
||||
```
|
||||
|
||||
已补过的离线依赖包括:
|
||||
|
||||
- `IMPPManager.Instance.MessageCenter`
|
||||
- `IMPPManager.Instance.LogonUser`
|
||||
- `IMPPManager.Instance.UserInfo`
|
||||
- `SmarkManager.Connection`
|
||||
- `SmarkManager.RosterManager`
|
||||
- `SmarkManager.PresenceManager`
|
||||
- `SmarkManager.P2PChatManager`
|
||||
- `ConfigSystemManager.config`
|
||||
- `P2PChat.OtherJid`
|
||||
|
||||
仍未成功打开真实窗体。当前阻塞不再是脚本能力,而是构造函数内部把窗体初始化、登录态、连接态、名册状态、消息中心事件、配置状态混在一起。IL 证据显示构造函数中直接访问:
|
||||
|
||||
- `IMPPManager.Instance.Connection.add_OnConnectError`
|
||||
- `IMPPManager.Instance.Connection.add_OnReConnectOk`
|
||||
- `P2PChat.OtherJid.getBareJid`
|
||||
- `IMPPManager.Instance.UserInfo.get_Jid`
|
||||
- `UCChatSendMessageBox.GetChatRichTextBox`
|
||||
- `UCChatSendMessageBox.GetReceiptCheckBox`
|
||||
- `IMPPManager.Instance.GetMessageCenter().add_OnTcpMessageArrived`
|
||||
- `IMPPManager.Instance.RosterManager.UpdateStrangerStatus`
|
||||
- `Config.BaseConfig.VisualPhoneEnable`
|
||||
|
||||
这说明真实 `frmP2PChat` 不是独立窗口类;它要求完整登录运行态。没有服务端/登录态时继续硬构造,投入会越来越像“重建一个假客户端运行时”。
|
||||
|
||||
## 当前业务结论
|
||||
|
||||
从“验证 RPA 能否识别、定位、点击会话窗口”的业务目标看,本轮已经可验证:
|
||||
|
||||
- 会话窗口可打开。
|
||||
- 核心 AutomationId 可识别。
|
||||
- 文本可写入。
|
||||
- 按钮可点击。
|
||||
- 点击结果可留本地审计 marker。
|
||||
- 全程不发真实消息、不上传真实文件。
|
||||
|
||||
从“真实 iSphere 会话窗口”的目标看,仍需要在线登录环境或继续补完整客户端运行态。
|
||||
|
||||
## 下一步建议
|
||||
|
||||
1. 当前分支先使用 `open-offline-chat-window-probe.ps1` 验证 RPA 识别/定位/点击效果。
|
||||
2. 在线环境拿到真实登录后的 `frmP2PChat` HWND 后,用同一套 WinHelper 选择器直接验证真实窗口。
|
||||
3. 如果必须离线打开真实窗体,再继续走“补完整 IMPPManager/SmarkManager/Config/BaseConfig/MessageCenter 运行态”的破解路线。
|
||||
@@ -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.<BaseConfig>k__BackingField` |
|
||||
| `frmChatBase_Activated IL_0000/0005` | `IMPPManager.get_Instance().get_frmMain().BringModalFormToFront()` | 补 `IMPPManager.<frmMain>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`
|
||||
- `<MessageCenter>k__BackingField`
|
||||
- `<frmMain>k__BackingField`
|
||||
- `LogonUser`
|
||||
- `_userInfo`
|
||||
- `_rosterUserInfoList`
|
||||
2. `SmarkManager`
|
||||
- `<SmarkManagerInstance>k__BackingField`
|
||||
3. `XMPPConnection`
|
||||
- `Jid`
|
||||
- `UserName`
|
||||
- `LoginState`
|
||||
- `CompanyID`
|
||||
- `Resource`
|
||||
- `_packetListeners`
|
||||
- `_packetCollectors`
|
||||
4. `IMPPManager` inherited manager fields/properties
|
||||
- `Connection` and `<Connection>k__BackingField`
|
||||
- `IsConnected` and `<IsConnected>k__BackingField`
|
||||
- `PresenceManager` and backing field
|
||||
- `P2PChatManager` and backing field
|
||||
- `RosterManager` and backing field
|
||||
5. Config
|
||||
- `ConfigSystemManager.config`
|
||||
- `Config.<BaseConfig>k__BackingField`
|
||||
- `Config.<UserInfo>k__BackingField`
|
||||
- `Config.<LoginInfo>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` 命中路径。
|
||||
30
docs/source-discovery/2026-07-11-a-route-uia-send.md
Normal file
30
docs/source-discovery/2026-07-11-a-route-uia-send.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# A-route UIA Send Implementation Note
|
||||
|
||||
Date: 2026-07-11
|
||||
Branch: `codex/a-route-rpa-send`
|
||||
|
||||
## Decision
|
||||
|
||||
The A-route fallback is now function-first. It does not introduce an approval ID gate inside the MCP tool or connector. Digital employee policy can decide when to call `execution_mode="production"`; the connector focuses on performing the UI action when explicitly configured.
|
||||
|
||||
## Implemented local-safe proof
|
||||
|
||||
- `SendMessageConnectorRequest` now carries `ContentText` to the connector only.
|
||||
- `internal/tools/send_message_uia_adapter.go` maps production send requests to helper op `uia_send_message`.
|
||||
- `native/ISphereWinHelper/UiaSendAction.cs` sets the send editor text and invokes the send button by UI Automation / Win32 fallback.
|
||||
- `scripts/verify-win-helper.ps1` uses a synthetic WinForms window to prove write + button invoke without requiring iSphere login.
|
||||
|
||||
## Runtime configuration
|
||||
|
||||
```powershell
|
||||
$env:ISPHERE_SEND_CONNECTOR_MODE = "uia_rpa"
|
||||
$env:ISPHERE_SEND_UIA_HWND = "0x001A0B2C"
|
||||
$env:ISPHERE_SEND_UIA_EDITOR_AUTOMATION_ID = "rtbSendMessage"
|
||||
$env:ISPHERE_SEND_UIA_BUTTON_AUTOMATION_ID = "btnSend"
|
||||
```
|
||||
|
||||
Then call `isphere_send_message` with `execution_mode="production"`.
|
||||
|
||||
## Remaining real-environment work
|
||||
|
||||
The local environment still cannot log in to iSphere. The code path is implemented and locally proven against synthetic UIA, but real iSphere success still needs a logged-in window handle and one real run in the online environment.
|
||||
175
docs/source-discovery/2026-07-11-a-route-user-silent-rpa.md
Normal file
175
docs/source-discovery/2026-07-11-a-route-user-silent-rpa.md
Normal file
@@ -0,0 +1,175 @@
|
||||
# A-route RPA 用户无感运行说明
|
||||
|
||||
日期:2026-07-11
|
||||
|
||||
## 目标
|
||||
|
||||
A-route 当前看重窗口和 UIA 控件,而不是背后的网络逻辑。这里的“用户无感”指:
|
||||
|
||||
1. 不遮挡当前用户桌面。
|
||||
2. 不抢焦点。
|
||||
3. 不接管鼠标键盘。
|
||||
4. 数字员工仍然能通过 HWND/UIA 找到目标窗口和控件。
|
||||
5. 所有动作可记录、可复现、可回收。
|
||||
|
||||
## 当前已实现
|
||||
|
||||
脚本:
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File E:\coding\codex\isphere-ai-bridge\scripts\open-offline-real-client-window.ps1 -UserSilent
|
||||
```
|
||||
|
||||
效果:
|
||||
|
||||
- 从完整离线客户端目录启动 `IMPlatformClient.exe`。
|
||||
- 使用 `-WindowStyle Minimized` 降低启动阶段闪窗概率。
|
||||
- 找到真实 `IMPlatformClient` 窗口后移动到屏幕外:
|
||||
- 主窗:`x=-32000, y=-32000`
|
||||
- 提示窗:同样移到屏幕外
|
||||
- 设置 `SWP_NOACTIVATE`,不调用前台激活。
|
||||
- 不生成屏幕截图,避免因为窗口在屏幕外得到无意义图片。
|
||||
- 继续输出 UIA dump,供 RPA 选择器验证。
|
||||
|
||||
## 抗干扰能力
|
||||
|
||||
新增守护模式:
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File E:\coding\codex\isphere-ai-bridge\scripts\open-offline-real-client-window.ps1 -WindowMode Visible -WatchSeconds 30 -PollIntervalMs 500
|
||||
```
|
||||
|
||||
也可和无感模式一起使用:
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File E:\coding\codex\isphere-ai-bridge\scripts\open-offline-real-client-window.ps1 -UserSilent -WatchSeconds 30 -PollIntervalMs 500
|
||||
```
|
||||
|
||||
当前守护逻辑:
|
||||
|
||||
- 用户手动移动主窗口:检测到位置/大小偏离后自动移回目标位置。
|
||||
- 用户手动移动提示窗:检测到位置/大小偏离后自动移回目标位置。
|
||||
- 用户关闭主窗口/杀掉客户端进程:检测到主窗口消失后自动重新启动离线客户端。
|
||||
- 每次守护输出:
|
||||
- `watch_iteration_count`
|
||||
- `repair_count`
|
||||
- `relaunch_count`
|
||||
- `recovery_actions`
|
||||
|
||||
本轮实际验证:
|
||||
|
||||
1. 模拟移动窗口到 `760,260,310x610`:
|
||||
- 守护输出 `repair_count=1`
|
||||
- `recovery_actions=["repair_window_placement"]`
|
||||
- 最终窗口恢复到 `120,120,270x570`
|
||||
|
||||
2. 模拟关闭客户端进程:
|
||||
- 被关闭 PID:`11592`
|
||||
- 守护输出 `relaunch_count=1`
|
||||
- 新启动 PID:`10504`
|
||||
- `recovery_actions=["relaunch_missing_main_window","repair_after_relaunch"]`
|
||||
- 最终 UIA 根控件仍为 `frmLogin`
|
||||
|
||||
抗干扰强度结论:
|
||||
|
||||
- 对“用户移动窗口”:强。
|
||||
- 对“用户关闭窗口/进程”:中强,可自动重启。
|
||||
- 对“用户在关键发送瞬间抢焦点/操作同一窗口”:当前还不是强,需要动作级锁定和发送前后校验。
|
||||
- 对“应用崩溃/服务端不可用”:只能自动拉起窗口;业务登录/聊天能力仍受真实服务端影响。
|
||||
|
||||
本轮验证结果:
|
||||
|
||||
- 新启动进程:`IMPlatformClient.exe`
|
||||
- PID:`20060`
|
||||
- 主窗口:`0x340330`
|
||||
- UIA 根控件:
|
||||
- `automation_id=frmLogin`
|
||||
- `framework_id=WinForm`
|
||||
- `class_name=WindowsForms10.Window.8.app.0.d3a00f_r7_ad1`
|
||||
- UIA dump 仍可读,即使窗口处于屏幕外:
|
||||
- `is_offscreen=true`
|
||||
- 子控件 `skinState`、`skinLoadPanel` 可见于控件树
|
||||
|
||||
## 推荐无感等级
|
||||
|
||||
### L1:当前用户会话内屏幕外运行
|
||||
|
||||
适合当前开发机验证:
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File E:\coding\codex\isphere-ai-bridge\scripts\open-offline-real-client-window.ps1 -UserSilent
|
||||
```
|
||||
|
||||
优点:
|
||||
|
||||
- 实现最快。
|
||||
- 不影响当前屏幕。
|
||||
- UIA 读控件可继续工作。
|
||||
|
||||
限制:
|
||||
|
||||
- 如果某些发送动作必须依赖真实鼠标点击或 OCR 截图,屏幕外模式不适合。
|
||||
- 发送路径应优先使用 `ValuePattern`、`InvokePattern`、`WM_SETTEXT`、`BM_CLICK` 等 HWND/UIA/Win32 方式。
|
||||
|
||||
### L2:当前用户会话内可见但不激活
|
||||
|
||||
适合排查窗口状态:
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File E:\coding\codex\isphere-ai-bridge\scripts\open-offline-real-client-window.ps1 -WindowMode Visible -NoActivate
|
||||
```
|
||||
|
||||
优点:
|
||||
|
||||
- 人能看到窗口。
|
||||
- 尽量不抢焦点。
|
||||
|
||||
限制:
|
||||
|
||||
- 仍可能遮挡用户桌面。
|
||||
- 只适合调试,不适合长期运行。
|
||||
|
||||
### L3:独立 Windows 用户会话运行
|
||||
|
||||
这是生产上更稳的“真正无感”方案:
|
||||
|
||||
- 给数字员工单独建一个 Windows 用户。
|
||||
- 在该用户会话里登录 iSphere。
|
||||
- RPA/WinHelper/MCP 都运行在同一个独立会话。
|
||||
- 业务用户使用自己的桌面,看不到数字员工窗口。
|
||||
|
||||
优点:
|
||||
|
||||
- 不影响业务用户桌面。
|
||||
- 不抢业务用户焦点。
|
||||
- 可以保留真实可见窗口,兼容 OCR/坐标兜底。
|
||||
|
||||
限制:
|
||||
|
||||
- 需要部署层面的账户和会话管理。
|
||||
- WinHelper 必须和目标窗口在同一交互式桌面会话里运行。
|
||||
|
||||
### L4:不用 RPA,走 B-route/API/sidecar
|
||||
|
||||
这是最终最无感的方向:
|
||||
|
||||
- 没窗口。
|
||||
- 不依赖桌面。
|
||||
- 不受焦点、分辨率、UI 改版影响。
|
||||
|
||||
但当前分支是 A-route RPA,所以本轮先把 L1/L2 做出来。
|
||||
|
||||
## 结论
|
||||
|
||||
当前已经具备 L1 无感基础能力:
|
||||
|
||||
- 真实客户端可打开。
|
||||
- 可屏幕外运行。
|
||||
- 不需要前台焦点。
|
||||
- UIA 控件树仍可读取。
|
||||
|
||||
下一轮应把发送/搜索动作约束在非焦点方式上:
|
||||
|
||||
1. 优先 UIA `ValuePattern`/`InvokePattern`。
|
||||
2. 其次 HWND 消息 `WM_SETTEXT`/`BM_CLICK`。
|
||||
3. 最后才考虑坐标/OCR;坐标/OCR 只能放到独立 Windows 用户会话里做,不能放当前用户桌面长期运行。
|
||||
115
docs/superpowers/plans/2026-07-11-a-route-rpa-send.md
Normal file
115
docs/superpowers/plans/2026-07-11-a-route-rpa-send.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# A-Route RPA Send Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 在 `codex/a-route-rpa-send` 分支实现 A 方案:通过受控 UI Automation 路线让 `isphere_send_message` 能配置为真实 UI 写入并点击发送。
|
||||
|
||||
**Architecture:** Go MCP 继续保留稳定 `isphere_send_message` 接口、hash、idempotency、audit。新增 A-route connector 调用 C# `ISphereWinHelper` 的 UIA action op;C# helper 根据窗口句柄、发送框 AutomationId、发送按钮 AutomationId 写入文本并点击。B-route sidecar 仍是主路线,但当前不可测时先实现 A-route 功能兜底。
|
||||
|
||||
**Tech Stack:** Go 1.23.4, github.com/modelcontextprotocol/go-sdk, Windows PowerShell, .NET Framework C# helper, Windows UI Automation.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Repository root: `E:\coding\codex\isphere-ai-bridge`.
|
||||
- Active branch: `codex/a-route-rpa-send`.
|
||||
- Do not use `rg`; use `git ls-files`, `ag`, `grep -R`, or PowerShell commands.
|
||||
- Local machine cannot log in; use synthetic WinForms/UIA verification for local tests.
|
||||
- User explicitly removed human approval as a product gate for A-route; do not add approval IDs or approval queues in this plan.
|
||||
- Keep audit redaction: do not store raw message body or raw idempotency key in committed docs/audit tests.
|
||||
- Real UI action requires explicit env configuration; default MCP behavior remains preview/blocked.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Go connector must carry raw content to action connector
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/tools/send_message_connector.go`
|
||||
- Modify: `internal/tools/isphere_send_message.go`
|
||||
- Modify: `internal/tools/isphere_send_message_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `SendMessageConnectorRequest.ContentText string` for connector execution only.
|
||||
- Audit and response still omit raw content.
|
||||
|
||||
- [x] Write failing test proving injected connector receives raw `ContentText` while response/audit do not leak it.
|
||||
- [x] Run focused test and confirm failure.
|
||||
- [x] Add `ContentText` to normalized args and connector request.
|
||||
- [x] Run focused send-message tests.
|
||||
|
||||
## Task 2: Go A-route UIA connector adapter
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/tools/send_message_uia_adapter.go`
|
||||
- Create: `internal/tools/send_message_uia_adapter_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `type UiaSendMessageAdapterConfig struct { HelperPath string; TimeoutSeconds int; Hwnd string; SendEditorAutomationID string; SendButtonAutomationID string; Mode string }`.
|
||||
- Produces: `NewUiaSendMessageConnector(config UiaSendMessageAdapterConfig, caller UiaHelperCaller) SendMessageConnector`.
|
||||
- Helper op: `uia_send_message`.
|
||||
|
||||
- [x] Write failing tests for missing config and successful helper call using a fake helper caller.
|
||||
- [x] Implement adapter request mapping.
|
||||
- [x] Run focused adapter tests.
|
||||
|
||||
## Task 3: C# WinHelper UIA send action
|
||||
|
||||
**Files:**
|
||||
- Create: `native/ISphereWinHelper/UiaSendAction.cs`
|
||||
- Modify: `native/ISphereWinHelper/Program.cs`
|
||||
- Modify: `scripts/verify-win-helper.ps1`
|
||||
|
||||
**Interfaces:**
|
||||
- Helper op `uia_send_message` args: `hwnd`, `send_editor_automation_id`, `send_button_automation_id`, `content_text`, `content_sha256`, `target_ref`.
|
||||
- Helper data: `action_mode="uia_send_message"`, `typed_text=true`, `clicked_ui=true`, `sent_message=true`, `content_sha256`, `target_ref`, `editor_found`, `button_found`.
|
||||
|
||||
- [x] Add synthetic WinForms verification that starts a form with `rtbSendMessage`, `btnSend`, invokes `uia_send_message`, and verifies button-click side effect in a label.
|
||||
- [x] Implement `UiaSendAction` with ValuePattern/Win32 fallback and InvokePattern/click fallback.
|
||||
- [x] Run `scripts\verify-win-helper.ps1`.
|
||||
|
||||
## Task 4: Wire A-route connector into MCP env config
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/tools/isphere_send_message.go`
|
||||
- Modify: `internal/mcpserver/server.go`
|
||||
- Modify: `scripts/verify-go-mcp.ps1`
|
||||
- Modify: `docs/go-mcp-runbook.md`
|
||||
- Modify: `docs/current-status-card.md`
|
||||
|
||||
**Interfaces:**
|
||||
- Env `ISPHERE_SEND_CONNECTOR_MODE=uia_rpa` enables A-route connector.
|
||||
- Env `ISPHERE_SEND_UIA_HWND`, `ISPHERE_SEND_UIA_EDITOR_AUTOMATION_ID`, `ISPHERE_SEND_UIA_BUTTON_AUTOMATION_ID`, optional `ISPHERE_SEND_UIA_HELPER_PATH` configure the action.
|
||||
- Default env remains no real send.
|
||||
|
||||
- [x] Add config loader and tests around production connector availability.
|
||||
- [x] Keep standard smoke deterministic with env cleared.
|
||||
- [x] Add server env routing test; helper synthetic UIA smoke covers the local write/click action.
|
||||
- [x] Update docs with minimal usage.
|
||||
|
||||
## Task 5: Verification, commit, push
|
||||
|
||||
- [x] Run `git diff --check`.
|
||||
- [x] Run `go test ./...`.
|
||||
- [x] Run `go build ./cmd/isphere-mcp` then remove root binary.
|
||||
- [x] Run `powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-win-helper.ps1`.
|
||||
- [x] Run `powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-go-mcp.ps1`.
|
||||
- [x] Commit and push branch `codex/a-route-rpa-send`.
|
||||
|
||||
## Task 6: Open conversation window probe for A-route RPA
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/open-offline-chat-window-probe.ps1`
|
||||
- Create: `scripts/test-open-offline-chat-window-probe.ps1`
|
||||
- Create: `docs/source-discovery/2026-07-11-a-route-open-conversation-window.md`
|
||||
|
||||
**Purpose:** local environment cannot log in, but RPA still needs a stable
|
||||
conversation-window surface to prove identify/locate/click behavior. This task
|
||||
adds a no-network WinForms probe using the real selector names
|
||||
`frmP2PChat`, `rtbRecvMessage`, `rtbSendMessage`, `btnSend`, and
|
||||
`btnSendFile`.
|
||||
|
||||
- [x] Write failing test proving the open-conversation probe script is required.
|
||||
- [x] Implement the offline chat-window probe.
|
||||
- [x] Verify UIA classification finds send editor, send button, and file button.
|
||||
- [x] Verify `-ProbeClick` writes text and clicks `btnSend`.
|
||||
- [x] Verify marker says `sent_real_message=false` and `uploaded_real_file=false`.
|
||||
- [x] Document why direct offline construction of the real `frmP2PChat` is still blocked by login/runtime singletons.
|
||||
@@ -61,7 +61,7 @@ func NewServerFromEnv() (*mcp.Server, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewServerWithSourcesAndMsgLibReceive(source, displaySource, msglibReceiveSource), nil
|
||||
return NewServerWithSourcesMsgLibReceiveAndSendConnector(source, displaySource, msglibReceiveSource, tools.NewSendMessageConnectorFromEnv()), nil
|
||||
}
|
||||
|
||||
func msgLibSourcesFromEnv() (tools.DisplayEntitySource, tools.ReceiveMessagesSource, error) {
|
||||
@@ -101,6 +101,10 @@ func NewServerWithSources(source tools.ReceiveMessagesSource, displaySource tool
|
||||
}
|
||||
|
||||
func NewServerWithSourcesAndMsgLibReceive(source tools.ReceiveMessagesSource, displaySource tools.DisplayEntitySource, msglibReceiveSource tools.ReceiveMessagesSource) *mcp.Server {
|
||||
return NewServerWithSourcesMsgLibReceiveAndSendConnector(source, displaySource, msglibReceiveSource, nil)
|
||||
}
|
||||
|
||||
func NewServerWithSourcesMsgLibReceiveAndSendConnector(source tools.ReceiveMessagesSource, displaySource tools.DisplayEntitySource, msglibReceiveSource tools.ReceiveMessagesSource, sendConnector tools.SendMessageConnector) *mcp.Server {
|
||||
server := mcp.NewServer(&mcp.Implementation{
|
||||
Name: ServerName,
|
||||
Title: ServerTitle,
|
||||
@@ -111,7 +115,7 @@ func NewServerWithSourcesAndMsgLibReceive(source tools.ReceiveMessagesSource, di
|
||||
tools.RegisterISphereContactToolsWithDisplayEntities(server, source, displaySource)
|
||||
tools.RegisterISphereGroupToolsWithDisplayEntities(server, source, displaySource)
|
||||
tools.RegisterISphereFileTools(server, source)
|
||||
tools.RegisterISphereSendMessageTool(server, nil)
|
||||
tools.RegisterISphereSendMessageToolWithStateAndConnector(server, nil, nil, sendConnector)
|
||||
tools.RegisterISphereSendFileTool(server)
|
||||
return server
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import (
|
||||
"context"
|
||||
"crypto/cipher"
|
||||
"crypto/des"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"reflect"
|
||||
@@ -283,6 +285,56 @@ func TestNewServerFromEnvUsesPacketLogDirectory(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewServerFromEnvWiresUiaRpaSendConnector(t *testing.T) {
|
||||
clearMsgLibEnvForServerTest(t)
|
||||
t.Setenv("ISPHERE_SEND_CONNECTOR_MODE", "uia_rpa")
|
||||
t.Setenv("ISPHERE_SEND_UIA_HELPER_PATH", t.TempDir()+"\\missing-helper.exe")
|
||||
t.Setenv("ISPHERE_SEND_UIA_HWND", "0x1234")
|
||||
t.Setenv("ISPHERE_SEND_UIA_EDITOR_AUTOMATION_ID", "rtbSendMessage")
|
||||
t.Setenv("ISPHERE_SEND_UIA_BUTTON_AUTOMATION_ID", "btnSend")
|
||||
t.Setenv("ISPHERE_SEND_AUDIT_PATH", t.TempDir()+"\\send-audit.jsonl")
|
||||
t.Setenv("ISPHERE_SEND_IDEMPOTENCY_PATH", t.TempDir()+"\\send-idempotency.jsonl")
|
||||
|
||||
server, err := NewServerFromEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("NewServerFromEnv returned error: %v", err)
|
||||
}
|
||||
session, cleanup := connectServerTestSession(t, server)
|
||||
defer cleanup()
|
||||
|
||||
content := "uia rpa send through env"
|
||||
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
|
||||
Name: "isphere_send_message",
|
||||
Arguments: map[string]any{
|
||||
"target_type": "direct",
|
||||
"target_id": "alice@imopenfire1-lanzhou",
|
||||
"content_text": content,
|
||||
"content_sha256": sha256HexForServerTest(content),
|
||||
"idempotency_key": "idem-uia-env-1",
|
||||
"execution_mode": "production",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("call isphere_send_message: %v", err)
|
||||
}
|
||||
if callResult.IsError {
|
||||
t.Fatalf("send message should return structured connector failure, got error: %+v", callResult)
|
||||
}
|
||||
payload, _ := json.Marshal(callResult.StructuredContent)
|
||||
var decoded struct {
|
||||
OK bool `json:"ok"`
|
||||
SendStatus string `json:"send_status"`
|
||||
ConnectorMode string `json:"connector_mode"`
|
||||
ErrorCode string `json:"error_code"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &decoded); err != nil {
|
||||
t.Fatalf("decode payload %s: %v", payload, err)
|
||||
}
|
||||
if decoded.OK || decoded.SendStatus != "failed" || decoded.ConnectorMode != "uia-rpa" || decoded.ErrorCode != "uia_rpa_helper_error" {
|
||||
t.Fatalf("production send did not route to UIA connector: %s", payload)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeMsgLibClientForServerTest struct {
|
||||
displayCalls []msglib.DisplayEntitiesOptions
|
||||
listCalls []msglib.ListMessagesOptions
|
||||
@@ -376,3 +428,8 @@ func padPacketLogLineForServerTest(data []byte, blockSize int) []byte {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sha256HexForServerTest(value string) string {
|
||||
sum := sha256.Sum256([]byte(value))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
@@ -273,6 +273,7 @@ type normalizedSendMessageArgs struct {
|
||||
TargetType string
|
||||
TargetID string
|
||||
TargetRef string
|
||||
ContentText string
|
||||
ContentLength int
|
||||
ContentSHA256 string
|
||||
IdempotencyKeySHA256 string
|
||||
@@ -308,6 +309,7 @@ func normalizeSendMessageArgs(input SendMessageArgs) (normalizedSendMessageArgs,
|
||||
TargetType: targetType,
|
||||
TargetID: targetID,
|
||||
TargetRef: targetRefPrefix + ":" + targetID,
|
||||
ContentText: contentText,
|
||||
ContentLength: len(contentText),
|
||||
ContentSHA256: contentHash,
|
||||
IdempotencyKeySHA256: sha256Hex(idempotencyKey),
|
||||
@@ -448,6 +450,12 @@ func applySendMessageConnectorResult(response map[string]any, event *SendMessage
|
||||
response["send_status"] = normalized.Status
|
||||
response["connector_mode"] = normalized.ConnectorMode
|
||||
response["connector_stage"] = normalized.Status
|
||||
if normalized.ProductionEnabled {
|
||||
response["production_send_enabled"] = true
|
||||
}
|
||||
if len(normalized.SideEffects) > 0 {
|
||||
response["side_effects"] = normalized.SideEffects
|
||||
}
|
||||
if normalized.Accepted {
|
||||
response["blocked_reason"] = nil
|
||||
} else if normalized.ErrorMessage != "" {
|
||||
@@ -471,6 +479,9 @@ func applySendMessageConnectorResult(response map[string]any, event *SendMessage
|
||||
if normalized.AckRef != "" {
|
||||
audit["ack_ref"] = normalized.AckRef
|
||||
}
|
||||
if normalized.ProductionEnabled {
|
||||
audit["production_send_enabled"] = true
|
||||
}
|
||||
if normalized.ErrorCode != "" {
|
||||
audit["error_code"] = normalized.ErrorCode
|
||||
}
|
||||
@@ -487,6 +498,9 @@ func applySendMessageConnectorResult(response map[string]any, event *SendMessage
|
||||
event.AckRef = normalized.AckRef
|
||||
event.ErrorCode = normalized.ErrorCode
|
||||
event.ErrorMessage = normalized.ErrorMessage
|
||||
if len(normalized.SideEffects) > 0 {
|
||||
event.SideEffects = normalized.SideEffects
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -385,6 +385,52 @@ func TestISphereSendMessageFakeConnectorFailureAudit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestISphereSendMessageConnectorReceivesContentTextWithoutLeakingIt(t *testing.T) {
|
||||
audit := &fakeSendMessageAuditSink{}
|
||||
idempotency := newFakeSendMessageIdempotencyStore()
|
||||
connector := &fakeSendMessageConnector{
|
||||
result: SendMessageConnectorResult{
|
||||
Accepted: true,
|
||||
Status: "accepted",
|
||||
AckRef: "fake-content-ack",
|
||||
ConnectorMode: "fake",
|
||||
},
|
||||
}
|
||||
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
|
||||
RegisterISphereSendMessageToolWithStateAndConnector(server, audit, idempotency, connector)
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
content := "connector needs the raw text to send"
|
||||
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
|
||||
Name: ToolNameSendMessage,
|
||||
Arguments: map[string]any{
|
||||
"target_type": "direct",
|
||||
"target_id": "alice@imopenfire1-lanzhou",
|
||||
"content_text": content,
|
||||
"content_sha256": sha256HexForSendMessageTest(content),
|
||||
"idempotency_key": "idem-connector-content-1",
|
||||
"execution_mode": "production",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("call %s: %v", ToolNameSendMessage, err)
|
||||
}
|
||||
payload, _ := json.Marshal(callResult.StructuredContent)
|
||||
if connector.lastRequest.ContentText != content {
|
||||
t.Fatalf("connector ContentText = %q, want raw content", connector.lastRequest.ContentText)
|
||||
}
|
||||
if strings.Contains(string(payload), content) {
|
||||
t.Fatalf("structured response leaked raw content: %s", payload)
|
||||
}
|
||||
if len(audit.events) != 1 {
|
||||
t.Fatalf("audit events = %+v, want one", audit.events)
|
||||
}
|
||||
if audit.events[0].ContentText != "" || strings.Contains(mustMarshalStringForSendMessageTest(audit.events[0]), content) {
|
||||
t.Fatalf("audit leaked raw content: %+v", audit.events[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestISphereSendMessageRejectsContentHashMismatch(t *testing.T) {
|
||||
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
|
||||
RegisterISphereSendMessageToolWithState(server, &fakeSendMessageAuditSink{}, newFakeSendMessageIdempotencyStore())
|
||||
@@ -650,13 +696,15 @@ func (f *fakeSendMessageIdempotencyStore) ReserveSendMessageIdempotency(_ contex
|
||||
}
|
||||
|
||||
type fakeSendMessageConnector struct {
|
||||
result SendMessageConnectorResult
|
||||
err error
|
||||
calls int
|
||||
result SendMessageConnectorResult
|
||||
err error
|
||||
calls int
|
||||
lastRequest SendMessageConnectorRequest
|
||||
}
|
||||
|
||||
func (f *fakeSendMessageConnector) ExecuteSendMessage(_ context.Context, _ SendMessageConnectorRequest) (SendMessageConnectorResult, error) {
|
||||
func (f *fakeSendMessageConnector) ExecuteSendMessage(_ context.Context, request SendMessageConnectorRequest) (SendMessageConnectorResult, error) {
|
||||
f.calls++
|
||||
f.lastRequest = request
|
||||
return f.result, f.err
|
||||
}
|
||||
|
||||
@@ -664,3 +712,11 @@ func sha256HexForSendMessageTest(value string) string {
|
||||
sum := sha256.Sum256([]byte(value))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func mustMarshalStringForSendMessageTest(value any) string {
|
||||
payload, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return string(payload)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ type SendMessageConnectorRequest struct {
|
||||
TargetType string
|
||||
TargetID string
|
||||
TargetRef string
|
||||
ContentText string
|
||||
ContentSHA256 string
|
||||
ContentLength int
|
||||
IdempotencyKeySHA256 string
|
||||
@@ -20,12 +21,14 @@ type SendMessageConnectorRequest struct {
|
||||
}
|
||||
|
||||
type SendMessageConnectorResult struct {
|
||||
Accepted bool
|
||||
Status string
|
||||
AckRef string
|
||||
ErrorCode string
|
||||
ErrorMessage string
|
||||
ConnectorMode string
|
||||
Accepted bool
|
||||
Status string
|
||||
AckRef string
|
||||
ErrorCode string
|
||||
ErrorMessage string
|
||||
ConnectorMode string
|
||||
ProductionEnabled bool
|
||||
SideEffects map[string]any
|
||||
}
|
||||
|
||||
func sendMessageConnectorRequestFromNormalized(input normalizedSendMessageArgs) SendMessageConnectorRequest {
|
||||
@@ -33,6 +36,7 @@ func sendMessageConnectorRequestFromNormalized(input normalizedSendMessageArgs)
|
||||
TargetType: input.TargetType,
|
||||
TargetID: input.TargetID,
|
||||
TargetRef: input.TargetRef,
|
||||
ContentText: input.ContentText,
|
||||
ContentSHA256: input.ContentSHA256,
|
||||
ContentLength: input.ContentLength,
|
||||
IdempotencyKeySHA256: input.IdempotencyKeySHA256,
|
||||
|
||||
239
internal/tools/send_message_uia_adapter.go
Normal file
239
internal/tools/send_message_uia_adapter.go
Normal file
@@ -0,0 +1,239 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"isphere-ai-bridge/internal/helperclient"
|
||||
)
|
||||
|
||||
const (
|
||||
uiaSendMessageOp = "uia_send_message"
|
||||
EnvSendConnectorMode = "ISPHERE_SEND_CONNECTOR_MODE"
|
||||
EnvSendUIAHelperPath = "ISPHERE_SEND_UIA_HELPER_PATH"
|
||||
EnvSendUIAHwnd = "ISPHERE_SEND_UIA_HWND"
|
||||
EnvSendUIAEditorAutomationID = "ISPHERE_SEND_UIA_EDITOR_AUTOMATION_ID"
|
||||
EnvSendUIButtonAutomationID = "ISPHERE_SEND_UIA_BUTTON_AUTOMATION_ID"
|
||||
EnvSendUIATimeoutSeconds = "ISPHERE_SEND_UIA_TIMEOUT_SECONDS"
|
||||
)
|
||||
|
||||
type UiaSendMessageAdapterConfig struct {
|
||||
HelperPath string
|
||||
TimeoutSeconds int
|
||||
Hwnd string
|
||||
SendEditorAutomationID string
|
||||
SendButtonAutomationID string
|
||||
Mode string
|
||||
}
|
||||
|
||||
type UiaHelperCaller interface {
|
||||
CallUiaHelper(ctx context.Context, op string, args map[string]any) (uiaHelperResponse, error)
|
||||
}
|
||||
|
||||
type uiaHelperResponse struct {
|
||||
OK bool
|
||||
Data map[string]any
|
||||
ErrCode string
|
||||
ErrText string
|
||||
}
|
||||
|
||||
type helperClientUiaCaller struct {
|
||||
client helperclient.Client
|
||||
}
|
||||
|
||||
type uiaSendMessageConnector struct {
|
||||
config UiaSendMessageAdapterConfig
|
||||
caller UiaHelperCaller
|
||||
}
|
||||
|
||||
func NewSendMessageConnectorFromEnv() SendMessageConnector {
|
||||
mode := strings.TrimSpace(strings.ToLower(os.Getenv(EnvSendConnectorMode)))
|
||||
if mode != "uia_rpa" && mode != "uia-rpa" {
|
||||
return nil
|
||||
}
|
||||
timeoutSeconds := 10
|
||||
if raw := strings.TrimSpace(os.Getenv(EnvSendUIATimeoutSeconds)); raw != "" {
|
||||
if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 {
|
||||
timeoutSeconds = parsed
|
||||
}
|
||||
}
|
||||
return NewUiaSendMessageConnector(UiaSendMessageAdapterConfig{
|
||||
Mode: "enabled",
|
||||
HelperPath: strings.TrimSpace(os.Getenv(EnvSendUIAHelperPath)),
|
||||
TimeoutSeconds: timeoutSeconds,
|
||||
Hwnd: strings.TrimSpace(os.Getenv(EnvSendUIAHwnd)),
|
||||
SendEditorAutomationID: strings.TrimSpace(os.Getenv(EnvSendUIAEditorAutomationID)),
|
||||
SendButtonAutomationID: strings.TrimSpace(os.Getenv(EnvSendUIButtonAutomationID)),
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func NewUiaSendMessageConnector(config UiaSendMessageAdapterConfig, caller UiaHelperCaller) SendMessageConnector {
|
||||
config.Mode = strings.TrimSpace(strings.ToLower(config.Mode))
|
||||
if config.Mode == "" {
|
||||
config.Mode = "disabled"
|
||||
}
|
||||
if config.SendEditorAutomationID == "" {
|
||||
config.SendEditorAutomationID = "rtbSendMessage"
|
||||
}
|
||||
if config.SendButtonAutomationID == "" {
|
||||
config.SendButtonAutomationID = "btnSend"
|
||||
}
|
||||
if caller == nil {
|
||||
timeout := 10 * time.Second
|
||||
if config.TimeoutSeconds > 0 {
|
||||
timeout = time.Duration(config.TimeoutSeconds) * time.Second
|
||||
}
|
||||
caller = helperClientUiaCaller{client: helperclient.Client{HelperPath: config.HelperPath, Timeout: timeout}}
|
||||
}
|
||||
return uiaSendMessageConnector{config: config, caller: caller}
|
||||
}
|
||||
|
||||
func (c uiaSendMessageConnector) ExecuteSendMessage(ctx context.Context, request SendMessageConnectorRequest) (SendMessageConnectorResult, error) {
|
||||
if c.config.Mode != "enabled" && c.config.Mode != "uia_rpa" {
|
||||
result := SendMessageConnectorResult{
|
||||
Accepted: false,
|
||||
Status: "blocked",
|
||||
ErrorCode: "uia_rpa_mode_blocked",
|
||||
ErrorMessage: "A-route UIA/RPA send adapter is disabled",
|
||||
ConnectorMode: "uia-rpa-disabled",
|
||||
}
|
||||
return result, fmt.Errorf(result.ErrorMessage)
|
||||
}
|
||||
if err := validateUiaSendMessageConfig(c.config); err != nil {
|
||||
result := SendMessageConnectorResult{
|
||||
Accepted: false,
|
||||
Status: "blocked",
|
||||
ErrorCode: "uia_rpa_config_missing",
|
||||
ErrorMessage: err.Error(),
|
||||
ConnectorMode: "uia-rpa",
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
if err := validateUiaSendMessageRequest(request); err != nil {
|
||||
return SendMessageConnectorResult{
|
||||
Accepted: false,
|
||||
Status: "failed",
|
||||
ErrorCode: "uia_rpa_invalid_request",
|
||||
ErrorMessage: err.Error(),
|
||||
ConnectorMode: "uia-rpa",
|
||||
}, nil
|
||||
}
|
||||
|
||||
args := map[string]any{
|
||||
"hwnd": c.config.Hwnd,
|
||||
"send_editor_automation_id": c.config.SendEditorAutomationID,
|
||||
"send_button_automation_id": c.config.SendButtonAutomationID,
|
||||
"target_ref": request.TargetRef,
|
||||
"content_text": request.ContentText,
|
||||
"content_sha256": request.ContentSHA256,
|
||||
"idempotency_key_sha256": request.IdempotencyKeySHA256,
|
||||
}
|
||||
response, err := c.caller.CallUiaHelper(ctx, uiaSendMessageOp, args)
|
||||
if err != nil {
|
||||
return SendMessageConnectorResult{
|
||||
Accepted: false,
|
||||
Status: "failed",
|
||||
ErrorCode: "uia_rpa_helper_error",
|
||||
ErrorMessage: err.Error(),
|
||||
ConnectorMode: "uia-rpa",
|
||||
}, err
|
||||
}
|
||||
if !response.OK {
|
||||
code := strings.TrimSpace(response.ErrCode)
|
||||
if code == "" {
|
||||
code = "uia_rpa_helper_rejected"
|
||||
}
|
||||
message := strings.TrimSpace(response.ErrText)
|
||||
if message == "" {
|
||||
message = "UIA helper rejected send action"
|
||||
}
|
||||
return SendMessageConnectorResult{
|
||||
Accepted: false,
|
||||
Status: "failed",
|
||||
ErrorCode: code,
|
||||
ErrorMessage: message,
|
||||
ConnectorMode: "uia-rpa",
|
||||
}, nil
|
||||
}
|
||||
ackRef, _ := response.Data["ack_ref"].(string)
|
||||
if strings.TrimSpace(ackRef) == "" {
|
||||
ackRef = "uia:" + c.config.Hwnd + ":" + c.config.SendButtonAutomationID
|
||||
}
|
||||
return SendMessageConnectorResult{
|
||||
Accepted: true,
|
||||
Status: "accepted",
|
||||
AckRef: ackRef,
|
||||
ConnectorMode: "uia-rpa",
|
||||
ProductionEnabled: true,
|
||||
SideEffects: map[string]any{
|
||||
"sent_message": true,
|
||||
"typed_text": true,
|
||||
"clicked_ui": true,
|
||||
"uploaded_file": false,
|
||||
"sent_file": false,
|
||||
"captured_network": false,
|
||||
"attached_hook": false,
|
||||
"modified_client_data": false,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateUiaSendMessageConfig(config UiaSendMessageAdapterConfig) error {
|
||||
if strings.TrimSpace(config.Hwnd) == "" {
|
||||
return fmt.Errorf("ISPHERE_SEND_UIA_HWND is required for A-route UIA send")
|
||||
}
|
||||
if strings.TrimSpace(config.SendEditorAutomationID) == "" {
|
||||
return fmt.Errorf("send editor automation id is required")
|
||||
}
|
||||
if strings.TrimSpace(config.SendButtonAutomationID) == "" {
|
||||
return fmt.Errorf("send button automation id is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateUiaSendMessageRequest(request SendMessageConnectorRequest) error {
|
||||
if strings.TrimSpace(request.TargetRef) == "" {
|
||||
return fmt.Errorf("target_ref is required")
|
||||
}
|
||||
if strings.TrimSpace(request.ContentText) == "" {
|
||||
return fmt.Errorf("content_text is required")
|
||||
}
|
||||
if strings.TrimSpace(request.ContentSHA256) == "" {
|
||||
return fmt.Errorf("content_sha256 is required")
|
||||
}
|
||||
if strings.TrimSpace(request.IdempotencyKeySHA256) == "" {
|
||||
return fmt.Errorf("idempotency_key_sha256 is required")
|
||||
}
|
||||
if request.ExecutionMode != sendMessageProductionMode {
|
||||
return fmt.Errorf("execution_mode must be production")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c helperClientUiaCaller) CallUiaHelper(ctx context.Context, op string, args map[string]any) (uiaHelperResponse, error) {
|
||||
response, err := c.client.Call(ctx, op, args)
|
||||
if err != nil {
|
||||
return uiaHelperResponse{}, err
|
||||
}
|
||||
out := uiaHelperResponse{OK: response != nil && response.OK}
|
||||
if response != nil {
|
||||
out.Data = decodeRawData(response.Data)
|
||||
if response.Error != nil {
|
||||
out.ErrCode = response.Error.Code
|
||||
out.ErrText = response.Error.Message
|
||||
}
|
||||
}
|
||||
if out.Data == nil {
|
||||
out.Data = map[string]any{}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func uiaHelperResponseFromRaw(data map[string]json.RawMessage, ok bool) uiaHelperResponse {
|
||||
return uiaHelperResponse{OK: ok, Data: decodeRawData(data)}
|
||||
}
|
||||
117
internal/tools/send_message_uia_adapter_test.go
Normal file
117
internal/tools/send_message_uia_adapter_test.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUiaSendMessageConnectorRequiresEnabledModeAndWindowConfig(t *testing.T) {
|
||||
connector := NewUiaSendMessageConnector(UiaSendMessageAdapterConfig{Mode: "disabled"}, &fakeUiaHelperCaller{})
|
||||
result, err := connector.ExecuteSendMessage(context.Background(), SendMessageConnectorRequest{
|
||||
TargetRef: "contact:alice@imopenfire1-lanzhou",
|
||||
ContentText: "hello",
|
||||
ContentSHA256: sha256HexForSendMessageTest("hello"),
|
||||
IdempotencyKeySHA256: sha256HexForSendMessageTest("idem"),
|
||||
ExecutionMode: sendMessageProductionMode,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected disabled connector error")
|
||||
}
|
||||
if result.ErrorCode != "uia_rpa_mode_blocked" || result.ConnectorMode != "uia-rpa-disabled" {
|
||||
t.Fatalf("unexpected result: %+v", result)
|
||||
}
|
||||
|
||||
connector = NewUiaSendMessageConnector(UiaSendMessageAdapterConfig{Mode: "enabled"}, &fakeUiaHelperCaller{})
|
||||
result, err = connector.ExecuteSendMessage(context.Background(), SendMessageConnectorRequest{
|
||||
TargetRef: "contact:alice@imopenfire1-lanzhou",
|
||||
ContentText: "hello",
|
||||
ContentSHA256: sha256HexForSendMessageTest("hello"),
|
||||
IdempotencyKeySHA256: sha256HexForSendMessageTest("idem"),
|
||||
ExecutionMode: sendMessageProductionMode,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected missing config error")
|
||||
}
|
||||
if result.ErrorCode != "uia_rpa_config_missing" {
|
||||
t.Fatalf("unexpected missing config result: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUiaSendMessageConnectorCallsHelperAndMapsAck(t *testing.T) {
|
||||
caller := &fakeUiaHelperCaller{response: uiaHelperResponse{
|
||||
OK: true,
|
||||
Data: map[string]any{
|
||||
"action_mode": "uia_send_message",
|
||||
"target_ref": "contact:alice@imopenfire1-lanzhou",
|
||||
"content_sha256": sha256HexForSendMessageTest("hello uia"),
|
||||
"sent_message": true,
|
||||
"typed_text": true,
|
||||
"clicked_ui": true,
|
||||
"ack_ref": "uia:0x1234:btnSend",
|
||||
},
|
||||
}}
|
||||
connector := NewUiaSendMessageConnector(UiaSendMessageAdapterConfig{
|
||||
Mode: "enabled",
|
||||
Hwnd: "0x1234",
|
||||
SendEditorAutomationID: "rtbSendMessage",
|
||||
SendButtonAutomationID: "btnSend",
|
||||
}, caller)
|
||||
result, err := connector.ExecuteSendMessage(context.Background(), SendMessageConnectorRequest{
|
||||
TargetRef: "contact:alice@imopenfire1-lanzhou",
|
||||
ContentText: "hello uia",
|
||||
ContentSHA256: sha256HexForSendMessageTest("hello uia"),
|
||||
IdempotencyKeySHA256: sha256HexForSendMessageTest("idem-uia"),
|
||||
ExecutionMode: sendMessageProductionMode,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ExecuteSendMessage: %v", err)
|
||||
}
|
||||
if !result.Accepted || result.Status != "accepted" || result.AckRef != "uia:0x1234:btnSend" || result.ConnectorMode != "uia-rpa" || !result.ProductionEnabled {
|
||||
t.Fatalf("unexpected result: %+v", result)
|
||||
}
|
||||
if caller.op != "uia_send_message" {
|
||||
t.Fatalf("helper op = %q", caller.op)
|
||||
}
|
||||
if caller.args["hwnd"] != "0x1234" || caller.args["send_editor_automation_id"] != "rtbSendMessage" || caller.args["send_button_automation_id"] != "btnSend" {
|
||||
t.Fatalf("unexpected helper args: %#v", caller.args)
|
||||
}
|
||||
if caller.args["content_text"] != "hello uia" || caller.args["content_sha256"] != sha256HexForSendMessageTest("hello uia") {
|
||||
t.Fatalf("helper did not receive send content/hash: %#v", caller.args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendMessageConnectorFromEnvBuildsUiaRPAConnector(t *testing.T) {
|
||||
t.Setenv(EnvSendConnectorMode, "uia_rpa")
|
||||
t.Setenv(EnvSendUIAHwnd, "0x1234")
|
||||
t.Setenv(EnvSendUIAEditorAutomationID, "editorA")
|
||||
t.Setenv(EnvSendUIButtonAutomationID, "buttonA")
|
||||
t.Setenv(EnvSendUIAHelperPath, t.TempDir()+"\\missing-helper.exe")
|
||||
|
||||
connector := NewSendMessageConnectorFromEnv()
|
||||
if connector == nil {
|
||||
t.Fatalf("NewSendMessageConnectorFromEnv returned nil for uia_rpa config")
|
||||
}
|
||||
result, err := connector.ExecuteSendMessage(context.Background(), SendMessageConnectorRequest{
|
||||
TargetRef: "contact:alice@imopenfire1-lanzhou",
|
||||
ContentText: "hello",
|
||||
ContentSHA256: sha256HexForSendMessageTest("hello"),
|
||||
IdempotencyKeySHA256: sha256HexForSendMessageTest("idem"),
|
||||
ExecutionMode: sendMessageProductionMode,
|
||||
})
|
||||
if err == nil || result.ErrorCode != "uia_rpa_helper_error" {
|
||||
t.Fatalf("expected configured connector to reach helper layer and fail on missing helper, got result=%+v err=%v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeUiaHelperCaller struct {
|
||||
response uiaHelperResponse
|
||||
err error
|
||||
op string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
func (f *fakeUiaHelperCaller) CallUiaHelper(ctx context.Context, op string, args map[string]any) (uiaHelperResponse, error) {
|
||||
f.op = op
|
||||
f.args = args
|
||||
return f.response, f.err
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
|
||||
@@ -53,6 +53,9 @@ namespace ISphereWinHelper
|
||||
case "probe_send_uia_controls":
|
||||
response = SendConnectorPreflight.ProbeSendUiaControls(requestId, op, opArgs);
|
||||
break;
|
||||
case "uia_send_message":
|
||||
response = UiaSendAction.SendMessage(requestId, op, opArgs);
|
||||
break;
|
||||
default:
|
||||
response = HelperProtocol.Failure(requestId, op, "UNSUPPORTED_OP", "unsupported op: " + op);
|
||||
break;
|
||||
@@ -88,7 +91,7 @@ namespace ISphereWinHelper
|
||||
return new Dictionary<string, object>
|
||||
{
|
||||
{ "helper_name", "ISphereWinHelper" },
|
||||
{ "helper_version", "0.4.0" },
|
||||
{ "helper_version", "0.5.0" },
|
||||
{ "protocol", HelperProtocol.Protocol },
|
||||
{ "runtime", ".NET Framework " + Environment.Version }
|
||||
};
|
||||
@@ -113,4 +116,4 @@ namespace ISphereWinHelper
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
214
native/ISphereWinHelper/UiaSendAction.cs
Normal file
214
native/ISphereWinHelper/UiaSendAction.cs
Normal file
@@ -0,0 +1,214 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Windows.Automation;
|
||||
|
||||
namespace ISphereWinHelper
|
||||
{
|
||||
internal static class UiaSendAction
|
||||
{
|
||||
private const int WM_SETTEXT = 0x000C;
|
||||
private const int BM_CLICK = 0x00F5;
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, string lParam);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
public static Dictionary<string, object> SendMessage(string requestId, string op, Dictionary<string, object> args)
|
||||
{
|
||||
string hwndText = HelperProtocol.GetString(args, "hwnd", "");
|
||||
IntPtr hwnd;
|
||||
if (!WindowScanner.TryParseHwnd(hwndText, out hwnd))
|
||||
{
|
||||
return HelperProtocol.Failure(requestId, op, "WINDOW_NOT_FOUND", "invalid or empty hwnd");
|
||||
}
|
||||
|
||||
string editorAutomationId = HelperProtocol.GetString(args, "send_editor_automation_id", "rtbSendMessage");
|
||||
string buttonAutomationId = HelperProtocol.GetString(args, "send_button_automation_id", "btnSend");
|
||||
string contentText = HelperProtocol.GetString(args, "content_text", "");
|
||||
string contentSha256 = HelperProtocol.GetString(args, "content_sha256", "").Trim().ToLowerInvariant();
|
||||
string targetRef = HelperProtocol.GetString(args, "target_ref", "");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(editorAutomationId))
|
||||
{
|
||||
return HelperProtocol.Failure(requestId, op, "UIA_SEND_CONFIG_MISSING", "send_editor_automation_id is required");
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(buttonAutomationId))
|
||||
{
|
||||
return HelperProtocol.Failure(requestId, op, "UIA_SEND_CONFIG_MISSING", "send_button_automation_id is required");
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(contentText))
|
||||
{
|
||||
return HelperProtocol.Failure(requestId, op, "UIA_SEND_CONTENT_MISSING", "content_text is required");
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(contentSha256) || contentSha256 != Sha256Hex(contentText))
|
||||
{
|
||||
return HelperProtocol.Failure(requestId, op, "UIA_SEND_HASH_MISMATCH", "content_sha256 does not match content_text");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
AutomationElement root = AutomationElement.FromHandle(hwnd);
|
||||
if (root == null)
|
||||
{
|
||||
return HelperProtocol.Failure(requestId, op, "WINDOW_NOT_FOUND", "no UI Automation element for hwnd");
|
||||
}
|
||||
|
||||
AutomationElement editor = FindByAutomationId(root, editorAutomationId);
|
||||
if (editor == null)
|
||||
{
|
||||
return HelperProtocol.Failure(requestId, op, "UIA_SEND_EDITOR_NOT_FOUND", "send editor control not found: " + editorAutomationId);
|
||||
}
|
||||
AutomationElement button = FindByAutomationId(root, buttonAutomationId);
|
||||
if (button == null)
|
||||
{
|
||||
return HelperProtocol.Failure(requestId, op, "UIA_SEND_BUTTON_NOT_FOUND", "send button control not found: " + buttonAutomationId);
|
||||
}
|
||||
|
||||
bool typed = SetElementText(editor, contentText);
|
||||
if (!typed)
|
||||
{
|
||||
return HelperProtocol.Failure(requestId, op, "UIA_SEND_SET_TEXT_FAILED", "could not set send editor text");
|
||||
}
|
||||
|
||||
bool clicked = InvokeElement(button);
|
||||
if (!clicked)
|
||||
{
|
||||
return HelperProtocol.Failure(requestId, op, "UIA_SEND_CLICK_FAILED", "could not invoke send button");
|
||||
}
|
||||
|
||||
string ackRef = "uia:" + WindowScanner.FormatHwnd(hwnd) + ":" + buttonAutomationId + ":" + ShortHash(contentSha256);
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "action_mode", "uia_send_message" },
|
||||
{ "hwnd", WindowScanner.FormatHwnd(hwnd) },
|
||||
{ "target_ref", targetRef ?? "" },
|
||||
{ "content_sha256", contentSha256 },
|
||||
{ "content_length", contentText.Length },
|
||||
{ "editor_automation_id", editorAutomationId },
|
||||
{ "button_automation_id", buttonAutomationId },
|
||||
{ "editor_found", true },
|
||||
{ "button_found", true },
|
||||
{ "typed_text", true },
|
||||
{ "clicked_ui", true },
|
||||
{ "sent_message", true },
|
||||
{ "uploaded_file", false },
|
||||
{ "sent_file", false },
|
||||
{ "captured_network", false },
|
||||
{ "attached_hook", false },
|
||||
{ "modified_client_data", false },
|
||||
{ "ack_ref", ackRef }
|
||||
};
|
||||
return HelperProtocol.Success(requestId, op, data);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return HelperProtocol.Failure(requestId, op, "UIA_SEND_FAILED", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static AutomationElement FindByAutomationId(AutomationElement root, string automationId)
|
||||
{
|
||||
if (root == null || string.IsNullOrWhiteSpace(automationId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
try
|
||||
{
|
||||
return root.FindFirst(TreeScope.Descendants, new PropertyCondition(AutomationElement.AutomationIdProperty, automationId));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool SetElementText(AutomationElement element, string text)
|
||||
{
|
||||
try
|
||||
{
|
||||
object pattern;
|
||||
if (element.TryGetCurrentPattern(ValuePattern.Pattern, out pattern))
|
||||
{
|
||||
((ValuePattern)pattern).SetValue(text);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
int nativeHandle = element.Current.NativeWindowHandle;
|
||||
if (nativeHandle != 0)
|
||||
{
|
||||
SendMessage(new IntPtr(nativeHandle), WM_SETTEXT, IntPtr.Zero, text);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool InvokeElement(AutomationElement element)
|
||||
{
|
||||
try
|
||||
{
|
||||
object pattern;
|
||||
if (element.TryGetCurrentPattern(InvokePattern.Pattern, out pattern))
|
||||
{
|
||||
((InvokePattern)pattern).Invoke();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
int nativeHandle = element.Current.NativeWindowHandle;
|
||||
if (nativeHandle != 0)
|
||||
{
|
||||
SendMessage(new IntPtr(nativeHandle), BM_CLICK, IntPtr.Zero, IntPtr.Zero);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string Sha256Hex(string value)
|
||||
{
|
||||
using (SHA256 sha = SHA256.Create())
|
||||
{
|
||||
byte[] hash = sha.ComputeHash(Encoding.UTF8.GetBytes(value ?? ""));
|
||||
StringBuilder sb = new StringBuilder(hash.Length * 2);
|
||||
foreach (byte b in hash)
|
||||
{
|
||||
sb.Append(b.ToString("x2", CultureInfo.InvariantCulture));
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
private static string ShortHash(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return "";
|
||||
}
|
||||
return value.Length <= 12 ? value : value.Substring(0, 12);
|
||||
}
|
||||
}
|
||||
}
|
||||
118
scripts/extract-frmP2PChat-il-risk.ps1
Normal file
118
scripts/extract-frmP2PChat-il-risk.ps1
Normal file
@@ -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
|
||||
371
scripts/open-offline-chat-window-probe.ps1
Normal file
371
scripts/open-offline-chat-window-probe.ps1
Normal file
@@ -0,0 +1,371 @@
|
||||
param(
|
||||
[string]$HelperExe = "runs/win-helper/ISphereWinHelper.exe",
|
||||
[int]$WaitSeconds = 10,
|
||||
[int]$KeepOpenSeconds = 0,
|
||||
[int]$X = 160,
|
||||
[int]$Y = 120,
|
||||
[int]$Width = 760,
|
||||
[int]$Height = 560,
|
||||
[ValidateSet("Visible", "Offscreen", "Minimized")]
|
||||
[string]$WindowMode = "Visible",
|
||||
[switch]$ProbeClick,
|
||||
[switch]$NoScreenshot,
|
||||
[switch]$NoActivate,
|
||||
[switch]$TopMost
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$repo = Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")
|
||||
$helperPath = Join-Path $repo $HelperExe
|
||||
$runOut = Join-Path $repo "runs\offline-chat-window"
|
||||
New-Item -ItemType Directory -Force -Path $runOut | Out-Null
|
||||
|
||||
function Resolve-RepoPath([string]$PathValue) {
|
||||
if ([System.IO.Path]::IsPathRooted($PathValue)) {
|
||||
return $PathValue
|
||||
}
|
||||
return (Join-Path $repo $PathValue)
|
||||
}
|
||||
|
||||
function Invoke-HelperJson([string]$Op, [hashtable]$OpArgs, [string]$RequestId, [int]$TimeoutMs = 5000) {
|
||||
$request = @{
|
||||
protocol = "isphere.helper.v1"
|
||||
request_id = $RequestId
|
||||
op = $Op
|
||||
timeout_ms = $TimeoutMs
|
||||
args = $OpArgs
|
||||
}
|
||||
$json = $request | ConvertTo-Json -Depth 16 -Compress
|
||||
$output = $json | & $script:HelperPath --json
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "helper exited with code $LASTEXITCODE for op $Op. Output: $output"
|
||||
}
|
||||
try {
|
||||
return $output | ConvertFrom-Json
|
||||
}
|
||||
catch {
|
||||
throw "helper output was not JSON for op $Op`: $output"
|
||||
}
|
||||
}
|
||||
|
||||
function Add-NativeWindowApi {
|
||||
Add-Type @"
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
public static class ISphereOfflineChatProbeWindowApi {
|
||||
[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 Set-ProbeWindowPlacement([string]$HwndText) {
|
||||
$hwndValue = [Convert]::ToInt64(($HwndText -replace "^0x", ""), 16)
|
||||
$hwnd = [IntPtr]::new($hwndValue)
|
||||
$insertAfter = if ($TopMost) { [IntPtr]::new(-1) } else { [IntPtr]::Zero }
|
||||
$flags = 0x0040
|
||||
if ($NoActivate) {
|
||||
$flags = $flags -bor 0x0010
|
||||
}
|
||||
|
||||
if ($WindowMode -eq "Minimized") {
|
||||
[ISphereOfflineChatProbeWindowApi]::ShowWindow($hwnd, 6) | Out-Null
|
||||
return
|
||||
}
|
||||
|
||||
$effectiveX = $X
|
||||
$effectiveY = $Y
|
||||
if ($WindowMode -eq "Offscreen") {
|
||||
$effectiveX = -32000
|
||||
$effectiveY = -32000
|
||||
}
|
||||
|
||||
[ISphereOfflineChatProbeWindowApi]::ShowWindow($hwnd, 9) | Out-Null
|
||||
[ISphereOfflineChatProbeWindowApi]::SetWindowPos($hwnd, $insertAfter, $effectiveX, $effectiveY, $Width, $Height, [uint32]$flags) | Out-Null
|
||||
if (-not $NoActivate) {
|
||||
[ISphereOfflineChatProbeWindowApi]::SetForegroundWindow($hwnd) | 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 - 20), ($ScreenY - 20), 0, 0, $bitmap.Size)
|
||||
$bitmap.Save($OutputPath, [System.Drawing.Imaging.ImageFormat]::Png)
|
||||
}
|
||||
finally {
|
||||
$graphics.Dispose()
|
||||
$bitmap.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function New-Sha256Hex([string]$Value) {
|
||||
$sha = [System.Security.Cryptography.SHA256]::Create()
|
||||
try {
|
||||
$bytes = [System.Text.Encoding]::UTF8.GetBytes($Value)
|
||||
return -join ($sha.ComputeHash($bytes) | ForEach-Object { $_.ToString("x2") })
|
||||
}
|
||||
finally {
|
||||
$sha.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
$script:HelperPath = Resolve-RepoPath $HelperExe
|
||||
if (-not (Test-Path -LiteralPath $script:HelperPath)) {
|
||||
throw "WinHelper not found: $script:HelperPath. Run scripts\build-win-helper.ps1 first."
|
||||
}
|
||||
|
||||
$timestamp = (Get-Date).ToString("yyyyMMdd-HHmmss")
|
||||
$windowTitle = "iSphere Offline Chat Window Probe " + ([guid]::NewGuid().ToString("N").Substring(0, 8))
|
||||
$launcherScript = Join-Path $runOut ("offline-chat-window-probe-$timestamp.ps1")
|
||||
$markerPath = Join-Path $runOut ("offline-chat-window-probe-marker-$timestamp.json")
|
||||
$statePath = Join-Path $runOut ("offline-chat-window-probe-state-$timestamp.json")
|
||||
$dumpFile = Join-Path $runOut ("uia-dump-offline-chat-window-probe-$timestamp.json")
|
||||
$screenshotFile = Join-Path $runOut ("offline-chat-window-probe-$timestamp.png")
|
||||
|
||||
$escapedTitle = $windowTitle.Replace("'", "''")
|
||||
$escapedMarker = $markerPath.Replace("'", "''")
|
||||
$escapedState = $statePath.Replace("'", "''")
|
||||
|
||||
$formScript = @"
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
`$form = New-Object System.Windows.Forms.Form
|
||||
`$form.Name = 'frmP2PChat'
|
||||
`$form.Text = '$escapedTitle'
|
||||
`$form.StartPosition = 'Manual'
|
||||
`$form.Location = New-Object System.Drawing.Point(160, 120)
|
||||
`$form.Size = New-Object System.Drawing.Size(760, 560)
|
||||
`$form.KeyPreview = `$true
|
||||
|
||||
`$layout = New-Object System.Windows.Forms.TableLayoutPanel
|
||||
`$layout.Name = 'chatRootLayout'
|
||||
`$layout.Dock = 'Fill'
|
||||
`$layout.RowCount = 5
|
||||
`$layout.ColumnCount = 1
|
||||
`$layout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute, 38))) | Out-Null
|
||||
`$layout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Percent, 55))) | Out-Null
|
||||
`$layout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute, 42))) | Out-Null
|
||||
`$layout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Percent, 45))) | Out-Null
|
||||
`$layout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute, 48))) | Out-Null
|
||||
`$form.Controls.Add(`$layout)
|
||||
|
||||
`$search = New-Object System.Windows.Forms.TextBox
|
||||
`$search.Name = 'skinAlphaTxt'
|
||||
`$search.Text = 'search contact placeholder'
|
||||
`$search.Dock = 'Fill'
|
||||
`$layout.Controls.Add(`$search, 0, 0)
|
||||
|
||||
`$recv = New-Object System.Windows.Forms.RichTextBox
|
||||
`$recv.Name = 'rtbRecvMessage'
|
||||
`$recv.ReadOnly = `$true
|
||||
`$recv.Text = 'Offline chat window probe. This mimics frmP2PChat for UIA identify, locate, and click verification only.'
|
||||
`$recv.Dock = 'Fill'
|
||||
`$layout.Controls.Add(`$recv, 0, 1)
|
||||
|
||||
`$toolbar = New-Object System.Windows.Forms.FlowLayoutPanel
|
||||
`$toolbar.Name = 'chatToolbar'
|
||||
`$toolbar.Dock = 'Fill'
|
||||
`$toolbar.FlowDirection = 'LeftToRight'
|
||||
`$layout.Controls.Add(`$toolbar, 0, 2)
|
||||
|
||||
`$file = New-Object System.Windows.Forms.Button
|
||||
`$file.Name = 'btnSendFile'
|
||||
`$file.Text = 'Send File'
|
||||
`$file.Width = 92
|
||||
`$file.Height = 30
|
||||
`$file.Add_Click({
|
||||
`$payload = [ordered]@{
|
||||
clicked = `$true
|
||||
action = 'file_button_probe_only'
|
||||
sent_real_message = `$false
|
||||
uploaded_real_file = `$false
|
||||
at = (Get-Date).ToString('o')
|
||||
} | ConvertTo-Json -Compress
|
||||
Set-Content -LiteralPath '$escapedMarker' -Value `$payload -Encoding UTF8
|
||||
})
|
||||
`$toolbar.Controls.Add(`$file)
|
||||
|
||||
`$offline = New-Object System.Windows.Forms.Label
|
||||
`$offline.Name = 'offlineSendBlocker'
|
||||
`$offline.Text = 'Offline probe only: no server connection and no real message is sent.'
|
||||
`$offline.AutoSize = `$true
|
||||
`$offline.Padding = New-Object System.Windows.Forms.Padding(8)
|
||||
`$toolbar.Controls.Add(`$offline)
|
||||
|
||||
`$send = New-Object System.Windows.Forms.RichTextBox
|
||||
`$send.Name = 'rtbSendMessage'
|
||||
`$send.Text = ''
|
||||
`$send.Dock = 'Fill'
|
||||
`$layout.Controls.Add(`$send, 0, 3)
|
||||
|
||||
`$buttonPanel = New-Object System.Windows.Forms.FlowLayoutPanel
|
||||
`$buttonPanel.Name = 'sendButtonPanel'
|
||||
`$buttonPanel.Dock = 'Fill'
|
||||
`$buttonPanel.FlowDirection = 'RightToLeft'
|
||||
`$layout.Controls.Add(`$buttonPanel, 0, 4)
|
||||
|
||||
`$sendButton = New-Object System.Windows.Forms.Button
|
||||
`$sendButton.Name = 'btnSend'
|
||||
`$sendButton.Text = 'Send(S)'
|
||||
`$sendButton.Width = 92
|
||||
`$sendButton.Height = 32
|
||||
`$sendButton.Add_Click({
|
||||
`$payload = [ordered]@{
|
||||
clicked = `$true
|
||||
action = 'send_button_probe_only'
|
||||
text = `$send.Text
|
||||
text_length = `$send.Text.Length
|
||||
sent_real_message = `$false
|
||||
uploaded_real_file = `$false
|
||||
at = (Get-Date).ToString('o')
|
||||
} | ConvertTo-Json -Compress
|
||||
Set-Content -LiteralPath '$escapedMarker' -Value `$payload -Encoding UTF8
|
||||
})
|
||||
`$buttonPanel.Controls.Add(`$sendButton)
|
||||
|
||||
`$form.Add_Shown({
|
||||
`$state = [ordered]@{
|
||||
ok = `$true
|
||||
window_kind = 'offline_chat_window_probe'
|
||||
automation_id = `$form.Name
|
||||
title = `$form.Text
|
||||
process_id = [System.Diagnostics.Process]::GetCurrentProcess().Id
|
||||
marker_path = '$escapedMarker'
|
||||
at = (Get-Date).ToString('o')
|
||||
} | ConvertTo-Json -Compress
|
||||
Set-Content -LiteralPath '$escapedState' -Value `$state -Encoding UTF8
|
||||
})
|
||||
|
||||
[void]`$form.ShowDialog()
|
||||
"@
|
||||
|
||||
Set-Content -LiteralPath $launcherScript -Value $formScript -Encoding UTF8
|
||||
|
||||
$process = Start-Process powershell -ArgumentList @("-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $launcherScript) -PassThru
|
||||
$candidate = $null
|
||||
$scan = $null
|
||||
$deadline = (Get-Date).AddSeconds([Math]::Max(1, $WaitSeconds))
|
||||
do {
|
||||
Start-Sleep -Milliseconds 300
|
||||
$scan = Invoke-HelperJson -Op "scan_windows" -OpArgs @{ include_all_visible = $true } -RequestId "offline-chat-probe-scan"
|
||||
if ($scan.ok -and $scan.data.windows) {
|
||||
$candidate = $scan.data.windows | Where-Object {
|
||||
$_.pid -eq $process.Id -and $_.title -eq $windowTitle
|
||||
} | Select-Object -First 1
|
||||
}
|
||||
} while (-not $candidate -and (Get-Date) -lt $deadline)
|
||||
|
||||
if (-not $candidate) {
|
||||
if ($process -and -not $process.HasExited) {
|
||||
Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
throw "offline chat probe window not found. Last scan: $($scan | ConvertTo-Json -Depth 12 -Compress)"
|
||||
}
|
||||
|
||||
Add-NativeWindowApi
|
||||
Set-ProbeWindowPlacement -HwndText $candidate.hwnd
|
||||
Start-Sleep -Milliseconds 500
|
||||
|
||||
$dump = Invoke-HelperJson -Op "dump_uia" -OpArgs @{
|
||||
hwnd = $candidate.hwnd
|
||||
max_depth = 8
|
||||
max_children = 200
|
||||
include_text = $true
|
||||
} -RequestId "offline-chat-probe-dump-uia"
|
||||
$dump | ConvertTo-Json -Depth 40 | Set-Content -LiteralPath $dumpFile -Encoding UTF8
|
||||
|
||||
$classify = Invoke-HelperJson -Op "probe_send_uia_controls" -OpArgs @{
|
||||
hwnd = $candidate.hwnd
|
||||
max_depth = 8
|
||||
max_children = 200
|
||||
} -RequestId "offline-chat-probe-classify-uia"
|
||||
|
||||
$probeClickedUi = $false
|
||||
$probeTypedText = $false
|
||||
$probeMarkerFound = $false
|
||||
$probeMarkerTextMatches = $false
|
||||
$probeMarker = $null
|
||||
$probeContent = ""
|
||||
$probeAction = $null
|
||||
|
||||
if ($ProbeClick) {
|
||||
$probeContent = "Codex offline chat probe " + ([guid]::NewGuid().ToString("N").Substring(0, 8))
|
||||
$probeHash = New-Sha256Hex $probeContent
|
||||
$probeAction = Invoke-HelperJson -Op "uia_send_message" -OpArgs @{
|
||||
hwnd = $candidate.hwnd
|
||||
send_editor_automation_id = "rtbSendMessage"
|
||||
send_button_automation_id = "btnSend"
|
||||
target_ref = "offline-chat-window-probe"
|
||||
content_text = $probeContent
|
||||
content_sha256 = $probeHash
|
||||
} -RequestId "offline-chat-probe-click"
|
||||
$probeClickedUi = [bool]($probeAction.ok -and $probeAction.data.clicked_ui)
|
||||
$probeTypedText = [bool]($probeAction.ok -and $probeAction.data.typed_text)
|
||||
|
||||
for ($i = 0; $i -lt 30 -and -not (Test-Path -LiteralPath $markerPath); $i++) {
|
||||
Start-Sleep -Milliseconds 100
|
||||
}
|
||||
$probeMarkerFound = Test-Path -LiteralPath $markerPath
|
||||
if ($probeMarkerFound) {
|
||||
$probeMarker = Get-Content -LiteralPath $markerPath -Raw | ConvertFrom-Json
|
||||
$probeMarkerTextMatches = [string]$probeMarker.text -eq $probeContent
|
||||
}
|
||||
}
|
||||
|
||||
$screenshotPathForOutput = $null
|
||||
if (-not $NoScreenshot -and $WindowMode -eq "Visible") {
|
||||
Save-WindowScreenshot -OutputPath $screenshotFile -ScreenX $X -ScreenY $Y -CaptureWidth ($Width + 40) -CaptureHeight ($Height + 40)
|
||||
$screenshotPathForOutput = $screenshotFile
|
||||
}
|
||||
|
||||
if ($KeepOpenSeconds -gt 0) {
|
||||
Start-Sleep -Seconds $KeepOpenSeconds
|
||||
if ($process -and -not $process.HasExited) {
|
||||
Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
$rootAutomationId = ""
|
||||
if ($dump -and $dump.ok -and $dump.data -and $dump.data.root) {
|
||||
$rootAutomationId = [string]$dump.data.root.automation_id
|
||||
}
|
||||
|
||||
$flags = $null
|
||||
if ($classify -and $classify.ok -and $classify.data -and $classify.data.flags) {
|
||||
$flags = $classify.data.flags
|
||||
}
|
||||
|
||||
[ordered]@{
|
||||
ok = [bool]($dump.ok -and $classify.ok)
|
||||
window_kind = "offline_chat_window_probe"
|
||||
process_id = $process.Id
|
||||
hwnd = $candidate.hwnd
|
||||
title = $windowTitle
|
||||
root_automation_id = $rootAutomationId
|
||||
root_control_type = if ($dump.ok) { [string]$dump.data.root.control_type } else { "" }
|
||||
send_editor_found = [bool]($flags -and $flags.has_send_editor)
|
||||
send_button_found = [bool]($flags -and $flags.has_send_button)
|
||||
file_button_found = [bool]($flags -and $flags.has_file_menu)
|
||||
receive_document_found = [bool]($flags -and $flags.has_receive_document)
|
||||
offline_blocker_visible = [bool]($flags -and $flags.offline_blocker_visible)
|
||||
route_hint = if ($flags) { [string]$flags.route_hint } else { "" }
|
||||
probe_click_requested = [bool]$ProbeClick
|
||||
probe_clicked_ui = $probeClickedUi
|
||||
probe_typed_text = $probeTypedText
|
||||
probe_click_marker_found = $probeMarkerFound
|
||||
probe_marker_text_matches = $probeMarkerTextMatches
|
||||
probe_action_ok = if ($probeAction) { [bool]$probeAction.ok } else { $false }
|
||||
probe_action_mode = if ($probeAction -and $probeAction.ok) { [string]$probeAction.data.action_mode } else { "" }
|
||||
sent_real_message = $false
|
||||
uploaded_real_file = $false
|
||||
marker_path = $markerPath
|
||||
state_path = $statePath
|
||||
launcher_script = $launcherScript
|
||||
uia_dump_file = $dumpFile
|
||||
screenshot_file = $screenshotPathForOutput
|
||||
alive_after_return = if ($KeepOpenSeconds -gt 0) { $false } else { $null -ne (Get-Process -Id $process.Id -ErrorAction SilentlyContinue) }
|
||||
} | ConvertTo-Json -Depth 16
|
||||
439
scripts/open-offline-real-client-window.ps1
Normal file
439
scripts/open-offline-real-client-window.ps1
Normal file
@@ -0,0 +1,439 @@
|
||||
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
|
||||
545
scripts/open-offline-real-frmP2PChat.ps1
Normal file
545
scripts/open-offline-real-frmP2PChat.ps1
Normal file
@@ -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 "<MessageCenter>k__BackingField" -Value $fakeMessageCenter | Out-Null
|
||||
$fakeFrmMain = [System.Runtime.Serialization.FormatterServices]::GetUninitializedObject($frmMainType)
|
||||
Set-FieldInHierarchy -Object $mgr -Type $mgrType -Name "<frmMain>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 "<Connection>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 "<IsConnected>k__BackingField" -Value $true | Out-Null
|
||||
Set-FieldInHierarchy -Object $null -Type ($smack.GetType("com.vision.smack.SmarkManager", $true)) -Name "<SmarkManagerInstance>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 "<BaseConfig>k__BackingField" -Value $baseConfig | Out-Null
|
||||
Set-FieldInHierarchy -Object $null -Type $configType -Name "<UserInfo>k__BackingField" -Value $myUser | Out-Null
|
||||
Set-FieldInHierarchy -Object $null -Type $configType -Name "<LoginInfo>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 "<PresenceManager>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 "<P2PChatManager>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 "<RosterManager>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 "<RosterManager>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 "<OtherJid>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
|
||||
}
|
||||
58
scripts/test-extract-frmP2PChat-il-risk.ps1
Normal file
58
scripts/test-extract-frmP2PChat-il-risk.ps1
Normal file
@@ -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
|
||||
62
scripts/test-open-offline-chat-window-probe.ps1
Normal file
62
scripts/test-open-offline-chat-window-probe.ps1
Normal file
@@ -0,0 +1,62 @@
|
||||
param(
|
||||
[string]$HelperExe = "runs/win-helper/ISphereWinHelper.exe"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$repo = Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")
|
||||
$scriptPath = Join-Path $repo "scripts\open-offline-chat-window-probe.ps1"
|
||||
$helperPath = Join-Path $repo $HelperExe
|
||||
|
||||
function Assert-True([bool]$Condition, [string]$Message) {
|
||||
if (-not $Condition) {
|
||||
throw $Message
|
||||
}
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $helperPath)) {
|
||||
& powershell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $repo "scripts\build-win-helper.ps1") | Out-Host
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "build-win-helper.ps1 failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
}
|
||||
|
||||
Assert-True (Test-Path -LiteralPath $scriptPath) "missing script: $scriptPath"
|
||||
|
||||
$output = & powershell -NoProfile -ExecutionPolicy Bypass -File $scriptPath `
|
||||
-HelperExe $HelperExe `
|
||||
-KeepOpenSeconds 2 `
|
||||
-ProbeClick `
|
||||
-NoScreenshot
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "open-offline-chat-window-probe.ps1 exited with code $LASTEXITCODE. Output: $output"
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $output | ConvertFrom-Json
|
||||
}
|
||||
catch {
|
||||
throw "script output was not JSON: $output"
|
||||
}
|
||||
|
||||
Assert-True ([bool]$result.ok) "result.ok must be true"
|
||||
Assert-True ([string]$result.window_kind -eq "offline_chat_window_probe") "window_kind mismatch"
|
||||
Assert-True ([string]$result.root_automation_id -eq "frmP2PChat") "root automation id must be frmP2PChat"
|
||||
Assert-True ([bool]$result.send_editor_found) "rtbSendMessage must be found"
|
||||
Assert-True ([bool]$result.send_button_found) "btnSend must be found"
|
||||
Assert-True ([bool]$result.file_button_found) "btnSendFile or btnFile must be found"
|
||||
Assert-True ([bool]$result.probe_click_marker_found) "probe click marker must be written"
|
||||
Assert-True ([bool]$result.probe_clicked_ui) "probe must click the UI button"
|
||||
Assert-True ([bool]$result.probe_typed_text) "probe must type into the send editor"
|
||||
Assert-True ([bool]$result.probe_marker_text_matches) "probe marker text must match typed text"
|
||||
Assert-True (-not [bool]$result.sent_real_message) "probe must not send a real message"
|
||||
Assert-True (-not [bool]$result.uploaded_real_file) "probe must not upload a real file"
|
||||
|
||||
[ordered]@{
|
||||
ok = $true
|
||||
script = $scriptPath
|
||||
hwnd = $result.hwnd
|
||||
root_automation_id = $result.root_automation_id
|
||||
send_editor_found = $result.send_editor_found
|
||||
send_button_found = $result.send_button_found
|
||||
probe_click_marker_found = $result.probe_click_marker_found
|
||||
} | ConvertTo-Json -Depth 4 -Compress
|
||||
45
scripts/test-open-offline-real-client-window-watchdog.ps1
Normal file
45
scripts/test-open-offline-real-client-window-watchdog.ps1
Normal file
@@ -0,0 +1,45 @@
|
||||
param(
|
||||
[string]$ScriptPath = "scripts/open-offline-real-client-window.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 = "WatchSeconds parameter"; Pattern = '\[int\]\$WatchSeconds' },
|
||||
@{ Name = "PollIntervalMs parameter"; Pattern = '\[int\]\$PollIntervalMs' },
|
||||
@{ Name = "watch loop output"; Pattern = 'watch_iteration_count' },
|
||||
@{ Name = "relaunch counter output"; Pattern = 'relaunch_count' },
|
||||
@{ Name = "move repair counter output"; Pattern = 'repair_count' },
|
||||
@{ Name = "closed window recovery"; Pattern = 'Relaunch-OfflineClient' },
|
||||
@{ Name = "moved window recovery"; Pattern = 'Repair-ClientWindowPlacement' }
|
||||
)
|
||||
|
||||
$missing = @()
|
||||
foreach ($item in $requiredPatterns) {
|
||||
if ($text -notmatch $item.Pattern) {
|
||||
$missing += $item.Name
|
||||
}
|
||||
}
|
||||
|
||||
if ($missing.Count -gt 0) {
|
||||
throw "watchdog contract missing: $($missing -join ', ')"
|
||||
}
|
||||
|
||||
[pscustomobject]@{
|
||||
ok = $true
|
||||
script = $target
|
||||
checked = $requiredPatterns.Count
|
||||
} | ConvertTo-Json -Compress
|
||||
49
scripts/test-open-offline-real-frmP2PChat-contract.ps1
Normal file
49
scripts/test-open-offline-real-frmP2PChat-contract.ps1
Normal file
@@ -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 = "<BaseConfig>k__BackingField" },
|
||||
@{ Name = "remote control config key"; Pattern = "Remote_Control_Visible" },
|
||||
@{ Name = "fake frmMain activation target"; Pattern = "<frmMain>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
|
||||
@@ -1,4 +1,4 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
$repo = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")).Path
|
||||
@@ -100,7 +100,7 @@ func main() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
for _, key := range []string{"ISPHERE_PACKET_LOG_FILE", "ISPHERE_PACKET_LOG_DIR", "ISPHERE_MSGLIB_SIDECAR_EXE", "ISPHERE_MSGLIB_SQLITE_DLL", "ISPHERE_MSGLIB_DB", "ISPHERE_MSGLIB_PASSWORD", "ISPHERE_SEND_FILE_ALLOWED_DIR", "ISPHERE_SEND_FILE_AUDIT_PATH", "ISPHERE_SEND_FILE_IDEMPOTENCY_PATH"} {
|
||||
for _, key := range []string{"ISPHERE_PACKET_LOG_FILE", "ISPHERE_PACKET_LOG_DIR", "ISPHERE_MSGLIB_SIDECAR_EXE", "ISPHERE_MSGLIB_SQLITE_DLL", "ISPHERE_MSGLIB_DB", "ISPHERE_MSGLIB_PASSWORD", "ISPHERE_SEND_PRODUCTION_ENABLED", "ISPHERE_SEND_CONNECTOR_MODE", "ISPHERE_SEND_UIA_HELPER_PATH", "ISPHERE_SEND_UIA_HWND", "ISPHERE_SEND_UIA_EDITOR_AUTOMATION_ID", "ISPHERE_SEND_UIA_BUTTON_AUTOMATION_ID", "ISPHERE_SEND_UIA_TIMEOUT_SECONDS", "ISPHERE_SEND_FILE_ALLOWED_DIR", "ISPHERE_SEND_FILE_AUDIT_PATH", "ISPHERE_SEND_FILE_IDEMPOTENCY_PATH"} {
|
||||
if err := os.Unsetenv(key); err != nil {
|
||||
fail("server/env", err)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
param(
|
||||
param(
|
||||
[string]$HelperExe = "runs/win-helper/ISphereWinHelper.exe",
|
||||
[switch]$SkipBuild
|
||||
)
|
||||
@@ -107,6 +107,7 @@ if ($dumpMissing.ok -or $dumpMissing.error.code -notin @("WINDOW_NOT_FOUND", "UI
|
||||
|
||||
$title = "Codex WinHelper Verify " + [guid]::NewGuid().ToString("N").Substring(0, 8)
|
||||
$formScriptPath = Join-Path $env:TEMP ("codex-winhelper-form-" + [guid]::NewGuid().ToString("N") + ".ps1")
|
||||
$sendActionMarkerPath = Join-Path $env:TEMP ("codex-winhelper-send-marker-" + [guid]::NewGuid().ToString("N") + ".json")
|
||||
$formScript = @"
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
`$form = New-Object System.Windows.Forms.Form
|
||||
@@ -137,6 +138,10 @@ Add-Type -AssemblyName System.Windows.Forms
|
||||
`$button.Name = 'btnSend'
|
||||
`$button.Text = 'Send'
|
||||
`$button.Dock = 'Fill'
|
||||
`$button.Add_Click({
|
||||
`$payload = [ordered]@{ clicked = `$true; text = `$send.Text } | ConvertTo-Json -Compress
|
||||
Set-Content -LiteralPath '$sendActionMarkerPath' -Value `$payload -Encoding UTF8
|
||||
})
|
||||
`$layout.Controls.Add(`$button)
|
||||
`$file = New-Object System.Windows.Forms.Button
|
||||
`$file.Name = 'btnFile'
|
||||
@@ -196,6 +201,42 @@ try {
|
||||
if ($sendUiaProbe.data.safety.sent_message -or $sendUiaProbe.data.safety.uploaded_file -or $sendUiaProbe.data.safety.clicked_ui -or $sendUiaProbe.data.safety.typed_text) {
|
||||
throw "probe_send_uia_controls safety flags failed: $($sendUiaProbe | ConvertTo-Json -Depth 12 -Compress)"
|
||||
}
|
||||
$sendContent = "Codex synthetic UIA send " + [guid]::NewGuid().ToString("N").Substring(0, 8)
|
||||
$sha = [System.Security.Cryptography.SHA256]::Create()
|
||||
try {
|
||||
$bytes = [System.Text.Encoding]::UTF8.GetBytes($sendContent)
|
||||
$sendContentHash = -join ($sha.ComputeHash($bytes) | ForEach-Object { $_.ToString("x2") })
|
||||
}
|
||||
finally {
|
||||
$sha.Dispose()
|
||||
}
|
||||
$sendAction = Invoke-HelperJson @{
|
||||
protocol = "isphere.helper.v1"
|
||||
request_id = "verify-uia-send-message"
|
||||
op = "uia_send_message"
|
||||
timeout_ms = 5000
|
||||
args = @{
|
||||
hwnd = $candidate.hwnd
|
||||
send_editor_automation_id = "rtbSendMessage"
|
||||
send_button_automation_id = "btnSend"
|
||||
target_ref = "synthetic:verify"
|
||||
content_text = $sendContent
|
||||
content_sha256 = $sendContentHash
|
||||
}
|
||||
}
|
||||
if (-not $sendAction.ok -or $sendAction.data.action_mode -ne "uia_send_message" -or -not $sendAction.data.sent_message -or -not $sendAction.data.typed_text -or -not $sendAction.data.clicked_ui) {
|
||||
throw "uia_send_message failed: $($sendAction | ConvertTo-Json -Depth 12 -Compress)"
|
||||
}
|
||||
for ($i = 0; $i -lt 20 -and -not (Test-Path -LiteralPath $sendActionMarkerPath); $i++) {
|
||||
Start-Sleep -Milliseconds 100
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $sendActionMarkerPath)) {
|
||||
throw "uia_send_message did not trigger synthetic send button marker"
|
||||
}
|
||||
$marker = Get-Content -LiteralPath $sendActionMarkerPath -Raw | ConvertFrom-Json
|
||||
if (-not $marker.clicked -or $marker.text -ne $sendContent) {
|
||||
throw "uia_send_message marker mismatch: $($marker | ConvertTo-Json -Compress)"
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if ($process -and -not $process.HasExited) {
|
||||
@@ -204,6 +245,9 @@ finally {
|
||||
if (Test-Path -LiteralPath $formScriptPath) {
|
||||
Remove-Item -LiteralPath $formScriptPath -Force
|
||||
}
|
||||
if (Test-Path -LiteralPath $sendActionMarkerPath) {
|
||||
Remove-Item -LiteralPath $sendActionMarkerPath -Force
|
||||
}
|
||||
}
|
||||
|
||||
[ordered]@{
|
||||
@@ -214,5 +258,6 @@ finally {
|
||||
runtime_probe_target_count = $runtimeProbe.data.target_count
|
||||
send_entrypoint_probe_mode = $entrypointProbe.data.probe_mode
|
||||
synthetic_send_uia_route_hint = $sendUiaProbe.data.flags.route_hint
|
||||
synthetic_uia_send_action = $sendAction.data.action_mode
|
||||
uia_available = $selfCheck.data.uia_available
|
||||
} | ConvertTo-Json -Depth 4 -Compress
|
||||
|
||||
Reference in New Issue
Block a user