@forsion/tangu-computer-use 0.5.8
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 +223 -0
- package/LICENSE +26 -0
- package/LICENSE.upstream +21 -0
- package/README.md +169 -0
- package/UPSTREAM.md +277 -0
- package/check.mjs +369 -0
- package/icon.png +0 -0
- package/install.sh +112 -0
- package/main.js +541 -0
- package/manifest.json +47 -0
- package/native/linux/bridge-rs/Cargo.lock +1204 -0
- package/native/linux/bridge-rs/Cargo.toml +18 -0
- package/native/linux/bridge-rs/src/atspi.rs +640 -0
- package/native/linux/bridge-rs/src/error.rs +65 -0
- package/native/linux/bridge-rs/src/lib.rs +11 -0
- package/native/linux/bridge-rs/src/main.rs +1074 -0
- package/native/linux/bridge-rs/src/protocol.rs +49 -0
- package/native/linux/bridge-rs/src/state.rs +293 -0
- package/native/linux/bridge-rs/src/wayland.rs +89 -0
- package/native/linux/bridge-rs/src/x11.rs +909 -0
- package/native/linux/bridge-rs/tests/protocol_tests.rs +73 -0
- package/native/macos/agent_cursor.swift +184 -0
- package/native/macos/agent_cursor_motion.swift +252 -0
- package/native/macos/agent_cursor_tests.swift +128 -0
- package/native/macos/agent_highlight.swift +229 -0
- package/native/macos/agent_highlight_tests.swift +99 -0
- package/native/macos/bridge.swift +3852 -0
- package/native/macos/foreground_activity.swift +57 -0
- package/native/macos/foreground_activity_tests.swift +31 -0
- package/native/macos/live_stream.swift +289 -0
- package/native/windows/bridge-rs/Cargo.lock +396 -0
- package/native/windows/bridge-rs/Cargo.toml +30 -0
- package/native/windows/bridge-rs/src/capture.rs +518 -0
- package/native/windows/bridge-rs/src/error.rs +81 -0
- package/native/windows/bridge-rs/src/input.rs +501 -0
- package/native/windows/bridge-rs/src/lib.rs +16 -0
- package/native/windows/bridge-rs/src/main.rs +1683 -0
- package/native/windows/bridge-rs/src/protocol.rs +53 -0
- package/native/windows/bridge-rs/src/refs.rs +237 -0
- package/native/windows/bridge-rs/src/state.rs +55 -0
- package/native/windows/bridge-rs/src/uia.rs +1252 -0
- package/native/windows/bridge-rs/src/window.rs +718 -0
- package/native/windows/bridge-rs/tests/protocol_tests.rs +252 -0
- package/native/windows/bridge-rs/tests/refs_tests.rs +86 -0
- package/native/windows/bridge-rs/tests/state_tests.rs +33 -0
- package/package.json +74 -0
- package/prebuilt/linux/arm64/linux-bridge +0 -0
- package/prebuilt/linux/x64/linux-bridge +0 -0
- package/prebuilt/macos/arm64/bridge +0 -0
- package/prebuilt/macos/arm64/tangu-computer-use.app.json +10 -0
- 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 +10 -0
- package/prebuilt/macos/x64/tangu-computer-use.app.zip +0 -0
- package/prebuilt/windows/windows-bridge.exe +0 -0
- package/scripts/blind-click.check.mjs +128 -0
- package/scripts/build-native.mjs +302 -0
- package/scripts/build.mjs +84 -0
- package/scripts/calc-fixture.mjs +78 -0
- package/scripts/cli-exit.check.mjs +49 -0
- package/scripts/helper-path.check.mjs +82 -0
- package/scripts/helper-refresh.check.mjs +62 -0
- package/scripts/helper-signal.check.mjs +59 -0
- package/scripts/highlight.check.mjs +32 -0
- package/scripts/keychain-free-install.check.mjs +140 -0
- package/scripts/live-view.check.mjs +124 -0
- package/scripts/macos-bundle.d.mts +1 -0
- package/scripts/macos-bundle.mjs +119 -0
- package/scripts/make-signing-cert.sh +57 -0
- package/scripts/mini-foreground.check.mjs +11 -0
- package/scripts/no-foreground.check.mjs +81 -0
- package/scripts/overlay-visible.check.mjs +221 -0
- package/scripts/package-macos-app.mjs +39 -0
- package/scripts/permissions.check.mjs +199 -0
- package/scripts/platform-contract.check.mjs +30 -0
- package/scripts/setup-helper.mjs +154 -0
- package/scripts/tangu-computer-use.entitlements +10 -0
- package/scripts/verify-macos-bundles.mjs +25 -0
- package/scripts/verify-package.mjs +74 -0
- package/skills/computer-use/SKILL.md +110 -0
- package/src/foregroundNote.ts +43 -0
- package/src/helperState.ts +31 -0
- package/src/index.ts +63 -0
- package/src/onboarding.ts +198 -0
- package/src/pi-compat.ts +49 -0
- package/src/settings.ts +11 -0
- package/src/setup.ts +97 -0
- package/src/tools.ts +289 -0
- package/src/vendor/actions.ts +130 -0
- package/src/vendor/bridge.ts +2405 -0
- package/src/vendor/cdp.ts +658 -0
- package/src/vendor/config.ts +113 -0
- package/src/vendor/contract.ts +104 -0
- package/src/vendor/note.ts +195 -0
- package/src/vendor/outline.ts +651 -0
- package/src/vendor/output.ts +134 -0
- package/src/vendor/permissions.ts +111 -0
- package/src/vendor/platform/architecture.ts +23 -0
- package/src/vendor/platform/coerce.ts +16 -0
- package/src/vendor/platform/index.ts +59 -0
- package/src/vendor/platform/linux/backend.ts +186 -0
- package/src/vendor/platform/linux/helper.ts +238 -0
- package/src/vendor/platform/macos/backend.ts +131 -0
- package/src/vendor/platform/macos/browser.ts +110 -0
- package/src/vendor/platform/macos/helper-path.d.mts +9 -0
- package/src/vendor/platform/macos/helper-path.mjs +34 -0
- package/src/vendor/platform/macos/helper.ts +291 -0
- package/src/vendor/platform/macos/permissions.ts +146 -0
- package/src/vendor/platform/types.ts +222 -0
- package/src/vendor/platform/windows/backend.ts +142 -0
- package/src/vendor/platform/windows/helper.ts +140 -0
- package/src/vendor/root-selection.ts +25 -0
- package/src/vendor/runtime.ts +129 -0
- package/src/vendor/state.ts +146 -0
- package/src/vendor/view.ts +147 -0
- package/tangu-plugins/computer-use/dist/foregroundNote.js +19 -0
- package/tangu-plugins/computer-use/dist/index.js +5672 -0
- package/tangu-plugins/computer-use/tangu-plugin.json +9 -0
- package/tsconfig.json +22 -0
- package/types/tangu-agent.d.ts +142 -0
package/UPSTREAM.md
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
# Upstream & vendor strategy
|
|
2
|
+
|
|
3
|
+
Forked from **[injaneity/pi-computer-use](https://github.com/injaneity/pi-computer-use)**
|
|
4
|
+
@ `4b8dbd7e` (v0.5.1, 2026-08-31), MIT. (Previously `c838d3a` = v0.5.0; resynced 2026-09-07.) Upstream license: `LICENSE.upstream`.
|
|
5
|
+
|
|
6
|
+
> Repo shape: this repo **is a Forsion bundle** (`manifest.json` + `main.js` + `skills/` at the root,
|
|
7
|
+
> engine plugin under `tangu-plugins/computer-use/`). See "Bundle layout" at the bottom.
|
|
8
|
+
|
|
9
|
+
## Vendor strategy — vendor is verbatim except a mechanical brand rename
|
|
10
|
+
|
|
11
|
+
`src/vendor/` is the upstream `src/` copied verbatim **except one mechanical, greppable rename**
|
|
12
|
+
(see "Brand rename" below — the helper's identity/path constants must match the branded native
|
|
13
|
+
app or nothing connects) **and one deliberate behavioural constant**: `HELPER_PROTOCOL_VERSION` in
|
|
14
|
+
`platform/macos/helper.ts`, which we hold above upstream's (see "Helper protocol version" below).
|
|
15
|
+
Re-syncing must not blindly restore upstream's number — that silently disables every native feature
|
|
16
|
+
we added. The entire *code* coupling to pi (`@earendil-works/pi-coding-agent`)
|
|
17
|
+
is 5 files:
|
|
18
|
+
|
|
19
|
+
| upstream file | pi import | how it's satisfied |
|
|
20
|
+
|---|---|---|
|
|
21
|
+
| `bridge.ts` | `AgentToolResult`, `AgentToolUpdateCallback`, `ExtensionContext` (type) | `src/pi-compat.ts` |
|
|
22
|
+
| `permissions.ts`, `platform/types.ts`, `platform/macos/permissions.ts` | `ExtensionContext` (type) | `src/pi-compat.ts` |
|
|
23
|
+
| `config.ts` | `getAgentDir` (runtime) | `src/pi-compat.ts` |
|
|
24
|
+
|
|
25
|
+
`tsconfig.json` `paths` + `scripts/build.mjs` esbuild `alias` both map
|
|
26
|
+
`@earendil-works/pi-coding-agent` → `src/pi-compat.ts`, so **the vendor import lines are not
|
|
27
|
+
edited**. Config is injected by mutating the `activeConfig` object returned by
|
|
28
|
+
`getComputerUseConfig()` (see `src/tools.ts` → `syncSettings`), so `config.ts` is unmodified too.
|
|
29
|
+
|
|
30
|
+
### Tangu adaptation layer (NOT vendored — hand-written)
|
|
31
|
+
- `src/pi-compat.ts` — the pi shim (only the shapes vendor actually uses).
|
|
32
|
+
- `src/tools.ts` — upstream's 11 `defineTool` → Tangu `ToolDef[]`; typebox schema hand-translated
|
|
33
|
+
to plain JSON Schema; `AgentToolResult.content` → return string + `ctx.collectImage` (screenshots).
|
|
34
|
+
- `src/settings.ts` — 3 keys (browser_use / headless / cursor_overlay).
|
|
35
|
+
- `src/setup.ts` — `tangu computer-use setup|doctor|stop` (readline `ExtensionContext` reuses
|
|
36
|
+
vendor `ensureComputerUseSetup`).
|
|
37
|
+
- `src/index.ts` — the `TanguPlugin` entry.
|
|
38
|
+
|
|
39
|
+
### Brand rename (the one vendor patch)
|
|
40
|
+
The native helper is branded (`com.forsion.tangu-computer-use`, `/Applications/tangu-computer-use.app`,
|
|
41
|
+
socket `~/Library/Caches/tangu-computer-use/bridge.sock`, Windows exe under `tangu-computer-use/`).
|
|
42
|
+
The vendor **runtime** must point at those exact names — `HELPER_BUNDLE_ID` (used by `open -b`),
|
|
43
|
+
`HELPER_APP_PATH`, the socket dir, and `WINDOWS_HELPER_PATH`. So the vendor gets one mechanical
|
|
44
|
+
rename applied across `src/vendor/**.ts`:
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
s/\bpi-computer-use\b/tangu-computer-use/g # lowercase token: bundle id, app/socket/exe paths, user-facing strings, config filename
|
|
48
|
+
s/\binjaneity\b/forsion/g # com.injaneity.… → com.forsion.…
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Plus these **exact user-facing phrases** (never a blanket `\bpi\b` — that would hit the
|
|
52
|
+
`@earendil-works/pi-coding-agent` import specifier the alias depends on):
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
Restart Pi to use the installed helper. → Restart Tangu to use the installed helper.
|
|
56
|
+
Restart Pi so the canonical helper is used. → Restart Tangu so the canonical helper is used.
|
|
57
|
+
Start pi in interactive mode. → Run 'tangu computer-use setup' in a terminal. (single quotes — the string sits inside a template literal)
|
|
58
|
+
Windows helper closed because the Pi session ended. → … the Tangu session ended.
|
|
59
|
+
Enable browser_use in ~/.pi/agent/extensions/… or .pi/computer-use.json to allow browser windows.
|
|
60
|
+
→ Enable the computer-use plugin's browser_use setting to allow browser windows.
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Also `Linux helper closed because the Pi session ended.` → `… the Tangu session ended.` (v0.5.0's
|
|
64
|
+
Linux backend mirrors the Windows string).
|
|
65
|
+
|
|
66
|
+
Case matters: the `PI_COMPUTER_USE_*` / `PI_CU_*` **env-var names are a different token** (uppercase +
|
|
67
|
+
underscores) and are deliberately left unchanged, as are code comments mentioning Pi. After the rename,
|
|
68
|
+
`grep -rn "pi-computer-use\|injaneity" src/vendor` must be empty (the rename now also covers `.mjs` /
|
|
69
|
+
`.d.mts` — v0.5.0 added `platform/macos/helper-path.mjs`). This mismatch was a
|
|
70
|
+
real bug once (native branded, vendor still `pi-*`): the runtime launched an unregistered bundle id and
|
|
71
|
+
connected to the wrong socket, so `doctor` reported ready while a real observe/act failed.
|
|
72
|
+
|
|
73
|
+
### PACKAGE_ROOT — no longer patched, but still guarded
|
|
74
|
+
`platform/{macos,windows,linux}/helper.ts` locate `scripts/setup-helper.mjs` via
|
|
75
|
+
`path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..")`. Correct in the source
|
|
76
|
+
tree (`src/vendor/platform/<os>/` → package root). Once bundled it depends on **where the bundle lands**:
|
|
77
|
+
|
|
78
|
+
| bundle output | 3 levels up | verdict |
|
|
79
|
+
|---|---|---|
|
|
80
|
+
| `<root>/dist/index.js` (v0.1, engine-plugin layout) | two levels *above* the repo | ✗ — needed a build-time rewrite |
|
|
81
|
+
| `<root>/tangu-plugins/computer-use/dist/index.js` (bundle layout) | `<root>` | ✔ — upstream's own math is right |
|
|
82
|
+
|
|
83
|
+
So the bundle move **deleted a whole vendor patch**. `scripts/build.mjs` keeps two guards instead:
|
|
84
|
+
① the expression must still be present in every platform helper (upstream changing it fails the build),
|
|
85
|
+
② after the build it resolves 3 levels up from the real output dir and asserts `scripts/setup-helper.mjs`
|
|
86
|
+
is there. Move the output and the build tells you.
|
|
87
|
+
|
|
88
|
+
`scripts/setup-helper.mjs` gets one path patch of its own: upstream imports
|
|
89
|
+
`../src/platform/macos/helper-path.mjs`; ours is `../src/vendor/platform/macos/helper-path.mjs`
|
|
90
|
+
(and that file is listed in package.json `files`, or the published package can't install its helper).
|
|
91
|
+
Since 0.5.3 the macOS installer is intentionally diverged: **never restore upstream runtime
|
|
92
|
+
certificate discovery/import or signing**. `build-native.mjs` creates sealed App ZIPs and checksum
|
|
93
|
+
manifests, `macos-bundle.mjs` verifies and atomically installs them, and onboarding uses the same
|
|
94
|
+
complete-app check. ZIPs keep Electron's outer `--deep` signature from rewriting the helper.
|
|
95
|
+
`check:installer` traps every subprocess and rejects keychain/private-key operations. Keep this
|
|
96
|
+
guard in both plugin and Genesis release pipelines.
|
|
97
|
+
|
|
98
|
+
Historical cause (removed in 0.5.3): `codesign --sign <local cert>` needs the private key, so it raises a
|
|
99
|
+
**keychain dialog** — run it anywhere nobody can click and it hangs, and killing it leaves a
|
|
100
|
+
`MacOS/*.cstemp` behind. The next `codesign --deep` then seals that temp file into `CodeResources`
|
|
101
|
+
and deletes it on the way out, so the signature fails verification **forever**
|
|
102
|
+
(`a sealed resource is missing or invalid`). Because TCC matches the *designated requirement*, the
|
|
103
|
+
app silently loses Accessibility/Screen Recording and **toggling the checkbox in System Settings can
|
|
104
|
+
never fix it** — the stored grant no longer matches the binary. Re-signing cleanly with the same cert
|
|
105
|
+
restores the original requirement and the existing grants light up again; no uninstall, no
|
|
106
|
+
`tccutil reset`. (2026-07-28, cost a long debugging session.)
|
|
107
|
+
|
|
108
|
+
### Native
|
|
109
|
+
`native/macos` (Swift), `native/windows` (Rust) and `native/linux` (Rust) are vendored with the same
|
|
110
|
+
brand strings rewritten. **Never change the bundle id or signing identity across releases** — macOS ties
|
|
111
|
+
Accessibility/Screen-Recording (TCC) grants to them.
|
|
112
|
+
|
|
113
|
+
> ⚠️ v0.5.0 changed where the macOS helper installs: an existing **writable** `/Applications` install
|
|
114
|
+
> stays put, everything else goes to `~/Applications` (no admin needed). Users whose `/Applications`
|
|
115
|
+
> is not writable will be asked to re-grant TCC once, because the path moved.
|
|
116
|
+
|
|
117
|
+
**Helper protocol version — bumped by us.** Upstream v0.5.1 is still at 6; we run **12**, in two places that must
|
|
118
|
+
always match: `native/macos/bridge.swift` (`private let protocolVersion`) and
|
|
119
|
+
`src/vendor/platform/macos/helper.ts` (`HELPER_PROTOCOL_VERSION`). Bump it whenever the helper gains a
|
|
120
|
+
command **or changes native behaviour** — `src/onboarding.ts` now notices a stale binary *on disk*, but a
|
|
121
|
+
**already-running** daemon is only re-validated through the protocol number, so without a bump the old
|
|
122
|
+
process keeps serving after the update lands. 0.4.0 is the cautionary case: it changed only overlay window
|
|
123
|
+
levels and one CGWindowList call, no new commands — a same-protocol daemon would have gone on drawing
|
|
124
|
+
invisible overlays and the user would have reported the bug a fourth time. Without a bump, an old installed helper keeps serving observe/act while
|
|
125
|
+
every new native feature silently does nothing (that is exactly how the first live-view build shipped
|
|
126
|
+
"working"). With a bump, `ensureProtocol()` restarts the daemon and then throws a clear
|
|
127
|
+
"reinstall or rebuild the helper app" error. On resync, if upstream has moved past 6, go one above theirs.
|
|
128
|
+
|
|
129
|
+
**Tangu-only native additions (not upstream, replay them after every sync):**
|
|
130
|
+
- `native/macos/agent_highlight.swift` + `agent_highlight_tests.swift` — the glowing edge around the
|
|
131
|
+
window being controlled. Listed in `helperSourcePaths` (setup-helper.mjs) and `macosSourcePaths`
|
|
132
|
+
(build-native.mjs); forget either and the helper compiles without it.
|
|
133
|
+
- `native/macos/live_stream.swift` — `LiveWindowStream`, a **persistent `SCStream`** for the controlled
|
|
134
|
+
window plus a window-sized one-shot (`captureOnce`). Same two source lists as the highlight.
|
|
135
|
+
Two things it exists to fix, both measured, both easy to undo by accident:
|
|
136
|
+
① `SCScreenshotManager.captureImage` renegotiates a capture session per frame — fine for one
|
|
137
|
+
screenshot, a 1fps slideshow for a live view. The stream keeps the latest frame; a fetch is ~12ms.
|
|
138
|
+
② upstream's `captureWindow` sets **no** `config.width/height`, so ScreenCaptureKit renders at
|
|
139
|
+
*display* size with the window pinned top-left and the rest blank — a 230×408 Calculator comes back
|
|
140
|
+
as 1920×1080 mostly white. That whitespace is baked into the JPEG; no amount of CSS fixes it.
|
|
141
|
+
Anything serving the live view must set width/height. `captureWindow` itself is left alone because
|
|
142
|
+
`look` depends on its coordinate space.
|
|
143
|
+
- `bridge.swift` `act()`: **coordinate clicks try an AX hit-test first** (`backgroundPressAtPoint`).
|
|
144
|
+
Upstream sends coordinate clicks straight to HID/foreground (`needsForeground` in `actions.ts`)
|
|
145
|
+
because `CGEventPostToPid` is dropped by non-key windows — so "look at the screenshot and click"
|
|
146
|
+
always stole the foreground. Hit-testing **against the target app element** (never systemWide: the
|
|
147
|
+
occluding window would answer) and pressing via AX keeps it in the background. Guarded to
|
|
148
|
+
left-button single clicks, non-web, non-text, and the hit must be inside the target window's subtree.
|
|
149
|
+
`npm run check:blindclick` proves all of it on a real machine.
|
|
150
|
+
- `bridge.swift`: `noteControlledWindow()` called at the top of `act()`, a matching write inside
|
|
151
|
+
`storeLookRecord()`, and the `liveView` command (case + method). The highlight reuses the existing
|
|
152
|
+
`cursorOverlay` request flag — no new config key, so `platform/macos/backend.ts` is untouched.
|
|
153
|
+
- `agent_cursor.swift` (vendor file, small patch): the overlay window spans **every** display and is
|
|
154
|
+
re-framed on each `animate()`, and the global CG point is converted to overlay-local coordinates via
|
|
155
|
+
`OverlayGeometry.canvasPoint`. Upstream sizes it to `NSScreen.main?.frame` only, so a click on a
|
|
156
|
+
secondary display draws the cursor outside the window — invisible, which reads exactly like "the
|
|
157
|
+
assistive cursor never appears". The geometry lives in `agent_highlight.swift` (our file) so
|
|
158
|
+
`npm run check:highlight` can pin it; single-display setups are an exact identity, unchanged.
|
|
159
|
+
⚠️ "Primary" means the screen whose frame origin is `(0,0)` — not `NSScreen.main` (that follows the
|
|
160
|
+
key window) and not necessarily `screens.first`.
|
|
161
|
+
- `bridge.swift` `postMouseClick()`: save/restore the **physical cursor** around HID clicks. A
|
|
162
|
+
`.cghidEventTap` post drags the user's real pointer to the target and leaves it there; having the
|
|
163
|
+
foreground taken is bad enough without also losing your mouse. Guarded — it only warps back when the
|
|
164
|
+
pointer is still where we put it (so it never fights a user who grabbed the mouse mid-click), and
|
|
165
|
+
`CGAssociateMouseAndMouseCursorPosition(1)` after the warp or the pointer appears stuck. The
|
|
166
|
+
background (pid) path needs none of this: it never moves the system pointer, it draws `AgentCursor`.
|
|
167
|
+
|
|
168
|
+
- **Overlay z-order and window bounds — two bugs that made both overlays invisible (0.4.0).** Replay
|
|
169
|
+
these after any sync; both are one-liners and both fail *silently*.
|
|
170
|
+
① `cgWindowBounds()` must use `CGWindowListCopyWindowInfo([.optionIncludingWindow], id)`.
|
|
171
|
+
**Never** `CGWindowListCreateDescriptionFromArray` — it returns an **empty array** for windows owned
|
|
172
|
+
by another process (measured on the same window id: 0 vs 1 entries). `AgentHighlight.show()` bails on
|
|
173
|
+
a nil rect, so the glowing edge had never rendered once since it was written.
|
|
174
|
+
② Both overlay windows must set `window.level` (`.floating` for the glow, `.screenSaver` for the
|
|
175
|
+
cursor). They live in an `.accessory` app that never activates, so at the default level 0 they sink
|
|
176
|
+
below the controlled app the moment it is activated. Upstream's
|
|
177
|
+
`window.order(.above, relativeTo: <foreign window number>)` is a **no-op across processes** (measured:
|
|
178
|
+
the window neither rises nor disappears) — cross-process stacking is only expressible as a level.
|
|
179
|
+
`npm run check:overlay` samples the on-screen window stack at 100ms and asserts both overlays are
|
|
180
|
+
present **and above the controlled window**. Geometry unit tests cannot see either bug: "the rect is
|
|
181
|
+
correct", "the window exists" and "the user can see it" are three different claims.
|
|
182
|
+
|
|
183
|
+
- **Never spawn `Contents/MacOS/bridge` directly** — launch the daemon with
|
|
184
|
+
`open -n -g <APP> --args serve --socket <path>` (what `launchDaemon()` does). TCC attributes grants to
|
|
185
|
+
the *responsible process*, so a direct spawn inherits the caller's identity and `diagnostics` reports
|
|
186
|
+
"no Accessibility permission". It looks exactly like lost grants and is not.
|
|
187
|
+
|
|
188
|
+
- **Windows helper links the MSVC runtime statically (0.5.8)** — `scripts/build-native.mjs` (Windows branch)
|
|
189
|
+
appends `-C target-feature=+crt-static` to `RUSTFLAGS` before running cargo. Upstream links it dynamically,
|
|
190
|
+
so `windows-bridge.exe` imports `VCRUNTIME140.dll`, which Windows does not ship (it comes with the VC++
|
|
191
|
+
Redistributable): on a clean machine the helper never starts. Not a `.cargo/config.toml` in the crate —
|
|
192
|
+
cargo reads config from the cwd, and `build-native.mjs` runs cargo from the caller's cwd with
|
|
193
|
+
`--manifest-path`; an inherited `RUSTFLAGS` would override config rustflags anyway. `scripts/verify-package.mjs`
|
|
194
|
+
fails the release if the helper's bytes name any VC++ runtime DLL, so losing this patch in a sync cannot ship.
|
|
195
|
+
Side effect: a set `RUSTFLAGS` makes cargo ignore config-level rustflags, so a cross-build (`--target`) that
|
|
196
|
+
needs extra flags passes them in `RUSTFLAGS`. Not applied to `setup-helper.mjs`'s own `cargo build`, which runs
|
|
197
|
+
when the prebuilt exe is missing (`--runtime`, which the runtime always passes, `--allow-build`, or
|
|
198
|
+
`PI_COMPUTER_USE_ALLOW_BUILD=1`): that binary only runs on the machine that built it, which has MSVC and its runtime.
|
|
199
|
+
|
|
200
|
+
### Onboarding — the helper installs itself (0.4.0)
|
|
201
|
+
Since 0.5.2, a successful in-place macOS upgrade also awaits `macosHelper.restart()`. Replacing a
|
|
202
|
+
binary does not replace a running process image, and matching protocol/path checks alone cannot
|
|
203
|
+
detect that case. Keep installer and restart failures in the shared failure handler. Ship changed
|
|
204
|
+
native bits under a new bundle version: Genesis deliberately skips same-version installed bundles.
|
|
205
|
+
`check:helper-refresh` verifies this boundary; `check:helper-signal` probes the selected packaged
|
|
206
|
+
executable on an isolated socket rather than trusting source code or protocol numbers.
|
|
207
|
+
|
|
208
|
+
`ensureInstalled()` already shells out to `scripts/setup-helper.mjs` (re-entering Electron/Bun via
|
|
209
|
+
`ELECTRON_RUN_AS_NODE`), so a **first** install needs no terminal. Two things break that, and both were
|
|
210
|
+
ours:
|
|
211
|
+
- Any "helper not installed → return instructions" early-return in `src/tools.ts` **defeats it**. Don't
|
|
212
|
+
add one back.
|
|
213
|
+
- `ensureInstalled()` returns as soon as the executable *exists* and never notices a **stale** binary, so
|
|
214
|
+
plugin updates used to dead-end at a protocol-mismatch error telling the user to open a terminal.
|
|
215
|
+
`src/onboarding.ts` `helperNeedsUpdate()` compares the bundle's `prebuilt/macos/<arch>/bridge` against
|
|
216
|
+
the installed executable by hash (memoised per process) and reinstalls when they differ. This is why
|
|
217
|
+
the bundle must keep shipping the prebuilt binary — `npm run check:helper-path` asserts it does.
|
|
218
|
+
|
|
219
|
+
Permissions deliberately still require the user: granting Accessibility/Screen Recording is a system
|
|
220
|
+
security setting. `guideMissingPermissions()` pre-registers the app in the Privacy panes and opens the
|
|
221
|
+
right pane (once per kind per process, or a retrying agent spams System Settings).
|
|
222
|
+
|
|
223
|
+
### Known residual: same-app window pairing
|
|
224
|
+
`backgroundPressAtPoint` scopes its hit-test to the target app and then requires the hit to sit inside
|
|
225
|
+
the AX subtree of `windowElement(pid:windowId:)`. That last step relies on upstream's greedy
|
|
226
|
+
title/geometry pairing between CG window ids and AX windows, which has no tie-break for two windows of
|
|
227
|
+
one app with the same title **and** near-identical overlapping frames. In that (rare) case the press
|
|
228
|
+
can land in the front window instead of the one the screenshot came from. The `ref` path has exactly
|
|
229
|
+
the same exposure — fixing it means real AX↔CG window identity, which needs private API
|
|
230
|
+
(`_AXUIElementGetWindow`). Deliberately not done; noted so nobody assumes the subtree check is airtight.
|
|
231
|
+
|
|
232
|
+
### Bundle layout
|
|
233
|
+
```
|
|
234
|
+
manifest.json main.js check.mjs skills/computer-use/ ← Forsion 桌面侧(视图 + 配套技能)
|
|
235
|
+
tangu-plugins/computer-use/{tangu-plugin.json,dist/} ← 引擎侧插件(12 工具:上游 11 + 自研 ensure_app)
|
|
236
|
+
src/ native/ scripts/ prebuilt/ ← 源码与原生 helper(位置不变)
|
|
237
|
+
```
|
|
238
|
+
`bundles.ts` finds the engine plugin at `<bundle>/tangu-plugins/<pid>/tangu-plugin.json` and skills at
|
|
239
|
+
`<bundle>/skills/<slug>/SKILL.md`.
|
|
240
|
+
|
|
241
|
+
**Bundled into Forsion Desktop (0.5.0+).** The desktop depends on this repo as a vendored tarball
|
|
242
|
+
(`Forsion-Genesis/desktop/vendor/tangu-computer-use.tgz`, refreshed by `npm run vendor:cu` there = `npm pack` of this repo
|
|
243
|
+
after `npm run build` + `build:native --arch all`). electron-builder copies it to `resources/bundled-plugins/tangu-computer-use`
|
|
244
|
+
and `desktop/electron/builtinPlugins.ts` seeds it into `<home>/plugins/tangu-computer-use/` at startup (replace only when the
|
|
245
|
+
shipped version is newer; never downgrade a user-installed copy). The plugin therefore shows up as 「内置」 with no uninstall
|
|
246
|
+
button; `install.sh dev|prod` stays as the developer loop for iterating on the bundle alone. There is **no root `tangu-plugin.json` any more** — a stale
|
|
247
|
+
`tangu install --link` symlink from v0.1 now points at a directory without one.
|
|
248
|
+
|
|
249
|
+
## Re-syncing upstream
|
|
250
|
+
1. `git clone https://github.com/injaneity/pi-computer-use` and `git diff <pinned>..HEAD` to see the scope.
|
|
251
|
+
2. Copy its `src/` over `src/vendor/`, then re-run the brand rename over `src/vendor/**` (`.ts` + `.mjs`
|
|
252
|
+
+ `.mts`; the two `perl -pe` subs plus the exact phrases in "Brand rename"). `grep -rn
|
|
253
|
+
"pi-computer-use\|injaneity" src/vendor` must come back empty. Import lines need no change — the alias
|
|
254
|
+
handles pi.
|
|
255
|
+
3. Copy `scripts/{setup-helper,build-native,make-signing-cert}` over, rename, then re-apply our two
|
|
256
|
+
setup-helper patches (vendor path for `helper-path.mjs`, short signing CN) and the `build-native.mjs`
|
|
257
|
+
hooks listed under "Native" (macOS source list, Windows `+crt-static`).
|
|
258
|
+
4. Copy `native/{macos,windows,linux}` over, rename — then **replay the Tangu native additions** listed
|
|
259
|
+
under "Native" (the highlight file and the three `bridge.swift` hooks). `grep -n
|
|
260
|
+
"noteControlledWindow\|liveView\|AgentHighlight" native/macos/bridge.swift` must find them.
|
|
261
|
+
5. If `extensions/computer-use.ts` changed (new tools or schema), mirror it in `src/tools.ts` (`SPECS`),
|
|
262
|
+
and mirror new config keys in `src/settings.ts` + `syncSettings`.
|
|
263
|
+
6. `npm run build && npm run check`, then **`node scripts/build-native.mjs --arch all`** — not just a compile
|
|
264
|
+
probe: the bundle ships `prebuilt/macos/{arm64,x64}/bridge`, and the desktop vendors this repo as a tarball
|
|
265
|
+
(`npm run vendor:cu` in `Forsion-Genesis/desktop`), so a stale prebuilt is what users get. ⚠️ Upstream's own
|
|
266
|
+
git-tracked `prebuilt/` is *not* rebuilt per release (v0.5.0↔v0.5.1 blobs were byte-identical while
|
|
267
|
+
`bridge.swift` changed) — never copy their binaries, always rebuild from `native/`. Bump the commit at the
|
|
268
|
+
top of this file and the protocol number whenever `native/` changed behaviour.
|
|
269
|
+
7. Real-machine greens (need an installed, authorized helper — not runnable in CI, hence not in
|
|
270
|
+
`npm run check`): `npm run check:live` = `check:blindclick` (coordinate click goes AX, keeps the
|
|
271
|
+
foreground, **and the Calculator readout actually changes**) + `check:liveview` (frames come from
|
|
272
|
+
the stream, sub-100ms, actually differ, and every frame matches the window's aspect ratio).
|
|
273
|
+
For the view's layout, serve the repo and open `harness/harness.html`, then press 「量一量」 —
|
|
274
|
+
it walks every window shape and asserts canvas-aspect / fill / snug / no-overflow. The DOM shim in
|
|
275
|
+
`check.mjs` does no layout, so CSS regressions are only visible there.
|
|
276
|
+
|
|
277
|
+
- 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/check.mjs
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 桌面侧插件自检:用宿主同款 `new Function('ctx', src)` 求值 main.js,断言贡献点齐全,
|
|
3
|
+
* 并在 DOM 垫片上跑一轮真实挂载 —— 重点验三件**错了不会报错、只会静默烧 CPU 或骗人**的事:
|
|
4
|
+
* ① 卸载必须停掉轮询(视图关了还在每秒截屏 = 白烧,而且用户看不见)
|
|
5
|
+
* ② 看不见时(document.hidden / 滚出可视区)不发请求
|
|
6
|
+
* ③ 没在操控 / 拿不到画面时,绝不留着上一帧假装实时
|
|
7
|
+
* 跑法:node check.mjs
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync } from 'node:fs'
|
|
10
|
+
import { strict as A } from 'node:assert'
|
|
11
|
+
|
|
12
|
+
// ── 极简 DOM 垫片(只实现 main.js 真正用到的那点) ──
|
|
13
|
+
/**
|
|
14
|
+
* 画布(.cu-live-viewport)的尺寸。实现自己用 layoutViewport 把宽高写进 style,所以垫片
|
|
15
|
+
* 照读即可 —— 不必复刻任何布局规则,量到的就是实现算出来的那个数。
|
|
16
|
+
* (⚠️曾经想让 CSS `aspect-ratio` 干这件事,真浏览器里不成立,见 main.js 里那段注释。)
|
|
17
|
+
*/
|
|
18
|
+
const viewportBoxOfNode = (n) => ({
|
|
19
|
+
w: parseFloat(n.style.width) || 0,
|
|
20
|
+
h: parseFloat(n.style.height) || 0,
|
|
21
|
+
})
|
|
22
|
+
const mkNode = (tag) => {
|
|
23
|
+
const style = { setProperty(k, v) { style[k] = String(v) }, removeProperty(k) { delete style[k] } }
|
|
24
|
+
const n = {
|
|
25
|
+
tag, children: [], attrs: {}, parentNode: null, style, dataset: {},
|
|
26
|
+
className: '', id: '', src: '', alt: '', title: '',
|
|
27
|
+
}
|
|
28
|
+
let own = ''
|
|
29
|
+
Object.defineProperty(n, 'textContent', {
|
|
30
|
+
get: () => own + n.children.map((c) => c.textContent || '').join(''),
|
|
31
|
+
set: (x) => { n.children.forEach((c) => { c.parentNode = null }); n.children.length = 0; own = String(x) },
|
|
32
|
+
})
|
|
33
|
+
n.appendChild = (c) => { n.children.push(c); c.parentNode = n; return c }
|
|
34
|
+
n.append = (...cs) => cs.forEach((c) => n.appendChild(c))
|
|
35
|
+
n.replaceChildren = (...cs) => { n.children.forEach((c) => { c.parentNode = null }); n.children.length = 0; own = ''; cs.forEach((c) => n.appendChild(c)) }
|
|
36
|
+
n.setAttribute = (k, v) => { n.attrs[k] = v }
|
|
37
|
+
n.querySelector = () => null
|
|
38
|
+
n.listeners = {}
|
|
39
|
+
n.addEventListener = (t, fn) => { (n.listeners[t] ||= []).push(fn) }
|
|
40
|
+
n.removeEventListener = (t, fn) => { n.listeners[t] = (n.listeners[t] || []).filter((f) => f !== fn) }
|
|
41
|
+
n.fire = (t, ev = {}) => (n.listeners[t] || []).slice().forEach((f) => f({ preventDefault() {}, ...ev }))
|
|
42
|
+
// 真几何:垫片里 clientWidth/Height 恒 0 的话,缩放那一整套代码在自检里等于裸奔(codex 指出)。
|
|
43
|
+
// 画布是个例外 —— 它的尺寸不是谁设进来的,而是由自己的宽高比 + 舞台算出来的(同 CSS)。
|
|
44
|
+
let ownW = 0
|
|
45
|
+
let ownH = 0
|
|
46
|
+
const isViewport = () => n.className.includes('cu-live-viewport')
|
|
47
|
+
Object.defineProperty(n, 'clientWidth', {
|
|
48
|
+
get: () => (isViewport() ? viewportBoxOfNode(n).w : ownW),
|
|
49
|
+
set: (v) => { ownW = v },
|
|
50
|
+
})
|
|
51
|
+
Object.defineProperty(n, 'clientHeight', {
|
|
52
|
+
get: () => (isViewport() ? viewportBoxOfNode(n).h : ownH),
|
|
53
|
+
set: (v) => { ownH = v },
|
|
54
|
+
})
|
|
55
|
+
n.getBoundingClientRect = () => ({ left: 0, top: 0, width: n.clientWidth, height: n.clientHeight })
|
|
56
|
+
n.captured = []
|
|
57
|
+
n.setPointerCapture = (id) => { n.captured.push(id) }
|
|
58
|
+
n.releasePointerCapture = (id) => { n.captured = n.captured.filter((x) => x !== id) }
|
|
59
|
+
return n
|
|
60
|
+
}
|
|
61
|
+
const head = mkNode('head')
|
|
62
|
+
globalThis.document = {
|
|
63
|
+
hidden: false,
|
|
64
|
+
head,
|
|
65
|
+
createElement: mkNode,
|
|
66
|
+
createTextNode: (t) => { const n = mkNode('#text'); n.textContent = t; return n },
|
|
67
|
+
getElementById: (id) => (head.children.find((c) => c.id === id) || null),
|
|
68
|
+
}
|
|
69
|
+
let observed = null
|
|
70
|
+
globalThis.IntersectionObserver = class {
|
|
71
|
+
constructor(cb) { this.cb = cb; observed = this }
|
|
72
|
+
observe(el) { this.el = el }
|
|
73
|
+
disconnect() { this.disconnected = true }
|
|
74
|
+
fire(isIntersecting) { this.cb([{ isIntersecting }]) }
|
|
75
|
+
}
|
|
76
|
+
// 必须有:没有它,`resizeObserver.disconnect()` 漏掉的回归自检根本发现不了(codex 指出)。
|
|
77
|
+
let resized = null
|
|
78
|
+
globalThis.ResizeObserver = class {
|
|
79
|
+
constructor(cb) { this.cb = cb; resized = this }
|
|
80
|
+
observe(el) { this.el = el }
|
|
81
|
+
disconnect() { this.disconnected = true }
|
|
82
|
+
fire() { this.cb([{}]) }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── 求值 main.js(宿主同款) ──
|
|
86
|
+
const src = readFileSync(new URL('./main.js', import.meta.url), 'utf8')
|
|
87
|
+
const views = []
|
|
88
|
+
const ctx = { registerView: (v) => views.push(v) }
|
|
89
|
+
new Function('ctx', src)(ctx)
|
|
90
|
+
|
|
91
|
+
A.equal(views.length, 1, '应只注册一个视图')
|
|
92
|
+
A.equal(views[0].id, 'live', '视图 id 必须是 live —— 技能里写死了 plugin:tangu-computer-use:live')
|
|
93
|
+
A.equal(typeof views[0].mount, 'function', 'mount 必须是函数')
|
|
94
|
+
|
|
95
|
+
const T = globalThis.__CU_LIVE_TEST__
|
|
96
|
+
A.ok(T, 'main.js 必须暴露 __CU_LIVE_TEST__ 供自检')
|
|
97
|
+
// 技能 SKILL.md 里写死的视图 id 必须与插件 id/视图 id 拼得出来,改一处不改另一处就是死链
|
|
98
|
+
const skill = readFileSync(new URL('./skills/computer-use/SKILL.md', import.meta.url), 'utf8')
|
|
99
|
+
A.ok(skill.includes(`plugin:${T.PLUGIN_ID}:${T.VIEW_ID}`), 'SKILL.md 里的 desk_present 视图 id 与插件不一致')
|
|
100
|
+
const manifest = JSON.parse(readFileSync(new URL('./manifest.json', import.meta.url), 'utf8'))
|
|
101
|
+
A.equal(manifest.id, T.PLUGIN_ID, 'manifest.id 必须与 main.js 里的 PLUGIN_ID 一致(视图 id 由它拼)')
|
|
102
|
+
A.equal(manifest.main, 'main.js')
|
|
103
|
+
|
|
104
|
+
// ── 纯函数 ──
|
|
105
|
+
A.equal(T.ago(0), '刚刚')
|
|
106
|
+
A.equal(T.ago(3000), '3 秒前')
|
|
107
|
+
A.equal(T.ago(120000), '2 分钟前')
|
|
108
|
+
A.equal(T.describe({ app: 'TextEdit', title: '未命名' }), 'TextEdit — 未命名')
|
|
109
|
+
A.equal(T.describe({ app: 'TextEdit' }), 'TextEdit')
|
|
110
|
+
A.equal(T.describe({ windowId: 7 }), '窗口 #7')
|
|
111
|
+
A.equal(T.explain(''), '', '无错误码 → 空串')
|
|
112
|
+
A.ok(T.explain('helper_not_running').includes('还没有窗口'))
|
|
113
|
+
A.ok(T.explain('screen_recording_denied').includes('屏幕录制'))
|
|
114
|
+
A.ok(T.explain('some_new_code').includes('some_new_code'), '未知错误码必须原样带出,不许吞掉线索')
|
|
115
|
+
A.ok(T.explain('client_timeout').includes('超时'), '客户端超时与 helper 截图超时是两码事,都要有说法')
|
|
116
|
+
|
|
117
|
+
// ── 缩放几何(纯函数)──
|
|
118
|
+
// 默认比例 = 整幅塞进舞台
|
|
119
|
+
A.equal(T.fitScaleOf(1000, 500, 400, 400), 0.4, '宽受限时按宽算')
|
|
120
|
+
A.equal(T.fitScaleOf(500, 1000, 400, 400), 0.4, '高受限时按高算')
|
|
121
|
+
A.equal(T.fitScaleOf(0, 0, 400, 400), 1, '尺寸未知时不乱缩')
|
|
122
|
+
A.equal(T.fitScaleOf(1000, 500, 0, 0), 1, '舞台还没布局时不乱缩')
|
|
123
|
+
|
|
124
|
+
// 画布按窗口比例变形:宽窗口 → 画布变矮,高窗口 → 画布变窄。窗口比舞台小也**放大填满**
|
|
125
|
+
// (这是取景框不是原尺寸预览),所以两个方向上都必然有一边贴满舞台 —— 那正是"没有留白"。
|
|
126
|
+
{
|
|
127
|
+
const wide = T.viewportBoxOf(1600, 500, 400, 300)
|
|
128
|
+
A.ok(Math.abs(wide.w - 400) < 1e-6 && Math.abs(wide.h - 125) < 1e-6, `宽窗口画布应变矮,得到 ${JSON.stringify(wide)}`)
|
|
129
|
+
const tall = T.viewportBoxOf(500, 1600, 400, 300)
|
|
130
|
+
A.ok(Math.abs(tall.h - 300) < 1e-6 && Math.abs(tall.w - 93.75) < 1e-6, `高窗口画布应变窄,得到 ${JSON.stringify(tall)}`)
|
|
131
|
+
const box = T.viewportBoxOf(800, 600, 400, 300)
|
|
132
|
+
A.ok(Math.abs(box.w / box.h - 800 / 600) < 1e-9, '画布宽高比必须等于窗口宽高比')
|
|
133
|
+
A.ok(Math.abs(box.w - 400) < 1e-6 || Math.abs(box.h - 300) < 1e-6, '画布必须有一边贴满舞台,否则就是白留了空间')
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ⚠️实时画面的节奏线:helper 已是常驻取景流,这里再回到 1fps 就又变回用户实报的"掉帧幻灯片"。
|
|
137
|
+
A.ok(T.TICK_MS <= 200, `实时画面轮询必须 ≤200ms(现在 ${T.TICK_MS}ms)—— 慢过这个就不是 screen sharing 了`)
|
|
138
|
+
|
|
139
|
+
// 平移夹紧:图比舞台大 → 不许把边拖进舞台里面(拖出空白就再也找不回图了)
|
|
140
|
+
A.equal(T.clampOffset(500, 2000, 400), 0, '右边界:最多贴住左沿')
|
|
141
|
+
A.equal(T.clampOffset(-5000, 2000, 400), -1600, '左边界:最多贴住右沿')
|
|
142
|
+
A.equal(T.clampOffset(-100, 2000, 400), -100, '范围内原样保留')
|
|
143
|
+
// 图比舞台小 → 一律居中
|
|
144
|
+
A.equal(T.clampOffset(999, 200, 400), 100, '图比舞台小时强制居中')
|
|
145
|
+
|
|
146
|
+
// 以光标为锚缩放:锚点下的那个像素必须不动
|
|
147
|
+
{
|
|
148
|
+
const anchor = 300
|
|
149
|
+
const off0 = -100
|
|
150
|
+
const s0 = 1
|
|
151
|
+
const s1 = 2
|
|
152
|
+
const off1 = T.anchoredOffset(off0, anchor, s0, s1)
|
|
153
|
+
// 锚点在图片自身坐标系里的位置,缩放前后应一致
|
|
154
|
+
A.ok(Math.abs((anchor - off0) / s0 - (anchor - off1) / s1) < 1e-9, '缩放后锚点像素发生了漂移')
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 倍率夹紧:下限是"整幅可见",上限 MAX_SCALE
|
|
158
|
+
A.equal(T.clampScale(0.01, 0.4), 0.4, '不许缩到比默认比例还小(只会徒增空白)')
|
|
159
|
+
A.equal(T.clampScale(999, 0.4), T.MAX_SCALE, '上限封顶')
|
|
160
|
+
A.equal(T.clampScale(2, 0.4), 2, '范围内原样保留')
|
|
161
|
+
|
|
162
|
+
// ── 挂载行为 ──
|
|
163
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
164
|
+
const calls = []
|
|
165
|
+
let reply = { active: false }
|
|
166
|
+
// 假 API 必须**照实现主进程的语义**:image:false 就是不给图。
|
|
167
|
+
// (第一版偷懒无视这个参数,于是"没要图时保留画面"那条断言压根没被执行到 —— 没牙的断言比没有更坏。)
|
|
168
|
+
globalThis.window = {
|
|
169
|
+
tangu: {
|
|
170
|
+
computerUseLiveView: async (o) => {
|
|
171
|
+
calls.push(o)
|
|
172
|
+
if (o && o.image === false) { const { jpegBase64, width, height, ...rest } = reply; return rest }
|
|
173
|
+
return reply
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const host = mkNode('div')
|
|
179
|
+
const dispose = views[0].mount(host)
|
|
180
|
+
const rootOf = () => host.children[0]
|
|
181
|
+
const stageOf = () => rootOf().children[1]
|
|
182
|
+
const imgsIn = (n) => { const o = []; ;(function w(x) { if (x.tag === 'img') o.push(x); (x.children || []).forEach(w) })(n); return o }
|
|
183
|
+
const msgsIn = (n) => { const o = []; ;(function w(x) { if (x.className === 'cu-live-msg') o.push(x); (x.children || []).forEach(w) })(n); return o }
|
|
184
|
+
// 给舞台真尺寸,否则缩放几何在自检里全走"量不到→按 1"的退化分支
|
|
185
|
+
stageOf().clientWidth = 400
|
|
186
|
+
stageOf().clientHeight = 300
|
|
187
|
+
|
|
188
|
+
// ⚠️可见性**未知**必须按可见处理 —— 这是「永久静默」那个 bug 的回归线:
|
|
189
|
+
// 隐藏页面里浏览器根本不做交集计算,IntersectionObserver 连首次回调都不来,
|
|
190
|
+
// 初值若当成「不可见」就永远翻不了身(实测 ioHits=0,一帧都不请求)。
|
|
191
|
+
await sleep(30)
|
|
192
|
+
A.ok(calls.length >= 1, 'IO 还没表态时必须按可见处理并正常轮询(否则永久静默)')
|
|
193
|
+
A.ok(rootOf().textContent.includes('暂无正在被操控的窗口'), '没在操控时应给出说明')
|
|
194
|
+
|
|
195
|
+
// 有画面 → 显示 img,且头部换成窗口名。**必须带真尺寸**:没有尺寸就走不到缩放几何。
|
|
196
|
+
reply = { active: true, windowId: 3, app: 'Safari', title: '标签页', ageMs: 500, jpegBase64: 'AAAA', width: 800, height: 600 }
|
|
197
|
+
await sleep(T.IDLE_TICK_MS + 60)
|
|
198
|
+
A.equal(imgsIn(rootOf()).length, 1, '有画面时舞台上应只有一个 img')
|
|
199
|
+
A.equal(imgsIn(rootOf())[0].src, 'data:image/jpeg;base64,AAAA')
|
|
200
|
+
A.equal(msgsIn(stageOf()).length, 0, '有画面时舞台上不许还留着说明文案(图与文案互斥)')
|
|
201
|
+
A.ok(rootOf().textContent.includes('Safari'), '头部应显示 App 名')
|
|
202
|
+
// 舞台 400x300、画面 800x600(同为 4:3)→ 画布铺满舞台,默认比例 = 400/800 = 50%
|
|
203
|
+
const pctNow = () => { let t = null; ;(function w(n) { if (n.className === 'cu-live-pct') t = n.textContent; (n.children || []).forEach(w) })(rootOf()); return t }
|
|
204
|
+
A.equal(pctNow(), '50%', '默认比例应按真实几何算出(800x600 塞进 400x300 = 50%)')
|
|
205
|
+
// ⚠️"没有适配窗口 size"的回归线:画布的宽高比必须每帧跟着被操控窗口写进去。
|
|
206
|
+
const viewportOf = () => { let v = null; ;(function w(n) { if (n.className.includes('cu-live-viewport')) v = n; (n.children || []).forEach(w) })(rootOf()); return v }
|
|
207
|
+
A.equal(`${viewportOf().style.width}x${viewportOf().style.height}`, '400px x300px'.replace(' ', ''), '画布必须按这一帧的窗口比例变形(800x600 → 铺满 400x300)')
|
|
208
|
+
|
|
209
|
+
// ⚠️静止窗口:helper 回 unchanged(不含图)时必须**保留**现有画面,而不是当成"拿不到画面"。
|
|
210
|
+
// 同时必须真的把上次的 frameSeq 带回去,否则 helper 永远认不出"没变",每秒重编 8 次 JPEG。
|
|
211
|
+
{
|
|
212
|
+
reply = { active: true, windowId: 3, app: 'Safari', ageMs: 300, jpegBase64: 'SEQ1', width: 800, height: 600, frameSeq: '42' }
|
|
213
|
+
await sleep(T.TICK_MS + 120)
|
|
214
|
+
A.equal(imgsIn(rootOf()).length, 1, '先要有一帧画面')
|
|
215
|
+
await sleep(T.TICK_MS + 120)
|
|
216
|
+
A.equal(calls[calls.length - 1].sinceFrame, '42', '必须把上次的 frameSeq 带回去,否则省不掉重编')
|
|
217
|
+
reply = { active: true, windowId: 3, app: 'Safari', ageMs: 400, unchanged: true }
|
|
218
|
+
await sleep(T.TICK_MS + 120)
|
|
219
|
+
A.equal(imgsIn(rootOf()).length, 1, 'unchanged 时必须保留现有画面,不许换成"拿不到画面"')
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// 转为「拿不到画面」→ 必须把旧图撤下,不能留着上一帧假装实时
|
|
223
|
+
reply = { active: true, windowId: 3, app: 'Safari', error: 'screen_recording_denied' }
|
|
224
|
+
await sleep(T.TICK_MS + 80)
|
|
225
|
+
A.equal(imgsIn(rootOf()).length, 0, '拿不到画面时必须撤下旧图(不许留着上一帧冒充实时)')
|
|
226
|
+
A.ok(rootOf().textContent.includes('屏幕录制'), '应说明是权限问题')
|
|
227
|
+
|
|
228
|
+
// 滚出可视区 → **降频**(不是停),且主动不要图(省掉真正贵的那一步:一次真实截屏)
|
|
229
|
+
reply = { active: true, windowId: 3, app: 'Safari', ageMs: 10, jpegBase64: 'BBBB', width: 800, height: 600 }
|
|
230
|
+
await sleep(T.TICK_MS + 80) // 先在可见态拿到一帧真画面
|
|
231
|
+
observed.fire(false)
|
|
232
|
+
const before = calls.length
|
|
233
|
+
await sleep(T.HIDDEN_TICK_MS + 200)
|
|
234
|
+
A.ok(calls.length > before, '看不见也必须继续问状态 —— 只降频,绝不完全停(永久静默的教训)')
|
|
235
|
+
A.equal(calls[calls.length - 1].image, false, '看不见时不该要图')
|
|
236
|
+
// 而且不许因为"这一帧没图"把已经在显示的画面撤成错误文案
|
|
237
|
+
const imgs3 = []
|
|
238
|
+
;(function walk(n) { if (n.tag === 'img') imgs3.push(n); (n.children || []).forEach(walk) })(rootOf())
|
|
239
|
+
A.equal(imgs3.length, 1, '我们主动没要图时,应保留正在显示的那一帧,而不是换成"拿不到画面"')
|
|
240
|
+
observed.fire(true)
|
|
241
|
+
await sleep(120)
|
|
242
|
+
A.ok(calls[calls.length - 1].image !== false, '重新可见后应立刻恢复要图')
|
|
243
|
+
|
|
244
|
+
// ── 缩放/取景的行为(几何在垫片里是真的了,这些才有意义)──
|
|
245
|
+
const scaleNow = () => { let t = null; ;(function w(n) { if (n.tag === 'img') t = n.style.transform; (n.children || []).forEach(w) })(rootOf()); return t }
|
|
246
|
+
reply = { active: true, windowId: 3, app: 'Safari', ageMs: 10, jpegBase64: 'CCCC', width: 800, height: 600 }
|
|
247
|
+
observed.fire(true)
|
|
248
|
+
await sleep(T.TICK_MS + 120)
|
|
249
|
+
const fitTf = scaleNow()
|
|
250
|
+
// 放大一档(点 + 按钮)
|
|
251
|
+
let btnIn = null
|
|
252
|
+
;(function w(n) { if (n.tag === 'button' && n.textContent === '+') btnIn = n; (n.children || []).forEach(w) })(rootOf())
|
|
253
|
+
btnIn.fire('click')
|
|
254
|
+
const zoomedTf = scaleNow()
|
|
255
|
+
A.notEqual(zoomedTf, fitTf, '点 + 应该真的改变缩放')
|
|
256
|
+
|
|
257
|
+
// ⚠️用户放大着,新帧到来不许把取景弹回默认
|
|
258
|
+
await sleep(T.TICK_MS + 120)
|
|
259
|
+
A.equal(scaleNow(), zoomedTf, '常规新帧不得重置用户的取景')
|
|
260
|
+
|
|
261
|
+
// ⚠️用户一放大,我们就主动要更高清的原图(maxDimension 800→1600)→ 同一个窗口回来的像素翻倍。
|
|
262
|
+
// scale 是「相对原始像素」的倍率,像素翻倍而 scale 不动,画面就凭空放大一倍、锚点跟着跳。
|
|
263
|
+
// 判据只能是「用户看到的宽度」不变,不能是 scale 不变。
|
|
264
|
+
const visualWidthOf = (nat) => { const m = /scale\(([\d.]+)\)/.exec(scaleNow()); return nat * Number(m[1]) }
|
|
265
|
+
{
|
|
266
|
+
const before = visualWidthOf(800)
|
|
267
|
+
reply = { ...reply, jpegBase64: 'CCCC2', width: 1600, height: 1200 }
|
|
268
|
+
await sleep(T.TICK_MS + 120)
|
|
269
|
+
const after = visualWidthOf(1600)
|
|
270
|
+
A.ok(Math.abs(after - before) < 0.01, `换高清源不得改变看到的大小:${before} → ${after}`)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ⚠️用户放大着,目标窗口改了尺寸 → 守住倍率,但平移必须夹回新边界(不能让画面滑出舞台)
|
|
274
|
+
reply = { ...reply, jpegBase64: 'DDDD', width: 300, height: 200 }
|
|
275
|
+
await sleep(T.TICK_MS + 120)
|
|
276
|
+
{
|
|
277
|
+
const vp = viewportOf()
|
|
278
|
+
const ar = parseFloat(vp.style.width) / parseFloat(vp.style.height)
|
|
279
|
+
A.ok(Math.abs(ar - 300 / 200) < 1e-6, `窗口改了尺寸,画布比例必须跟着改,实得 ${vp.style.width}x${vp.style.height}`)
|
|
280
|
+
}
|
|
281
|
+
{
|
|
282
|
+
const m = /translate\((-?[\d.]+)px, (-?[\d.]+)px\) scale\(([\d.]+)\)/.exec(scaleNow())
|
|
283
|
+
A.ok(m, '变形应可解析')
|
|
284
|
+
const [, ox, oy, sc] = m.map(Number)
|
|
285
|
+
// 边界是**画布**的,不是舞台的 —— 画布已经按 300x200 变形过了
|
|
286
|
+
const { w: sw, h: sh } = T.viewportBoxOf(300, 200, 400, 300)
|
|
287
|
+
const w = 300 * sc, h = 200 * sc
|
|
288
|
+
const okX = w <= sw ? Math.abs(ox - (sw - w) / 2) < 0.5 : ox <= 0.001 && ox >= sw - w - 0.001
|
|
289
|
+
const okY = h <= sh ? Math.abs(oy - (sh - h) / 2) < 0.5 : oy <= 0.001 && oy >= sh - h - 0.001
|
|
290
|
+
A.ok(okX && okY, `窗口改尺寸后平移必须夹回边界,现在是 ${scaleNow()}`)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ⚠️换了窗口(哪怕尺寸恰好相同)必须复位,不能套用上一个窗口的取景
|
|
294
|
+
reply = { active: true, windowId: 99, app: 'Notes', ageMs: 10, jpegBase64: 'EEEE', width: 300, height: 200 }
|
|
295
|
+
await sleep(T.TICK_MS + 120)
|
|
296
|
+
A.equal(scaleNow(), defaultTfFor(300, 200), '换窗口必须回到默认比例(哪怕尺寸与上一个窗口相同)')
|
|
297
|
+
/** 用与实现同一条规则算期望值,别手推 —— 画布先按比例变形,再在画布里贴合。 */
|
|
298
|
+
function defaultTfFor(w, h) {
|
|
299
|
+
const { w: vw, h: vh } = T.viewportBoxOf(w, h, 400, 300)
|
|
300
|
+
const s = T.fitScaleOf(w, h, vw, vh)
|
|
301
|
+
const off = (box, scaled) => (scaled <= box ? (box - scaled) / 2 : Math.min(0, Math.max(box - scaled, (box - scaled) / 2)))
|
|
302
|
+
return `translate(${off(vw, w * s)}px, ${off(vh, h * s)}px) scale(${s})`
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// 卸载 → 停轮询 + 断开两个 observer + 清空宿主
|
|
306
|
+
dispose()
|
|
307
|
+
A.ok(resized.disconnected, '卸载后必须断开 ResizeObserver')
|
|
308
|
+
const afterDispose = calls.length
|
|
309
|
+
await sleep(T.TICK_MS + T.IDLE_TICK_MS + 120)
|
|
310
|
+
A.equal(calls.length, afterDispose, '卸载后必须停止轮询')
|
|
311
|
+
A.ok(observed.disconnected, '卸载后必须断开 IntersectionObserver')
|
|
312
|
+
A.equal(host.children.length, 0, '卸载后必须清空宿主元素')
|
|
313
|
+
|
|
314
|
+
// ── 跨实例单飞:每帧都是一次真实截屏(helper 最长 8s),两个实例同时在场不能各截各的 ──
|
|
315
|
+
{
|
|
316
|
+
let release
|
|
317
|
+
const hang = new Promise((r) => { release = r })
|
|
318
|
+
let hits = 0
|
|
319
|
+
globalThis.window = { tangu: { computerUseLiveView: async () => { hits++; await hang; return { active: false } } } }
|
|
320
|
+
const a = mkNode('div')
|
|
321
|
+
const b = mkNode('div')
|
|
322
|
+
const da = views[0].mount(a)
|
|
323
|
+
const oa = observed
|
|
324
|
+
const db = views[0].mount(b)
|
|
325
|
+
const ob = observed
|
|
326
|
+
oa.fire(true)
|
|
327
|
+
ob.fire(true)
|
|
328
|
+
await sleep(30)
|
|
329
|
+
A.equal(hits, 1, '两个实例同时取景只应发一次请求(共享单飞)')
|
|
330
|
+
release({ active: false })
|
|
331
|
+
await sleep(10)
|
|
332
|
+
da(); db()
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ⚠️落地时只许清**自己**那一份单飞槽:另一个桶的请求可能已经把槽换成它的了,
|
|
336
|
+
// 无条件清会让那个还在飞的请求失去保护 —— 下一个实例立刻又发一次真实取景(codex 指出)。
|
|
337
|
+
{
|
|
338
|
+
const gates = []
|
|
339
|
+
const seen = []
|
|
340
|
+
globalThis.window = { tangu: { computerUseLiveView: (o) => { seen.push(o.image); return new Promise((r) => gates.push(() => r({ active: false }))) } } }
|
|
341
|
+
const a = T.fetchFrame(globalThis.window.tangu.computerUseLiveView, { image: false })
|
|
342
|
+
const b = T.fetchFrame(globalThis.window.tangu.computerUseLiveView, { maxDimension: 800 })
|
|
343
|
+
A.equal(seen.length, 2, '两个桶各应发一次')
|
|
344
|
+
gates[0]() // 先落地的是**另一个桶**的 a
|
|
345
|
+
await a
|
|
346
|
+
const c = T.fetchFrame(globalThis.window.tangu.computerUseLiveView, { maxDimension: 800 })
|
|
347
|
+
A.equal(seen.length, 2, 'a 落地不得清掉 b 的单飞槽,否则 c 会再发一次真实取景')
|
|
348
|
+
A.equal(c, b, 'c 应复用还在飞的 b')
|
|
349
|
+
gates[1]()
|
|
350
|
+
await Promise.all([b, c])
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ⚠️单飞不许跨"要不要图"复用:慢速实例先发的 {image:false} 被要图的实例捡走,
|
|
354
|
+
// 那边看到没有 jpegBase64 就会误判成"拿不到画面" —— 我们没要 ≠ 拿不到。
|
|
355
|
+
{
|
|
356
|
+
const seen = []
|
|
357
|
+
let release
|
|
358
|
+
const hang = new Promise((r) => { release = r })
|
|
359
|
+
globalThis.window = { tangu: { computerUseLiveView: async (o) => { seen.push(o.image); await hang; return { active: false } } } }
|
|
360
|
+
const p1 = T.fetchFrame(globalThis.window.tangu.computerUseLiveView, { image: false })
|
|
361
|
+
const p2 = T.fetchFrame(globalThis.window.tangu.computerUseLiveView, { maxDimension: 1280 })
|
|
362
|
+
A.notEqual(p1, p2, '要图与不要图的请求不得共用同一个 Promise')
|
|
363
|
+
A.deepEqual(seen, [false, undefined], '两种请求都应真的发出去')
|
|
364
|
+
release()
|
|
365
|
+
await Promise.all([p1, p2])
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
console.log('✅ computer-use 插件自检通过')
|
|
369
|
+
process.exit(0)
|
package/icon.png
ADDED
|
Binary file
|