@forsion/tangu-computer-use 0.6.1 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/UPSTREAM.md +3 -1
- package/icon.png +0 -0
- package/main.js +15 -0
- package/manifest.json +3 -3
- package/native/macos/bridge.swift +171 -7
- package/native/windows/bridge-rs/Cargo.lock +1 -0
- package/native/windows/bridge-rs/Cargo.toml +11 -0
- package/native/windows/bridge-rs/src/agent_marker.rs +97 -0
- package/native/windows/bridge-rs/src/lib.rs +4 -0
- package/native/windows/bridge-rs/src/main.rs +32 -0
- package/native/windows/bridge-rs/src/recorder.rs +1865 -0
- package/native/windows/bridge-rs/src/recorder_logic.rs +851 -0
- package/native/windows/bridge-rs/tests/recorder_logic_tests.rs +314 -0
- package/package.json +1 -1
- package/prebuilt/linux/x64/linux-bridge +0 -0
- package/prebuilt/macos/arm64/bridge +0 -0
- package/prebuilt/macos/arm64/tangu-computer-use.app.json +4 -4
- package/prebuilt/macos/arm64/tangu-computer-use.app.zip +0 -0
- package/prebuilt/macos/x64/bridge +0 -0
- package/prebuilt/macos/x64/tangu-computer-use.app.json +4 -4
- package/prebuilt/macos/x64/tangu-computer-use.app.zip +0 -0
- package/prebuilt/windows/windows-bridge.exe +0 -0
- package/scripts/no-foreground.check.mjs +14 -1
- package/scripts/probe-recorder-windows.mjs +356 -0
- package/skills/computer-use/SKILL.md +4 -0
- package/src/foregroundNote.ts +16 -0
- package/src/index.ts +2 -0
- package/src/tools.ts +4 -3
- package/src/vendor/platform/macos/helper.ts +1 -1
- package/tangu-plugins/computer-use/dist/foregroundNote.js +10 -1
- package/tangu-plugins/computer-use/dist/index.js +13 -2
- package/tangu-plugins/computer-use/tangu-plugin.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
# 更新日志
|
|
2
2
|
|
|
3
|
+
## 0.6.2 — 2026-10-06
|
|
4
|
+
|
|
5
|
+
- 新增「侧边拼接」(macOS,协议 14):在 Forsion 命令面板里运行「把对话贴到应用旁边」,从屏上的窗口里挑一个,对话面板就贴在它旁边。拖动、移动、缩放目标窗口时面板跟着走;拖面板或拉它贴着目标的那条边,目标窗口也跟着动。目标最小化、隐藏、去了别的桌面或全屏时面板藏起,回来再贴上;目标关掉时面板一并收起。
|
|
6
|
+
- 面板里是一段新对话,每条消息默认带上这个 App 的引用(可以去掉)。agent 据此把它当作默认的操作对象,用电脑操作的工具直接看、直接操作它。点进面板时,目标 App 里划选的文字(没有文字选区时是列表里选中的项)自动挂成引用;密码框一律不读。
|
|
7
|
+
- helper 新增三条只读命令:`dockCandidates`(屏上可贴靠的窗口,前→后)、`dockProbe`(一个窗口此刻的位置与前台 App,只读 CGWindowList、不碰辅助功能,桌面端约 60Hz 轮询)、`selection`(焦点元素的划线文本或选中项);`setWindowFrame` 的宽高改为可省(只挪不缩放)。
|
|
8
|
+
- 已知限制:跨进程的窗口没法真正挂在一起,面板的层级靠「目标 App 到前台时把面板提上来」近似;Windows 暂不支持(那边的 helper 桌面端连不上)。
|
|
9
|
+
|
|
10
|
+
Docked chat panel (macOS, protocol 14). Run "Dock a chat beside an app" from the Forsion command palette and pick a window on screen. The chat panel docks beside it and follows when you drag, move or resize that window. Dragging the panel, or the panel edge that touches the window, moves or resizes the window too. The panel hides while the window is minimized, hidden, on another desktop or in full screen, and docks again when it comes back. Closing the window closes the panel. The panel holds a new chat whose messages reference that app by default (you can remove the reference), so the agent treats it as the default target and can see and operate it with the Computer Use tools. When you click into the panel, the text you selected in that app, or the items selected in a list when there's no text selection, is attached as a quote. Password fields are never read. The helper adds three read-only commands: `dockCandidates` (windows on screen you can dock beside, front to back), `dockProbe` (one window's position and the frontmost app, read from CGWindowList without Accessibility, polled by Desktop at about 60Hz) and `selection` (selected text or selected items of the focused element). `setWindowFrame` now accepts a missing width and height, which moves the window without resizing it. Known limits: windows of different apps can't truly be attached, so the panel's stacking is approximated by raising it whenever the target app comes to the front. Windows isn't supported yet, because Desktop can't reach the helper there.
|
|
11
|
+
|
|
12
|
+
- 目标窗口是 Forsion 自己(拉起引擎的那个桌面应用)时,`find_roots` / `observe_ui` / `act_ui` 的结果末尾附一行 `[forsion]` 提示,让模型改用界面命令(`list_ui_commands` → `run_ui_command` / `set_ui_setting`)。只提示不拦截:没有对应界面命令的地方仍可操作。技能说明同步加了这一条。
|
|
13
|
+
- `npm run check:no-foreground` 增加这条提示的 9 项判据。
|
|
14
|
+
|
|
15
|
+
When the target window belongs to Forsion itself (the desktop app that launched the engine), `find_roots`, `observe_ui` and `act_ui` results now end with a `[forsion]` line pointing the model to the UI tools (`list_ui_commands`, then `run_ui_command` / `set_ui_setting`). It is a hint, not a block: controls without a UI command can still be driven. The skill text carries the same rule.
|
|
16
|
+
|
|
17
|
+
- 「电脑历史」采集支持 Windows(协议仍是 13,与 macOS 同一套 `recordSubscribe` 订阅格式)。新增 `windows-bridge.exe serve --pipe <名字>` 常驻模式:采集器跑在独立进程里,监听只有当前用户和 SYSTEM 能连、拒绝远程客户端的命名管道,最后一个订阅者断开 60 秒后自行退出;`windows-bridge.exe recorder-protocol` 打印协议号。原有的 stdio 助手和它的协议版本不变。
|
|
18
|
+
- 记录内容与 macOS 相同:前台 App 切换(exe 名 + 文件说明里的显示名)、窗口标题、浏览器地址栏网址(去掉查询串)、焦点可编辑输入框里新打的字(UI Automation 的 Value / Text 模式)、点击的控件类型和按钮名、Ctrl / Win 组合快捷键。不截图,不录原始按键;键盘钩子只用来判断「有没有在打字」和识别组合键。锁屏、睡眠期间暂停。
|
|
19
|
+
- 始终不记录:密码框(UI Automation 的 IsPassword,报出前再复核一次)、密码管理器与 Windows 凭据界面、无痕 / InPrivate 窗口。Edge 与 Firefox 系的无痕窗口按标题标记识别;Chrome 等 Chromium 系浏览器在 Windows 上无痕窗口的标题没有标记,只能看工具栏上的无痕按钮,遍历没走完时这个窗口先按无痕处理(只留一条不带标题的切换),之后自动重走,最多 5 次。
|
|
20
|
+
- agent 通过 Computer Use 操作(`act`、`actBatch`、`focusWindow`、`openBrowserLocation`)期间及之后 750 毫秒,stdio 助手持有命名互斥体 `Local\tangu-computer-use-agent-active`,采集器据此把这段时间的事件标成 agent 所为。
|
|
21
|
+
- 已知限制:与 macOS 相同,管道只校验同一 Windows 用户,不校验对端身份。第一次读某个 Chromium 窗口时,Chrome 会在那一刻打开自己的无障碍支持,这一次可能要 1 秒左右。
|
|
22
|
+
- 新增 `scripts/probe-recorder-windows.mjs` 与 CI 工作流 `windows-recorder-probe`:在 Windows 真桌面上操作记事本、Chrome、Edge,核对事件与隐私边界。
|
|
23
|
+
|
|
24
|
+
Computer History recording now works on Windows too, with the same `recordSubscribe` format and protocol 13 as macOS. The new `windows-bridge.exe serve --pipe <name>` mode runs the recorder as its own resident process. It listens on a named pipe that only the current user and SYSTEM can open, rejects remote clients, and exits 60 seconds after its last subscriber disconnects. `windows-bridge.exe recorder-protocol` prints the protocol number. The stdio helper and its protocol version are unchanged. The recorder captures the same things as on macOS: app switches (exe name plus the display name from the file description), window titles, browser URLs from the address bar without query strings, newly typed text in the focused editable field (UI Automation Value or Text pattern), the type and label of clicked controls, and Ctrl and Win shortcuts. It takes no screenshots and records no raw keystrokes. The keyboard hook only tells whether someone is typing and recognizes key combinations. Recording pauses while the session is locked or asleep.
|
|
25
|
+
|
|
26
|
+
Password fields (UI Automation IsPassword, checked again before text is reported), password managers, Windows credential prompts and private or InPrivate windows are never recorded. Private windows in Edge and Firefox-based browsers are recognized by their title. On Windows, private windows in Chrome and other Chromium browsers have no marker in the title, so the recorder looks for the private-mode button in the toolbar. Until that walk finishes, the window is treated as private and leaves only an untitled switch, and the walk is retried automatically up to 5 times. While an agent acts through Computer Use (`act`, `actBatch`, `focusWindow`, `openBrowserLocation`) and for 750 ms afterwards, the stdio helper holds the named mutex `Local\tangu-computer-use-agent-active`, and the recorder marks events from that time as agent actions.
|
|
27
|
+
|
|
28
|
+
Known limitations: as on macOS, the pipe only checks that the client runs as the same Windows user. It doesn't check who the client is. The first time the recorder reads a Chromium window, Chrome turns on its accessibility support, which can take about a second once. The new `scripts/probe-recorder-windows.mjs` and the `windows-recorder-probe` CI workflow drive Notepad, Chrome and Edge on a real Windows desktop and check the events and privacy limits.
|
|
29
|
+
|
|
3
30
|
## 0.6.1 — 2026-10-02
|
|
4
31
|
|
|
5
32
|
- 修复:在 Forsion 里停用再启用 Computer Use 插件(引擎不重启的热插拔)时,每次启用都会多挂一组进程退出监听,开关拨多了会报 `MaxListenersExceededWarning`,退出时收尾也会重复执行。现在停用会摘掉这组监听,再启用也只挂一组。
|
package/UPSTREAM.md
CHANGED
|
@@ -114,7 +114,7 @@ Accessibility/Screen-Recording (TCC) grants to them.
|
|
|
114
114
|
> stays put, everything else goes to `~/Applications` (no admin needed). Users whose `/Applications`
|
|
115
115
|
> is not writable will be asked to re-grant TCC once, because the path moved.
|
|
116
116
|
|
|
117
|
-
**Helper protocol version — bumped by us.** Upstream v0.5.1 is still at 6; we run **
|
|
117
|
+
**Helper protocol version — bumped by us.** Upstream v0.5.1 is still at 6; we run **14**, in two places that must
|
|
118
118
|
always match: `native/macos/bridge.swift` (`private let protocolVersion`) and
|
|
119
119
|
`src/vendor/platform/macos/helper.ts` (`HELPER_PROTOCOL_VERSION`). Bump it whenever the helper gains a
|
|
120
120
|
command **or changes native behaviour** — `src/onboarding.ts` now notices a stale binary *on disk*, but a
|
|
@@ -275,4 +275,6 @@ button; `install.sh dev|prod` stays as the developer loop for iterating on the b
|
|
|
275
275
|
`check.mjs` does no layout, so CSS regressions are only visible there.
|
|
276
276
|
|
|
277
277
|
- Computer History recorder (protocol 13, 0.6.0): `native/macos/activity_recorder.swift` + `activity_recorder_tests.swift` (`check:recorder-logic`), listed in `macosSourcePaths` before `bridge.swift`. Three `bridge.swift` hooks to replay after a resync: `processClient` hands a `recordSubscribe` line to `subscribeRecorder()` and, once subscribed, only waits for EOF and then calls `ActivityRecorder.shared.unsubscribe`; `handleRequest` wraps `act` / `actBatch` / `focusWindow` / `restoreUserFocus` in `agentEnter()` / `agentLeave()` (events become `origin:"agent"`) and rejects `recordSubscribe` on stdin; `diagnostics` reports `recorderSubscribers`. `grep -n "ActivityRecorder" native/macos/bridge.swift` must find them. The recorder never calls `ensureEnhancedAccessibility`.
|
|
278
|
+
- Computer History recorder, **Windows** (recordSubscribe contract 13): `native/windows/bridge-rs/src/recorder.rs` (pipe server, hook thread, UIA thread — `#[cfg(windows)]`), `recorder_logic.rs` (platform-neutral, tests in `tests/recorder_logic_tests.rs`, run anywhere with `cargo test`) and `agent_marker.rs`. Replay after a resync: `lib.rs` declares the three modules; `main.rs` branches to `recorder-protocol` / `serve --pipe` **before** `window::start_root_event_journal()` (serve mode must not start the journal or the stdio loop) and wraps `act` / `actBatch` / `focusWindow` / `openBrowserLocation` in `agent_marker::enter()` (a session-local named mutex, held 750ms past the command; the serve process probes it to mark `origin:"agent"` — actor and recorder are different processes on Windows). `Cargo.toml` adds the `implement` feature + a direct `windows-core` dependency (the `#[implement]` macro expands to `windows_core::…`) and the Security / Pipes / IO / RemoteDesktop / StationsAndDesktops / LibraryLoader / Storage features. `grep -n "agent_marker\|serve_recorder" native/windows/bridge-rs/src/main.rs` must find them. The **stdio** protocol number (`protocol::PROTOCOL_VERSION`, vendor TS checks it with `!==`) is untouched; `RECORDER_PROTOCOL` is a separate constant. Desktop runs serve from its own hashed copy of the exe (never the `~/.pi/...` path setup-helper overwrites). Real-machine check: `.github/workflows/windows-recorder-probe.yml` (push `ci/windows-recorder*`) runs `scripts/probe-recorder-windows.mjs`.
|
|
279
|
+
- App side panel (protocol 14, 0.6.2): three read-only commands in `bridge.swift` under `MARK: - Tangu:App 侧边拼接` — `dockCandidates` (on-screen layer-0 windows of `.regular` apps, front to back), `dockProbe` (one window's live CGWindowList bounds + `frontPid`, no AX, polled ~60Hz by the desktop) and `selection` (focused element's selected text, walking up to the AXWebArea and falling back to `AXSelectedTextMarkerRange`; else selected rows/children; secure fields — and secure descendants of selected rows — never read; with `windowId` it reads only when that window is the focused one). Plus `setWindowFrame` now treats `width`/`height` as optional (move without resize). Dispatch cases sit next to `getMousePosition`.
|
|
278
280
|
- Mini Panel foreground activity: `native/macos/foreground_activity.swift` emits a bounded, data-only lease beside the daemon socket. Hooked into actual HID/focus delivery and recursive input scopes, not the requested delivery policy. Included in both native-build and source-install inputs. No change to upstream tool schemas or input behavior.
|
package/icon.png
CHANGED
|
Binary file
|
package/main.js
CHANGED
|
@@ -534,6 +534,21 @@ ctx.registerView({
|
|
|
534
534
|
},
|
|
535
535
|
})
|
|
536
536
|
|
|
537
|
+
// ── 侧边拼接:把一扇对话面板贴在某个 App 窗口旁边,拖动 / 移动 / 缩放都像一个窗口 ──────────────
|
|
538
|
+
// 面板、跟随、读划线都在宿主(electron/appDock.ts,经本包 helper 的 dockProbe / selection 命令);这里只是入口。
|
|
539
|
+
// 面板打开后先列出屏上的窗口让用户挑,所以命令本身不带目标。老宿主没有这条 IPC、或不是 macOS(跟随靠 helper 的
|
|
540
|
+
// CGWindowList / AX,宿主那边直接回 unsupported_platform)→ 不注册,免得给出一个点了没反应的命令。
|
|
541
|
+
const dockOpen = globalThis.window && window.tangu && window.tangu.platform === 'darwin' && window.tangu.appDockOpen
|
|
542
|
+
if (dockOpen) {
|
|
543
|
+
const en = () => (ctx.getLocale ? ctx.getLocale() : 'zh') === 'en'
|
|
544
|
+
ctx.registerCommand({
|
|
545
|
+
id: 'dock-chat',
|
|
546
|
+
title: () => (en() ? 'Dock a chat beside an app' : '把对话贴到应用旁边'),
|
|
547
|
+
keywords: 'side panel dock attach app window chat 侧边 贴边 拼接 应用 窗口 对话 tiebian pinjie',
|
|
548
|
+
run: () => { void dockOpen() },
|
|
549
|
+
})
|
|
550
|
+
}
|
|
551
|
+
|
|
537
552
|
// 纯函数暴露给 check.mjs(宿主装载时 ctx 存在,check 里只取这个挂点)。
|
|
538
553
|
globalThis.__CU_LIVE_TEST__ = {
|
|
539
554
|
explain, ago, describe, PLUGIN_ID, VIEW_ID, TICK_MS, IDLE_TICK_MS,
|
package/manifest.json
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
"id": "tangu-computer-use",
|
|
3
3
|
"name": "电脑操作",
|
|
4
4
|
"nameEn": "Computer Use",
|
|
5
|
-
"version": "0.6.
|
|
5
|
+
"version": "0.6.2",
|
|
6
6
|
"apiVersion": 1,
|
|
7
7
|
"minAppVersion": "2.11.2",
|
|
8
|
-
"description": "让 agent 观察并操作桌面应用:看屏幕、点按、输入、滚动(macOS / Windows / Linux)。捆绑包内含引擎侧的 12
|
|
9
|
-
"descriptionEn": "Let the agent observe and control desktop apps: see the screen, click, type and scroll (macOS / Windows / Linux). The bundle includes 12 engine tools, a companion skill
|
|
8
|
+
"description": "让 agent 观察并操作桌面应用:看屏幕、点按、输入、滚动(macOS / Windows / Linux)。捆绑包内含引擎侧的 12 个工具、配套技能,一个「被操控的窗口」实时画面视图,以及「把对话贴到应用旁边」的侧边面板(macOS)—— agent 动手时,被操控的那个窗口会亮起边缘光效,画面同步出现在 Agent Desk 上。",
|
|
9
|
+
"descriptionEn": "Let the agent observe and control desktop apps: see the screen, click, type and scroll (macOS / Windows / Linux). The bundle includes 12 engine tools, a companion skill, a live view of the window being controlled and a chat panel you can dock beside any app (macOS). While the agent works, that window gets a glowing edge and its picture appears on Agent Desk.",
|
|
10
10
|
"main": "main.js",
|
|
11
11
|
"onboarding": {
|
|
12
12
|
"intro": "工具、技能与实时画面视图都在这个包里,装一次就位。本机 helper 随包自带、首次使用时自动装好;你只需要在 macOS 系统设置里授予「辅助功能」与「屏幕录制」——授权给的是那个 helper,不是 Forsion 主程序。",
|
|
@@ -369,7 +369,7 @@ final class InputSuppressionGuard {
|
|
|
369
369
|
}
|
|
370
370
|
|
|
371
371
|
final class Bridge {
|
|
372
|
-
private let protocolVersion =
|
|
372
|
+
private let protocolVersion = 14
|
|
373
373
|
private let refStore = AXRefStore()
|
|
374
374
|
private let inputSuppressionGuard = InputSuppressionGuard()
|
|
375
375
|
private let physicalInputLock = NSRecursiveLock()
|
|
@@ -791,6 +791,12 @@ final class Bridge {
|
|
|
791
791
|
return try axReadText(request)
|
|
792
792
|
case "getMousePosition":
|
|
793
793
|
return getMousePosition()
|
|
794
|
+
case "dockCandidates":
|
|
795
|
+
return dockCandidates(request)
|
|
796
|
+
case "dockProbe":
|
|
797
|
+
return try dockProbe(request)
|
|
798
|
+
case "selection":
|
|
799
|
+
return try selection(request)
|
|
794
800
|
case "recordSubscribe":
|
|
795
801
|
// 只在 serve socket 的专用连接上有意义(processClient 截走了那条路);stdin 模式没有能长开的连接。
|
|
796
802
|
throw BridgeFailure(message: "recordSubscribe needs a dedicated connection to the serve socket", code: "unsupported")
|
|
@@ -1271,20 +1277,29 @@ final class Bridge {
|
|
|
1271
1277
|
}
|
|
1272
1278
|
let x = try doubleArg(request, "x")
|
|
1273
1279
|
let y = try doubleArg(request, "y")
|
|
1274
|
-
|
|
1275
|
-
|
|
1280
|
+
// Tangu(协议 14):width/height 可一起省 = 只挪不缩放。贴边面板拖动时每帧都在挪目标窗口,
|
|
1281
|
+
// 同尺寸再写一遍 kAXSize 会让一些 App 每帧重排一次。参数全部校验完才动窗口:报错了就不该已经挪过。
|
|
1282
|
+
var size: CGSize?
|
|
1283
|
+
if request["width"] != nil || request["height"] != nil {
|
|
1284
|
+
size = CGSize(width: max(100.0, try doubleArg(request, "width")), height: max(80.0, try doubleArg(request, "height")))
|
|
1285
|
+
}
|
|
1276
1286
|
var origin = CGPoint(x: x, y: y)
|
|
1277
|
-
|
|
1278
|
-
guard let originValue = AXValueCreate(.cgPoint, &origin), let sizeValue = AXValueCreate(.cgSize, &size) else {
|
|
1287
|
+
guard let originValue = AXValueCreate(.cgPoint, &origin) else {
|
|
1279
1288
|
throw BridgeFailure(message: "Failed to create AX frame values", code: "frame_value_failed")
|
|
1280
1289
|
}
|
|
1281
1290
|
let positionStatus = AXUIElementSetAttributeValue(window, kAXPositionAttribute as CFString, originValue)
|
|
1282
|
-
|
|
1291
|
+
var sizeStatus: AXError?
|
|
1292
|
+
if var size {
|
|
1293
|
+
guard let sizeValue = AXValueCreate(.cgSize, &size) else {
|
|
1294
|
+
throw BridgeFailure(message: "Failed to create AX frame values", code: "frame_value_failed")
|
|
1295
|
+
}
|
|
1296
|
+
sizeStatus = AXUIElementSetAttributeValue(window, kAXSizeAttribute as CFString, sizeValue)
|
|
1297
|
+
}
|
|
1283
1298
|
let frame = frameForWindow(window)
|
|
1284
1299
|
return [
|
|
1285
1300
|
"ok": positionStatus == .success || sizeStatus == .success,
|
|
1286
1301
|
"positionStatus": Int(positionStatus.rawValue),
|
|
1287
|
-
"sizeStatus": Int(
|
|
1302
|
+
"sizeStatus": sizeStatus.map { Int($0.rawValue) } ?? NSNull(),
|
|
1288
1303
|
"framePoints": ["x": frame.origin.x, "y": frame.origin.y, "w": frame.width, "h": frame.height],
|
|
1289
1304
|
]
|
|
1290
1305
|
}
|
|
@@ -3038,6 +3053,155 @@ final class Bridge {
|
|
|
3038
3053
|
return ["x": position.x, "y": position.y]
|
|
3039
3054
|
}
|
|
3040
3055
|
|
|
3056
|
+
// MARK: - Tangu:App 侧边拼接(协议 14)
|
|
3057
|
+
// Forsion 的对话面板贴在某个 App 窗口旁边、像一个窗口那样一起动。跟随走 CGWindowList —— WindowServer 的
|
|
3058
|
+
// 实时 bounds,用户拖窗口的过程中也在变(AX 的 kAXPosition 不保证拖动中更新);一次 ~0.1ms、零 AX 往返,
|
|
3059
|
+
// 桌面端按 60Hz 轮询 dockProbe。反方向(拖面板带着目标走)复用 setWindowFrame。
|
|
3060
|
+
|
|
3061
|
+
/// 可贴靠的窗口,前→后(z 序:最上面那个多半就是用户刚在用的)。只收普通 App(.regular)的屏上 layer-0 窗口;
|
|
3062
|
+
/// excludePids = 调用方自己(Forsion 的窗口不该出现在候选里)。
|
|
3063
|
+
private func dockCandidates(_ request: [String: Any]) -> [String: Any] {
|
|
3064
|
+
let excluded = Set(((request["excludePids"] as? [Any]) ?? []).compactMap { ($0 as? NSNumber)?.int32Value } + [getpid()])
|
|
3065
|
+
var regular: [Int32: NSRunningApplication] = [:]
|
|
3066
|
+
for app in NSWorkspace.shared.runningApplications where app.activationPolicy == .regular {
|
|
3067
|
+
regular[app.processIdentifier] = app
|
|
3068
|
+
}
|
|
3069
|
+
let entries = (CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]]) ?? []
|
|
3070
|
+
var windows: [[String: Any]] = []
|
|
3071
|
+
for entry in entries {
|
|
3072
|
+
guard let pid = (entry[kCGWindowOwnerPID as String] as? NSNumber)?.int32Value,
|
|
3073
|
+
!excluded.contains(pid), let app = regular[pid],
|
|
3074
|
+
((entry[kCGWindowLayer as String] as? NSNumber)?.intValue ?? 0) == 0,
|
|
3075
|
+
let windowId = (entry[kCGWindowNumber as String] as? NSNumber)?.uint32Value,
|
|
3076
|
+
let boundsDict = entry[kCGWindowBounds as String] as? [String: Any],
|
|
3077
|
+
let bounds = CGRect(dictionaryRepresentation: boundsDict as CFDictionary),
|
|
3078
|
+
bounds.width >= 200, bounds.height >= 150
|
|
3079
|
+
else { continue }
|
|
3080
|
+
// kCGWindowName 要屏幕录制权限;没有就退到 AX 标题(只要辅助功能)。
|
|
3081
|
+
var title = (entry[kCGWindowName as String] as? String) ?? ""
|
|
3082
|
+
if title.isEmpty, let window = windowElement(pid: pid, windowId: windowId) {
|
|
3083
|
+
title = stringAttribute(window, attribute: kAXTitleAttribute as CFString) ?? ""
|
|
3084
|
+
}
|
|
3085
|
+
var item: [String: Any] = [
|
|
3086
|
+
"pid": Int(pid), "windowId": Int(windowId), "app": app.localizedName ?? processName(pid: pid) ?? "", "title": title,
|
|
3087
|
+
"x": bounds.origin.x, "y": bounds.origin.y, "w": bounds.width, "h": bounds.height,
|
|
3088
|
+
]
|
|
3089
|
+
if let bundleId = app.bundleIdentifier { item["bundleId"] = bundleId }
|
|
3090
|
+
windows.append(item)
|
|
3091
|
+
if windows.count == 24 { break }
|
|
3092
|
+
}
|
|
3093
|
+
return ["windows": windows]
|
|
3094
|
+
}
|
|
3095
|
+
|
|
3096
|
+
/// 一个窗口此刻的位置/在不在屏上 + 前台 App 的 pid。只读 CGWindowList,不碰 AX。
|
|
3097
|
+
/// onScreen=false:最小化、App 被隐藏、在别的桌面(Space)。exists=false:窗口已关。
|
|
3098
|
+
private func dockProbe(_ request: [String: Any]) throws -> [String: Any] {
|
|
3099
|
+
let windowId = UInt32(try intArg(request, "windowId"))
|
|
3100
|
+
let frontPid = Int(NSWorkspace.shared.frontmostApplication?.processIdentifier ?? 0)
|
|
3101
|
+
// ⚠️别用 CGWindowListCreateDescriptionFromArray:对别的进程的窗口恒返回空数组(07-29 实测)。
|
|
3102
|
+
// ⚠️.optionIncludingWindow 单用只回**屏上**的窗口:最小化 / App 被隐藏 / 在别的桌面时回空(10-02 实测),
|
|
3103
|
+
// 拿空当「窗口关了」会把贴边面板一并关掉。回空时再到全量表里找一次(几 ms,只在窗口不在屏上时走;桌面端此时也降频)。
|
|
3104
|
+
let match = { (list: [[String: Any]]?) in list?.first(where: { ($0[kCGWindowNumber as String] as? NSNumber)?.uint32Value == windowId }) }
|
|
3105
|
+
guard let entry = match(CGWindowListCopyWindowInfo([.optionIncludingWindow], windowId) as? [[String: Any]])
|
|
3106
|
+
?? match(CGWindowListCopyWindowInfo([.optionAll], kCGNullWindowID) as? [[String: Any]]),
|
|
3107
|
+
let boundsDict = entry[kCGWindowBounds as String] as? [String: Any],
|
|
3108
|
+
let bounds = CGRect(dictionaryRepresentation: boundsDict as CFDictionary)
|
|
3109
|
+
else {
|
|
3110
|
+
return ["exists": false, "frontPid": frontPid]
|
|
3111
|
+
}
|
|
3112
|
+
return [
|
|
3113
|
+
"exists": true,
|
|
3114
|
+
"onScreen": (entry[kCGWindowIsOnscreen as String] as? NSNumber)?.boolValue ?? false,
|
|
3115
|
+
"x": bounds.origin.x, "y": bounds.origin.y, "w": bounds.width, "h": bounds.height,
|
|
3116
|
+
"frontPid": frontPid,
|
|
3117
|
+
]
|
|
3118
|
+
}
|
|
3119
|
+
|
|
3120
|
+
/// 目标 App 此刻的划线文本,没有就给选中项(列表/表格里选中的行:Finder 里的文件、邮件列表里的邮件)。只读。
|
|
3121
|
+
/// App 失去前台后焦点元素与选区仍在,所以用户点进 Forsion 面板那一刻再读也来得及。密码框一律不读。
|
|
3122
|
+
private func selection(_ request: [String: Any]) throws -> [String: Any] {
|
|
3123
|
+
let pid = Int32(try intArg(request, "pid"))
|
|
3124
|
+
ensureEnhancedAccessibility(pid: pid)
|
|
3125
|
+
let app = AXUIElementCreateApplication(pid)
|
|
3126
|
+
AXUIElementSetMessagingTimeout(app, 1.0)
|
|
3127
|
+
var result: [String: Any] = [:]
|
|
3128
|
+
let focusedWindow = copyAttribute(app, attribute: kAXFocusedWindowAttribute as CFString).flatMap(asAXElement)
|
|
3129
|
+
// 给了 windowId(贴边面板总会给)就只读那一扇窗:同一 App 的另一扇窗里的选区不属于这段对话。
|
|
3130
|
+
// 对不上(或配不出 AX 窗口)一律不读 —— 宁可漏引用,不可张冠李戴。
|
|
3131
|
+
if let windowId = optionalIntArg(request, "windowId").map({ UInt32($0) }) {
|
|
3132
|
+
guard let expected = windowElement(pid: pid, windowId: windowId), let focusedWindow, CFEqual(expected, focusedWindow) else {
|
|
3133
|
+
return ["otherWindow": true]
|
|
3134
|
+
}
|
|
3135
|
+
}
|
|
3136
|
+
if let focusedWindow {
|
|
3137
|
+
result["windowTitle"] = stringAttribute(focusedWindow, attribute: kAXTitleAttribute as CFString) ?? ""
|
|
3138
|
+
}
|
|
3139
|
+
guard let element = copyAttribute(app, attribute: kAXFocusedUIElementAttribute as CFString).flatMap(asAXElement) else {
|
|
3140
|
+
return result
|
|
3141
|
+
}
|
|
3142
|
+
let role = stringAttribute(element, attribute: kAXRoleAttribute as CFString) ?? ""
|
|
3143
|
+
let subrole = stringAttribute(element, attribute: kAXSubroleAttribute as CFString) ?? ""
|
|
3144
|
+
if isSecureTextElement(role: role, subrole: subrole) {
|
|
3145
|
+
result["element"] = ["role": role, "secure": true]
|
|
3146
|
+
return result
|
|
3147
|
+
}
|
|
3148
|
+
result["element"] = [
|
|
3149
|
+
"role": role,
|
|
3150
|
+
"subrole": subrole,
|
|
3151
|
+
"title": stringAttribute(element, attribute: kAXTitleAttribute as CFString) ?? "",
|
|
3152
|
+
"description": stringAttribute(element, attribute: kAXDescriptionAttribute as CFString) ?? "",
|
|
3153
|
+
"value": String(displayValue(element, role: role, subrole: subrole).prefix(200)),
|
|
3154
|
+
]
|
|
3155
|
+
if let text = selectedText(around: element), !text.isEmpty {
|
|
3156
|
+
result["text"] = String(text.prefix(20_000))
|
|
3157
|
+
return result
|
|
3158
|
+
}
|
|
3159
|
+
let rows = axElementArrayIfPresent(element, attribute: kAXSelectedRowsAttribute as CFString)
|
|
3160
|
+
?? axElementArrayIfPresent(element, attribute: kAXSelectedChildrenAttribute as CFString) ?? []
|
|
3161
|
+
let items = rows.prefix(20).map(describeSelected).filter { !$0.isEmpty }
|
|
3162
|
+
if !items.isEmpty { result["items"] = Array(items) }
|
|
3163
|
+
return result
|
|
3164
|
+
}
|
|
3165
|
+
|
|
3166
|
+
/// 焦点元素自己没有选区时往上找(网页里焦点常落在链接/段落上,选区挂在 AXWebArea)。
|
|
3167
|
+
/// 原生控件给 kAXSelectedText;WebKit/Chromium 的正文选区只认 text marker。
|
|
3168
|
+
private func selectedText(around start: AXUIElement) -> String? {
|
|
3169
|
+
var current: AXUIElement? = start
|
|
3170
|
+
for _ in 0..<8 {
|
|
3171
|
+
guard let element = current else { return nil }
|
|
3172
|
+
if let text = stringAttribute(element, attribute: kAXSelectedTextAttribute as CFString), !text.isEmpty { return text }
|
|
3173
|
+
if let range = copyAttribute(element, attribute: "AXSelectedTextMarkerRange" as CFString) {
|
|
3174
|
+
var value: AnyObject?
|
|
3175
|
+
if AXUIElementCopyParameterizedAttributeValue(element, "AXStringForTextMarkerRange" as CFString, range, &value) == .success,
|
|
3176
|
+
let text = value as? String, !text.isEmpty
|
|
3177
|
+
{
|
|
3178
|
+
return text
|
|
3179
|
+
}
|
|
3180
|
+
}
|
|
3181
|
+
current = copyAttribute(element, attribute: kAXParentAttribute as CFString).flatMap(asAXElement)
|
|
3182
|
+
}
|
|
3183
|
+
return nil
|
|
3184
|
+
}
|
|
3185
|
+
|
|
3186
|
+
/// 选中行 → 一行人话:自己的标题/值/描述,没有就拼前几个子孙里的文字(表格行的文字在单元格里)。
|
|
3187
|
+
/// 密码框一律跳过(行本身、以及行里的每个子孙):第三方 App 可能把明文挂在 AX value 上。
|
|
3188
|
+
private func describeSelected(_ element: AXUIElement) -> String {
|
|
3189
|
+
let isSecure = { (el: AXUIElement) in
|
|
3190
|
+
self.isSecureTextElement(role: self.stringAttribute(el, attribute: kAXRoleAttribute as CFString) ?? "",
|
|
3191
|
+
subrole: self.stringAttribute(el, attribute: kAXSubroleAttribute as CFString) ?? "")
|
|
3192
|
+
}
|
|
3193
|
+
if isSecure(element) { return "" }
|
|
3194
|
+
for attribute in [kAXTitleAttribute, kAXValueAttribute, kAXDescriptionAttribute] {
|
|
3195
|
+
if let text = stringAttribute(element, attribute: attribute as CFString), !text.isEmpty { return String(text.prefix(200)) }
|
|
3196
|
+
}
|
|
3197
|
+
let texts = collectDescendants(startingAt: element, maxDepth: 3).compactMap { child -> String? in
|
|
3198
|
+
let role = stringAttribute(child, attribute: kAXRoleAttribute as CFString) ?? ""
|
|
3199
|
+
guard role == "AXStaticText" || role == "AXTextField", !isSecure(child) else { return nil }
|
|
3200
|
+
return stringAttribute(child, attribute: kAXValueAttribute as CFString).flatMap { $0.isEmpty ? nil : $0 }
|
|
3201
|
+
}
|
|
3202
|
+
return String(texts.prefix(4).joined(separator: " · ").prefix(200))
|
|
3203
|
+
}
|
|
3204
|
+
|
|
3041
3205
|
private func copyAttribute(_ element: AXUIElement, attribute: CFString) -> AnyObject? {
|
|
3042
3206
|
var value: AnyObject?
|
|
3043
3207
|
let status = AXUIElementCopyAttributeValue(element, attribute, &value)
|
|
@@ -12,7 +12,18 @@ image = { version = "0.25", default-features = false, features = ["png"] }
|
|
|
12
12
|
base64 = "0.22"
|
|
13
13
|
|
|
14
14
|
[target.'cfg(windows)'.dependencies]
|
|
15
|
+
# Tangu 新增:`#[implement]`(电脑历史的 UIA 事件处理器)展开后引用 windows_core,须直接依赖、版本与 windows 一致。
|
|
16
|
+
windows-core = "0.58"
|
|
15
17
|
windows = { version = "0.58", features = [
|
|
18
|
+
"implement",
|
|
19
|
+
"Win32_Security",
|
|
20
|
+
"Win32_Security_Authorization",
|
|
21
|
+
"Win32_Storage_FileSystem",
|
|
22
|
+
"Win32_System_IO",
|
|
23
|
+
"Win32_System_LibraryLoader",
|
|
24
|
+
"Win32_System_Pipes",
|
|
25
|
+
"Win32_System_RemoteDesktop",
|
|
26
|
+
"Win32_System_StationsAndDesktops",
|
|
16
27
|
"Win32_UI_Accessibility",
|
|
17
28
|
"Win32_UI_WindowsAndMessaging",
|
|
18
29
|
"Win32_UI_Input_KeyboardAndMouse",
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
//! Tangu 新增:跨进程的「agent 正在操作」标记,给电脑历史的事件打 `origin:"agent"`。
|
|
2
|
+
//!
|
|
3
|
+
//! macOS 上动作与采集在同一个 helper 进程里,一个深度计数就够;Windows 上执行动作的是引擎拉起的 stdio 助手,
|
|
4
|
+
//! 采集的是桌面拉起的 `serve` 进程,所以用一个会话内的命名互斥量当标记:stdio 助手执行 act 等命令期间(以及结束后
|
|
5
|
+
//! 750ms —— UIA 事件与输入是异步到的)持有它的句柄,采集器只探它在不在(OpenMutexW),从不创建。
|
|
6
|
+
//! 非 Windows 平台全是空操作。
|
|
7
|
+
|
|
8
|
+
#[cfg(windows)]
|
|
9
|
+
use std::sync::Mutex;
|
|
10
|
+
|
|
11
|
+
#[cfg(windows)]
|
|
12
|
+
const MARKER_NAME: &str = "Local\\tangu-computer-use-agent-active";
|
|
13
|
+
#[cfg(windows)]
|
|
14
|
+
const GRACE_MS: u64 = 750;
|
|
15
|
+
|
|
16
|
+
#[cfg(windows)]
|
|
17
|
+
#[derive(Default)]
|
|
18
|
+
struct MarkerState {
|
|
19
|
+
depth: u32,
|
|
20
|
+
/// 互斥量句柄(isize:HANDLE 不是 Send)。
|
|
21
|
+
handle: Option<isize>,
|
|
22
|
+
generation: u64,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
#[cfg(windows)]
|
|
26
|
+
fn state() -> &'static Mutex<MarkerState> {
|
|
27
|
+
static STATE: std::sync::OnceLock<Mutex<MarkerState>> = std::sync::OnceLock::new();
|
|
28
|
+
STATE.get_or_init(|| Mutex::new(MarkerState::default()))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/// 持有期间标记亮着;drop 后再亮 750ms。
|
|
32
|
+
pub struct AgentGuard(());
|
|
33
|
+
|
|
34
|
+
pub fn enter() -> AgentGuard {
|
|
35
|
+
#[cfg(windows)]
|
|
36
|
+
if let Ok(mut s) = state().lock() {
|
|
37
|
+
s.depth += 1;
|
|
38
|
+
if s.handle.is_none() {
|
|
39
|
+
use windows::core::HSTRING;
|
|
40
|
+
use windows::Win32::System::Threading::CreateMutexW;
|
|
41
|
+
if let Ok(h) = unsafe { CreateMutexW(None, false, &HSTRING::from(MARKER_NAME)) } {
|
|
42
|
+
s.handle = Some(h.0 as isize);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
AgentGuard(())
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
impl Drop for AgentGuard {
|
|
50
|
+
fn drop(&mut self) {
|
|
51
|
+
#[cfg(windows)]
|
|
52
|
+
{
|
|
53
|
+
let generation = match state().lock() {
|
|
54
|
+
Ok(mut s) if s.depth > 0 => {
|
|
55
|
+
s.depth -= 1;
|
|
56
|
+
if s.depth > 0 {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
s.generation += 1;
|
|
60
|
+
s.generation
|
|
61
|
+
}
|
|
62
|
+
_ => return,
|
|
63
|
+
};
|
|
64
|
+
std::thread::spawn(move || {
|
|
65
|
+
std::thread::sleep(std::time::Duration::from_millis(GRACE_MS));
|
|
66
|
+
let Ok(mut s) = state().lock() else { return };
|
|
67
|
+
if s.depth == 0 && s.generation == generation {
|
|
68
|
+
if let Some(raw) = s.handle.take() {
|
|
69
|
+
use windows::Win32::Foundation::{CloseHandle, HANDLE};
|
|
70
|
+
let _ = unsafe { CloseHandle(HANDLE(raw as *mut _)) };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/// 别的进程(或本进程)此刻是否亮着标记。只探不建。
|
|
79
|
+
pub fn active() -> bool {
|
|
80
|
+
#[cfg(windows)]
|
|
81
|
+
{
|
|
82
|
+
use windows::core::HSTRING;
|
|
83
|
+
use windows::Win32::Foundation::CloseHandle;
|
|
84
|
+
use windows::Win32::System::Threading::{OpenMutexW, SYNCHRONIZATION_SYNCHRONIZE};
|
|
85
|
+
match unsafe { OpenMutexW(SYNCHRONIZATION_SYNCHRONIZE, false, &HSTRING::from(MARKER_NAME)) } {
|
|
86
|
+
Ok(h) => {
|
|
87
|
+
let _ = unsafe { CloseHandle(h) };
|
|
88
|
+
true
|
|
89
|
+
}
|
|
90
|
+
Err(_) => false,
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
#[cfg(not(windows))]
|
|
94
|
+
{
|
|
95
|
+
false
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -3,10 +3,14 @@
|
|
|
3
3
|
//! This crate provides the JSON-lines protocol types used to communicate
|
|
4
4
|
//! between the TypeScript host and the Rust helper binary.
|
|
5
5
|
|
|
6
|
+
pub mod agent_marker;
|
|
6
7
|
pub mod capture;
|
|
7
8
|
pub mod error;
|
|
8
9
|
pub mod input;
|
|
9
10
|
pub mod protocol;
|
|
11
|
+
#[cfg(windows)]
|
|
12
|
+
pub mod recorder;
|
|
13
|
+
pub mod recorder_logic;
|
|
10
14
|
pub mod refs;
|
|
11
15
|
pub mod state;
|
|
12
16
|
pub mod uia;
|
|
@@ -128,6 +128,16 @@ fn with_physical_input<T>(
|
|
|
128
128
|
fn main() {
|
|
129
129
|
#[cfg(windows)]
|
|
130
130
|
set_dpi_awareness();
|
|
131
|
+
// Tangu 新增:电脑历史采集器的入口(桌面端拉起,见 src/recorder.rs);不走下面的 stdio 循环与窗口事件日志。
|
|
132
|
+
let args = std::env::args().skip(1).collect::<Vec<_>>();
|
|
133
|
+
match args.first().map(String::as_str) {
|
|
134
|
+
Some("recorder-protocol") => {
|
|
135
|
+
println!("{}", windows_bridge::recorder_logic::RECORDER_PROTOCOL);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
Some("serve") => std::process::exit(serve_recorder(&args)),
|
|
139
|
+
_ => {}
|
|
140
|
+
}
|
|
131
141
|
window::start_root_event_journal();
|
|
132
142
|
|
|
133
143
|
let stdin = io::stdin();
|
|
@@ -192,6 +202,25 @@ fn main() {
|
|
|
192
202
|
}
|
|
193
203
|
}
|
|
194
204
|
|
|
205
|
+
fn serve_recorder(args: &[String]) -> i32 {
|
|
206
|
+
let pipe = args
|
|
207
|
+
.iter()
|
|
208
|
+
.position(|a| a == "--pipe")
|
|
209
|
+
.and_then(|i| args.get(i + 1))
|
|
210
|
+
.cloned()
|
|
211
|
+
.unwrap_or_default();
|
|
212
|
+
#[cfg(windows)]
|
|
213
|
+
{
|
|
214
|
+
windows_bridge::recorder::serve(&pipe)
|
|
215
|
+
}
|
|
216
|
+
#[cfg(not(windows))]
|
|
217
|
+
{
|
|
218
|
+
let _ = pipe;
|
|
219
|
+
eprintln!("serve is only available on Windows");
|
|
220
|
+
2
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
195
224
|
#[cfg(windows)]
|
|
196
225
|
fn set_dpi_awareness() {
|
|
197
226
|
use windows::Win32::UI::HiDpi::{
|
|
@@ -211,6 +240,9 @@ fn handle_request(request: &Request) -> Response {
|
|
|
211
240
|
return Response::err(&request.id, ProtocolError::new(format!("Unsupported Windows helper protocol {}; expected {}. Restart Pi to use the installed helper.", request.protocol_version, PROTOCOL_VERSION), ErrorCode::InvalidRequest));
|
|
212
241
|
}
|
|
213
242
|
|
|
243
|
+
// Tangu 新增:执行动作期间亮起跨进程标记,电脑历史据此给这段时间的事件打 origin:"agent"(见 agent_marker.rs)。
|
|
244
|
+
let _agent = matches!(request.cmd.as_str(), "act" | "actBatch" | "focusWindow" | "openBrowserLocation")
|
|
245
|
+
.then(windows_bridge::agent_marker::enter);
|
|
214
246
|
let result = match request.cmd.as_str() {
|
|
215
247
|
"diagnostics" => Ok(diagnostics()),
|
|
216
248
|
"listRoots" | "listWindows" => handle_list_roots(&request.args),
|