juce-webview-agent-bridge 0.3.1 → 0.5.0
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 +125 -0
- package/README.md +8 -4
- package/package.json +17 -3
- package/tools/e2e.d.mts +14 -11
- package/tools/e2e.mjs +60 -13
- package/tools/shared.d.mts +43 -0
- package/tools/shared.mjs +71 -1
- package/tools/web-agent.mjs +30 -2
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Notable user-facing changes are recorded here. Release artifacts and complete
|
|
4
|
+
commit comparisons are available from the linked GitHub Releases.
|
|
5
|
+
|
|
6
|
+
## [Unreleased]
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- The `hello` reply now reports `moduleVersion`, the version of the C++ module
|
|
11
|
+
the host embeds. `protocolVersion` only moves on a breaking change, so it
|
|
12
|
+
could not identify a plugin built against an older module pin.
|
|
13
|
+
- Both clients now negotiate capabilities against the `hello` handshake. An API
|
|
14
|
+
that needs an op the host does not provide fails immediately with an error
|
|
15
|
+
naming the host module version, the client version, and the fix (bump the
|
|
16
|
+
plugin's `GIT_TAG` and rebuild) instead of the host's bare `unknown op`.
|
|
17
|
+
Connecting to a host that advertises a newer protocol major now fails outright
|
|
18
|
+
rather than misbehaving later.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- `page.replayEvents()` now surfaces a failed `sink_replay` reply as an error
|
|
23
|
+
instead of silently resolving with an undefined count.
|
|
24
|
+
- `connect()` now fails when the host rejects the page-helper injection, instead
|
|
25
|
+
of returning a session whose every locator then failed for unclear reasons.
|
|
26
|
+
A failed authentication also surfaces here rather than passing silently.
|
|
27
|
+
- `connect()` no longer leaves the connection open when initialization fails.
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
|
|
31
|
+
- The CLI performs the handshake once per run, so a host advertising a newer
|
|
32
|
+
protocol major is refused before the command runs rather than part-way through.
|
|
33
|
+
`ping` and `hello` are exempt: they are the diagnostics you need in order to
|
|
34
|
+
see a version mismatch at all.
|
|
35
|
+
- `page.capabilities()` returns the handshake taken during `connect()` (it
|
|
36
|
+
cannot change within a connection) instead of issuing a second `hello`.
|
|
37
|
+
`page.caps` exposes the same value, or `null` against a host too old to answer
|
|
38
|
+
the handshake — in which case every capability guard stands down, so existing
|
|
39
|
+
setups keep working unchanged.
|
|
40
|
+
|
|
41
|
+
## [0.4.0] - 2026-07-18
|
|
42
|
+
|
|
43
|
+
### Added
|
|
44
|
+
|
|
45
|
+
- Added a maintained project changelog and repository social preview.
|
|
46
|
+
|
|
47
|
+
### Changed
|
|
48
|
+
|
|
49
|
+
- Improved the npm package description and search keywords.
|
|
50
|
+
- Raised the supported Node.js floor from the end-of-life Node 18 to Node 22;
|
|
51
|
+
CI now covers the supported Node 22 and 24 LTS lines.
|
|
52
|
+
- Added npm version and license badges to the README.
|
|
53
|
+
|
|
54
|
+
## [0.3.1] - 2026-07-18
|
|
55
|
+
|
|
56
|
+
### Changed
|
|
57
|
+
|
|
58
|
+
- Replaced long-lived npm publishing tokens with Trusted Publishing through
|
|
59
|
+
GitHub Actions OIDC.
|
|
60
|
+
- Updated the official GitHub Actions to their Node 24-compatible releases.
|
|
61
|
+
|
|
62
|
+
There are no package API changes in this release.
|
|
63
|
+
|
|
64
|
+
## [0.3.0] - 2026-07-18
|
|
65
|
+
|
|
66
|
+
### Added
|
|
67
|
+
|
|
68
|
+
- Published the zero-runtime-dependency Node client as
|
|
69
|
+
`juce-webview-agent-bridge`.
|
|
70
|
+
- Added strict TypeScript sources and declarations for the Playwright-shaped E2E
|
|
71
|
+
API, shared discovery helpers, and CLI.
|
|
72
|
+
- Added package smoke tests covering clean installation, imports, declarations,
|
|
73
|
+
and the canonical `npx juce-webview-agent-bridge` command.
|
|
74
|
+
- Added the root CMake entry point for JUCE consumers using `FetchContent`.
|
|
75
|
+
- Added the manually triggered, fully tested GitHub and npm release workflow.
|
|
76
|
+
|
|
77
|
+
## [0.2.2] - 2026-07-17
|
|
78
|
+
|
|
79
|
+
### Fixed
|
|
80
|
+
|
|
81
|
+
- Kept the native Windows screenshot backend compatible with the documented
|
|
82
|
+
C++17 minimum when recent MSVC libraries include C++/WinRT coroutine headers.
|
|
83
|
+
|
|
84
|
+
## [0.2.1] - 2026-07-17
|
|
85
|
+
|
|
86
|
+
### Added
|
|
87
|
+
|
|
88
|
+
- Added native Windows 11 screenshots using Windows Graphics Capture and D3D11.
|
|
89
|
+
- Added full-window and selector-based captures with DPI-aware cropping.
|
|
90
|
+
- Advertised Windows screenshot support through the `hello` handshake.
|
|
91
|
+
|
|
92
|
+
## [0.2.0] - 2026-07-16
|
|
93
|
+
|
|
94
|
+
### Added
|
|
95
|
+
|
|
96
|
+
- Added the macOS `layerdebug` op and `page.layerDebug()` for WebKit compositing
|
|
97
|
+
overlays.
|
|
98
|
+
- Added the macOS `layertree` op and `page.layerTree()` for programmatic CALayer
|
|
99
|
+
inspection.
|
|
100
|
+
- Added `parseLayerTree()` for extracting layer geometry from tree dumps.
|
|
101
|
+
|
|
102
|
+
### Changed
|
|
103
|
+
|
|
104
|
+
- Expanded CI to require the C++ suite on Windows and Linux.
|
|
105
|
+
- Split detailed E2E and protocol documentation out of the README.
|
|
106
|
+
|
|
107
|
+
## [0.1.0] - 2026-07-13
|
|
108
|
+
|
|
109
|
+
### Added
|
|
110
|
+
|
|
111
|
+
- Initial debug-only JUCE WebView bridge with loopback discovery and session
|
|
112
|
+
authentication.
|
|
113
|
+
- JavaScript evaluation, DOM interaction, console/error/network capture, event
|
|
114
|
+
replay, and native macOS screenshots.
|
|
115
|
+
- Zero-dependency CLI, Playwright-shaped E2E client, agent skill, and standalone
|
|
116
|
+
JavaScript and C++ test suites.
|
|
117
|
+
|
|
118
|
+
[Unreleased]: https://github.com/mateusz28011/juce-webview-agent-bridge/compare/v0.4.0...HEAD
|
|
119
|
+
[0.4.0]: https://github.com/mateusz28011/juce-webview-agent-bridge/releases/tag/v0.4.0
|
|
120
|
+
[0.3.1]: https://github.com/mateusz28011/juce-webview-agent-bridge/releases/tag/v0.3.1
|
|
121
|
+
[0.3.0]: https://github.com/mateusz28011/juce-webview-agent-bridge/releases/tag/v0.3.0
|
|
122
|
+
[0.2.2]: https://github.com/mateusz28011/juce-webview-agent-bridge/releases/tag/v0.2.2
|
|
123
|
+
[0.2.1]: https://github.com/mateusz28011/juce-webview-agent-bridge/releases/tag/v0.2.1
|
|
124
|
+
[0.2.0]: https://github.com/mateusz28011/juce-webview-agent-bridge/releases/tag/v0.2.0
|
|
125
|
+
[0.1.0]: https://github.com/mateusz28011/juce-webview-agent-bridge/releases/tag/v0.1.0
|
package/README.md
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
#
|
|
1
|
+
# JUCE WebView Agent Bridge
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/juce-webview-agent-bridge)
|
|
3
4
|
[](https://github.com/mateusz28011/juce-webview-agent-bridge/actions/workflows/tests.yml)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+

|
|
4
8
|
|
|
5
9
|
A **debug-only** JUCE module that lets an external agent (or script) drive a live
|
|
6
10
|
`juce::WebBrowserComponent` over a loopback TCP socket — a "mini-CDP" giving you a
|
|
@@ -70,7 +74,7 @@ inspection remains macOS-only. Linux real-app reports and fixes are very welcome
|
|
|
70
74
|
## Install
|
|
71
75
|
|
|
72
76
|
**Requirements:** JUCE 8, C++17, a `juce::WebBrowserComponent`-based UI; the
|
|
73
|
-
clients need Node ≥
|
|
77
|
+
clients need Node ≥ 22 (zero runtime dependencies).
|
|
74
78
|
|
|
75
79
|
It's a standard JUCE module. Get the repo — as a submodule:
|
|
76
80
|
|
|
@@ -85,7 +89,7 @@ or with CMake `FetchContent` (after JUCE has been added, so
|
|
|
85
89
|
include(FetchContent)
|
|
86
90
|
FetchContent_Declare(juce_webview_agent_bridge
|
|
87
91
|
GIT_REPOSITORY https://github.com/mateusz28011/juce-webview-agent-bridge.git
|
|
88
|
-
GIT_TAG v0.
|
|
92
|
+
GIT_TAG v0.5.0)
|
|
89
93
|
FetchContent_MakeAvailable(juce_webview_agent_bridge)
|
|
90
94
|
```
|
|
91
95
|
|
|
@@ -201,7 +205,7 @@ Both suites live in `tests/` and run **independently of any host app**, so they
|
|
|
201
205
|
keep working after this module is extracted into its own repo.
|
|
202
206
|
|
|
203
207
|
```bash
|
|
204
|
-
# JS client — zero dependencies, Node >=
|
|
208
|
+
# JS client — zero dependencies, Node >= 22
|
|
205
209
|
npm test # or: node --test tests/*.test.mjs
|
|
206
210
|
|
|
207
211
|
# Maintainer build — strict TypeScript source -> committed .mjs + .d.mts
|
package/package.json
CHANGED
|
@@ -1,7 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "juce-webview-agent-bridge",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Typed, zero-dependency E2E client and CLI for driving live JUCE WebViews over a debug-only loopback bridge, without CDP.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"juce",
|
|
7
|
+
"webview",
|
|
8
|
+
"wkwebview",
|
|
9
|
+
"webview2",
|
|
10
|
+
"e2e",
|
|
11
|
+
"testing",
|
|
12
|
+
"playwright",
|
|
13
|
+
"agent",
|
|
14
|
+
"automation",
|
|
15
|
+
"debugging",
|
|
16
|
+
"mcp-alternative"
|
|
17
|
+
],
|
|
5
18
|
"type": "module",
|
|
6
19
|
"types": "./tools/e2e.d.mts",
|
|
7
20
|
"exports": {
|
|
@@ -31,6 +44,7 @@
|
|
|
31
44
|
"files": [
|
|
32
45
|
"tools/",
|
|
33
46
|
"README.md",
|
|
47
|
+
"CHANGELOG.md",
|
|
34
48
|
"LICENSE"
|
|
35
49
|
],
|
|
36
50
|
"license": "MIT",
|
|
@@ -39,7 +53,7 @@
|
|
|
39
53
|
"url": "git+https://github.com/mateusz28011/juce-webview-agent-bridge.git"
|
|
40
54
|
},
|
|
41
55
|
"engines": {
|
|
42
|
-
"node": ">=
|
|
56
|
+
"node": ">=22"
|
|
43
57
|
},
|
|
44
58
|
"publishConfig": {
|
|
45
59
|
"access": "public"
|
package/tools/e2e.d.mts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Socket } from 'node:net';
|
|
2
|
+
import type { BridgeCapabilities } from './shared.mjs';
|
|
2
3
|
type ProtocolMessage = Record<string, any>;
|
|
3
4
|
type LogFn = (message: string) => void;
|
|
4
5
|
type TimeoutOptions = {
|
|
@@ -43,13 +44,10 @@ export type NetworkEventData = {
|
|
|
43
44
|
status?: number;
|
|
44
45
|
[key: string]: unknown;
|
|
45
46
|
};
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
screenshotAvailable: boolean;
|
|
51
|
-
authRequired: boolean;
|
|
52
|
-
};
|
|
47
|
+
/** The `hello` handshake. Shape owned by shared.mts (both clients negotiate
|
|
48
|
+
against it); re-exported here under the name this client has always used. */
|
|
49
|
+
export type Capabilities = BridgeCapabilities;
|
|
50
|
+
export type { BridgeCapabilities };
|
|
53
51
|
export type RenderPerfResult = {
|
|
54
52
|
durMs: number;
|
|
55
53
|
commitsPerSec: number;
|
|
@@ -162,12 +160,16 @@ export declare class Page {
|
|
|
162
160
|
readonly interval: number;
|
|
163
161
|
readonly backendTimeoutMs: number;
|
|
164
162
|
readonly log: LogFn;
|
|
163
|
+
/** The `hello` handshake taken at connect(), or null when the host could not
|
|
164
|
+
answer it. Immutable for the life of the connection (one host process). */
|
|
165
|
+
readonly caps: BridgeCapabilities | null;
|
|
165
166
|
logFile: string | null;
|
|
166
|
-
constructor(session: Session, { defaultTimeout, interval, log, backendTimeoutMs }: {
|
|
167
|
+
constructor(session: Session, { defaultTimeout, interval, log, backendTimeoutMs, caps }: {
|
|
167
168
|
defaultTimeout: number;
|
|
168
169
|
interval: number;
|
|
169
170
|
log: LogFn;
|
|
170
171
|
backendTimeoutMs?: number;
|
|
172
|
+
caps?: BridgeCapabilities | null;
|
|
171
173
|
});
|
|
172
174
|
locator(selector: string): Locator;
|
|
173
175
|
getByTestId(id: string): Locator;
|
|
@@ -241,8 +243,10 @@ export declare class Page {
|
|
|
241
243
|
motionSelector?: string | null;
|
|
242
244
|
}): Promise<RenderPerfResult>;
|
|
243
245
|
/** Capabilities handshake: { protocolVersion, platform, ops, screenshotAvailable,
|
|
244
|
-
authRequired }. Lets a caller branch on what the host supports
|
|
245
|
-
screenshots when screenshotAvailable is false) without probing
|
|
246
|
+
authRequired, moduleVersion? }. Lets a caller branch on what the host supports
|
|
247
|
+
(e.g. skip screenshots when screenshotAvailable is false) without probing
|
|
248
|
+
op-by-op. Returns the handshake connect() already took — it cannot change
|
|
249
|
+
within a connection — and only asks again if that one did not land. */
|
|
246
250
|
capabilities(): Promise<Capabilities>;
|
|
247
251
|
/** Toggle WebKit's compositing debug overlays (layer borders + repaint
|
|
248
252
|
counters) on the host's WKWebView via the bridge `layerdebug` op. The
|
|
@@ -356,4 +360,3 @@ export interface ExpectFunction {
|
|
|
356
360
|
poll<T>(fn: () => T | Promise<T>, options?: PollExpectOptions): PollAssertions<T>;
|
|
357
361
|
}
|
|
358
362
|
export declare const expect: ExpectFunction;
|
|
359
|
-
export {};
|
package/tools/e2e.mjs
CHANGED
|
@@ -31,7 +31,7 @@ import net from 'node:net';
|
|
|
31
31
|
import fs from 'node:fs';
|
|
32
32
|
import os from 'node:os';
|
|
33
33
|
import path from 'node:path';
|
|
34
|
-
import { DEFAULT_PORT, loadDiscovery, onJsonLines } from './shared.mjs';
|
|
34
|
+
import { DEFAULT_PORT, assertProtocolSupported, loadDiscovery, onJsonLines, parseHello, requireOp } from './shared.mjs';
|
|
35
35
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
36
36
|
/** Bring the host app's window to the foreground (macOS, best-effort; resolves
|
|
37
37
|
* false elsewhere). A backgrounded WebView reports document.hidden === true and
|
|
@@ -450,6 +450,21 @@ class Session {
|
|
|
450
450
|
}
|
|
451
451
|
catch { } }
|
|
452
452
|
}
|
|
453
|
+
/** Run the `hello` handshake and validate the protocol major.
|
|
454
|
+
Null means "capabilities unknown" — a host too old to answer `hello` must
|
|
455
|
+
stay usable exactly as before, so every guard stands down.
|
|
456
|
+
|
|
457
|
+
A transport failure is NOT folded into null: the reference host always
|
|
458
|
+
answers unknown ops with `ok:false`, so an exception here means the
|
|
459
|
+
connection is broken, and swallowing it would silently disable the guards
|
|
460
|
+
for the rest of the session. Validating here (rather than at the call site)
|
|
461
|
+
keeps the retry in capabilities() from returning an unvalidated host. */
|
|
462
|
+
async function handshake(session) {
|
|
463
|
+
const caps = parseHello(await session.request({ op: 'hello' }));
|
|
464
|
+
if (caps)
|
|
465
|
+
assertProtocolSupported(caps);
|
|
466
|
+
return caps;
|
|
467
|
+
}
|
|
453
468
|
// ---- public API -----------------------------------------------------------
|
|
454
469
|
export async function connect({ host = '127.0.0.1', port, token, timeout = 5000, interval = 50, log, logFile, logEcho, backendTimeoutMs = 10000, activate } = {}) {
|
|
455
470
|
// Foreground the host window first (see activateApp) so polling/timers are live
|
|
@@ -472,10 +487,29 @@ export async function connect({ host = '127.0.0.1', port, token, timeout = 5000,
|
|
|
472
487
|
});
|
|
473
488
|
sock.unref(); // the persistent client socket must not, by itself, keep node alive
|
|
474
489
|
const session = new Session(sock, resolvedToken);
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
490
|
+
// Every failure past this point must close the socket before rethrowing:
|
|
491
|
+
// unref() keeps the client socket from holding Node open on its own, but an
|
|
492
|
+
// abandoned connection still pins the host's server handle.
|
|
493
|
+
let caps;
|
|
494
|
+
try {
|
|
495
|
+
if (resolvedToken)
|
|
496
|
+
await session.request({ op: 'auth', token: resolvedToken });
|
|
497
|
+
// Negotiate up front (one loopback round-trip): the client and the host's C++
|
|
498
|
+
// module version independently, so this is what turns a stale module pin into
|
|
499
|
+
// a named mismatch instead of a late `unknown op` or an unchecked reply.
|
|
500
|
+
caps = await handshake(session);
|
|
501
|
+
requireOp(caps, 'eval', 'connect()'); // the helpers below, and every locator, ride on it
|
|
502
|
+
const injected = await session.request({ op: 'eval', code: PAGE_HELPERS }); // inject helpers once
|
|
503
|
+
// An unchecked injection hands back a Page whose every locator then fails
|
|
504
|
+
// cryptically; a failed auth also surfaces here rather than silently.
|
|
505
|
+
if (!injected.ok)
|
|
506
|
+
throw new Error(`bridge rejected the page helpers: ${injected.error || 'unknown error'}`);
|
|
507
|
+
}
|
|
508
|
+
catch (e) {
|
|
509
|
+
session.close();
|
|
510
|
+
throw e;
|
|
511
|
+
}
|
|
512
|
+
const page = new Page(session, { defaultTimeout: timeout, interval, log: logger, backendTimeoutMs, caps });
|
|
479
513
|
page.logFile = file; // where actions are being written (null if a custom log fn was passed)
|
|
480
514
|
return page;
|
|
481
515
|
}
|
|
@@ -485,14 +519,18 @@ export class Page {
|
|
|
485
519
|
interval;
|
|
486
520
|
backendTimeoutMs;
|
|
487
521
|
log;
|
|
522
|
+
/** The `hello` handshake taken at connect(), or null when the host could not
|
|
523
|
+
answer it. Immutable for the life of the connection (one host process). */
|
|
524
|
+
caps;
|
|
488
525
|
logFile = null;
|
|
489
526
|
// opts.log?: (msg) => void — when given, every action (click/fill/drag/backend/
|
|
490
527
|
// fire) emits a concise progress line as it runs. Off by default (no-op).
|
|
491
|
-
constructor(session, { defaultTimeout, interval, log, backendTimeoutMs = 10000 }) {
|
|
528
|
+
constructor(session, { defaultTimeout, interval, log, backendTimeoutMs = 10000, caps = null }) {
|
|
492
529
|
this.session = session;
|
|
493
530
|
this.defaultTimeout = defaultTimeout;
|
|
494
531
|
this.interval = interval;
|
|
495
532
|
this.backendTimeoutMs = backendTimeoutMs;
|
|
533
|
+
this.caps = caps;
|
|
496
534
|
this.log = typeof log === 'function' ? log : () => { };
|
|
497
535
|
}
|
|
498
536
|
locator(selector) { return new Locator(this, selector); }
|
|
@@ -617,7 +655,10 @@ export class Page {
|
|
|
617
655
|
events flow through the same on()/waitForEvent listeners (each carries a
|
|
618
656
|
monotonic `seq` for dedup). Resolves with the number of events replayed. */
|
|
619
657
|
async replayEvents({ since = 0 } = {}) {
|
|
658
|
+
requireOp(this.caps, 'sink_replay', 'page.replayEvents()');
|
|
620
659
|
const r = await this.session.request({ op: 'sink_replay', since });
|
|
660
|
+
if (!r.ok)
|
|
661
|
+
throw new Error(r.error || 'sink_replay failed');
|
|
621
662
|
return r.count;
|
|
622
663
|
}
|
|
623
664
|
/** Poll a JS boolean expression in the page until it is truthy (or time out).
|
|
@@ -706,14 +747,17 @@ export class Page {
|
|
|
706
747
|
})()`));
|
|
707
748
|
}
|
|
708
749
|
/** Capabilities handshake: { protocolVersion, platform, ops, screenshotAvailable,
|
|
709
|
-
authRequired }. Lets a caller branch on what the host supports
|
|
710
|
-
screenshots when screenshotAvailable is false) without probing
|
|
750
|
+
authRequired, moduleVersion? }. Lets a caller branch on what the host supports
|
|
751
|
+
(e.g. skip screenshots when screenshotAvailable is false) without probing
|
|
752
|
+
op-by-op. Returns the handshake connect() already took — it cannot change
|
|
753
|
+
within a connection — and only asks again if that one did not land. */
|
|
711
754
|
async capabilities() {
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
755
|
+
if (this.caps)
|
|
756
|
+
return this.caps;
|
|
757
|
+
const caps = await handshake(this.session);
|
|
758
|
+
if (!caps)
|
|
759
|
+
throw new Error('bridge did not answer the hello handshake');
|
|
760
|
+
return caps;
|
|
717
761
|
}
|
|
718
762
|
/** Toggle WebKit's compositing debug overlays (layer borders + repaint
|
|
719
763
|
counters) on the host's WKWebView via the bridge `layerdebug` op. The
|
|
@@ -722,6 +766,7 @@ export class Page {
|
|
|
722
766
|
macOS-only; throws where the backend has no such SPI. Remember to turn it
|
|
723
767
|
OFF before any pixel-comparison capture: the overlays are pixels too. */
|
|
724
768
|
async layerDebug(enabled = true) {
|
|
769
|
+
requireOp(this.caps, 'layerdebug', 'page.layerDebug()');
|
|
725
770
|
this.log(`layerdebug ${enabled ? 'on' : 'off'}`);
|
|
726
771
|
const r = await this.session.request({ op: 'layerdebug', enabled }, { timeoutMs: 10000 });
|
|
727
772
|
if (!r.ok)
|
|
@@ -733,6 +778,7 @@ export class Page {
|
|
|
733
778
|
to census compositing layers (count, geometry) from a script instead of
|
|
734
779
|
reading overlay pixels off a screenshot. macOS-only; throws elsewhere. */
|
|
735
780
|
async layerTree() {
|
|
781
|
+
requireOp(this.caps, 'layertree', 'page.layerTree()');
|
|
736
782
|
this.log('layertree');
|
|
737
783
|
const r = await this.session.request({ op: 'layertree' }, { timeoutMs: 10000 });
|
|
738
784
|
if (!r.ok)
|
|
@@ -743,6 +789,7 @@ export class Page {
|
|
|
743
789
|
Writes a PNG host-side and returns its path. Pass { path } to choose where, and
|
|
744
790
|
{ clip: {x,y,w,h} } (CSS px) to crop to a UI region for a much smaller PNG. */
|
|
745
791
|
async screenshot({ path, clip } = {}) {
|
|
792
|
+
requireOp(this.caps, 'shot', 'page.screenshot()');
|
|
746
793
|
this.log(`screenshot${clip ? ' (region)' : ''}${path ? ' ' + path : ''}`);
|
|
747
794
|
const r = await this.session.request({ op: 'shot', ...(path ? { path } : {}), ...(clip ? { rect: clip } : {}) }, { timeoutMs: 30000 });
|
|
748
795
|
if (!r.ok)
|
package/tools/shared.d.mts
CHANGED
|
@@ -7,6 +7,49 @@ export interface Discovery {
|
|
|
7
7
|
/** The port the host tries first (it scans upward on collision); clients fall
|
|
8
8
|
* back to it when no discovery file exists. Mirrors WebAgentBridge::start(). */
|
|
9
9
|
export declare const DEFAULT_PORT = 8930;
|
|
10
|
+
/** The protocol major these clients speak.
|
|
11
|
+
*
|
|
12
|
+
* The npm client and the C++ module version independently: the plugin pins a
|
|
13
|
+
* module tag in its build, the test host installs a client from npm, and the
|
|
14
|
+
* two drift. Equal versions are NOT the goal — the protocol is additive, so an
|
|
15
|
+
* older client against a newer module is fine by design. What matters is
|
|
16
|
+
* capability negotiation, and the `hello` reply carries both halves of it:
|
|
17
|
+
* - `ops` — the fine-grained capability list (grows additively);
|
|
18
|
+
* - `protocolVersion` — the coarse tripwire, moved ONLY by a breaking change.
|
|
19
|
+
* So a host advertising a HIGHER protocolVersion is one this client predates. */
|
|
20
|
+
export declare const CLIENT_PROTOCOL_VERSION = 1;
|
|
21
|
+
/** The `hello` handshake. `moduleVersion` is absent on hosts built against a
|
|
22
|
+
* module older than the release that started reporting it. */
|
|
23
|
+
export interface BridgeCapabilities {
|
|
24
|
+
protocolVersion: number;
|
|
25
|
+
platform: string;
|
|
26
|
+
ops: string[];
|
|
27
|
+
screenshotAvailable: boolean;
|
|
28
|
+
authRequired: boolean;
|
|
29
|
+
moduleVersion?: string;
|
|
30
|
+
}
|
|
31
|
+
/** This npm client's own version, read from the package manifest so it cannot
|
|
32
|
+
* drift from what was published. Falls back to 'unknown' — a diagnostic string
|
|
33
|
+
* must never be the thing that throws. */
|
|
34
|
+
export declare function clientVersion(): string;
|
|
35
|
+
/** Normalize a `hello` reply into capabilities, or null when the reply is not a
|
|
36
|
+
* usable handshake — i.e. a host too old to answer `hello` at all, which must
|
|
37
|
+
* keep working with every guard standing down.
|
|
38
|
+
*
|
|
39
|
+
* Deliberately total: it never throws and never inspects the transport. A
|
|
40
|
+
* TRANSPORT failure is not a legacy host, so callers must let that propagate
|
|
41
|
+
* rather than fold it into null — silently disabling the guards for the rest of
|
|
42
|
+
* a connection is exactly the failure this negotiation exists to prevent.
|
|
43
|
+
* Owning the parse here keeps the two clients from drifting apart. */
|
|
44
|
+
export declare function parseHello(reply: Record<string, unknown> | null | undefined): BridgeCapabilities | null;
|
|
45
|
+
/** Throw when the host speaks a protocol major this client cannot. Called once
|
|
46
|
+
* per connection, right after the handshake. */
|
|
47
|
+
export declare function assertProtocolSupported(caps: BridgeCapabilities): void;
|
|
48
|
+
/** Throw an actionable error when the host bridge lacks the op an API needs.
|
|
49
|
+
* Without this the host replies `unknown op: <x>`, which callers either surface
|
|
50
|
+
* raw or (for ops whose reply is not checked) swallow into silently wrong
|
|
51
|
+
* behaviour — neither of which points at the real cause: a stale module pin. */
|
|
52
|
+
export declare function requireOp(caps: BridgeCapabilities | null, op: string, api: string): void;
|
|
10
53
|
/** Locate a running bridge's {port, token}.
|
|
11
54
|
* The host writes them on start so clients never guess: each instance registers
|
|
12
55
|
* <home>/.web_agent_bridge.d/<port>.json (so several hosts — e.g. multiple
|
package/tools/shared.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* bridge discovery and NDJSON wire framing. Single source of truth — the two
|
|
4
4
|
* clients used to carry private copies of this logic, and the copies drifted
|
|
5
5
|
* (the fill focus fix landed in one and not the other), so it now lives here.
|
|
6
|
-
* Still zero third-party dependencies: bare Node >=
|
|
6
|
+
* Still zero third-party dependencies: bare Node >= 22 built-ins only.
|
|
7
7
|
*/
|
|
8
8
|
import fs from 'node:fs';
|
|
9
9
|
import os from 'node:os';
|
|
@@ -12,6 +12,76 @@ import { StringDecoder } from 'node:string_decoder';
|
|
|
12
12
|
/** The port the host tries first (it scans upward on collision); clients fall
|
|
13
13
|
* back to it when no discovery file exists. Mirrors WebAgentBridge::start(). */
|
|
14
14
|
export const DEFAULT_PORT = 8930;
|
|
15
|
+
/** The protocol major these clients speak.
|
|
16
|
+
*
|
|
17
|
+
* The npm client and the C++ module version independently: the plugin pins a
|
|
18
|
+
* module tag in its build, the test host installs a client from npm, and the
|
|
19
|
+
* two drift. Equal versions are NOT the goal — the protocol is additive, so an
|
|
20
|
+
* older client against a newer module is fine by design. What matters is
|
|
21
|
+
* capability negotiation, and the `hello` reply carries both halves of it:
|
|
22
|
+
* - `ops` — the fine-grained capability list (grows additively);
|
|
23
|
+
* - `protocolVersion` — the coarse tripwire, moved ONLY by a breaking change.
|
|
24
|
+
* So a host advertising a HIGHER protocolVersion is one this client predates. */
|
|
25
|
+
export const CLIENT_PROTOCOL_VERSION = 1;
|
|
26
|
+
/** This npm client's own version, read from the package manifest so it cannot
|
|
27
|
+
* drift from what was published. Falls back to 'unknown' — a diagnostic string
|
|
28
|
+
* must never be the thing that throws. */
|
|
29
|
+
export function clientVersion() {
|
|
30
|
+
try {
|
|
31
|
+
const manifest = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
32
|
+
return manifest.version ?? 'unknown';
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return 'unknown';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Normalize a `hello` reply into capabilities, or null when the reply is not a
|
|
39
|
+
* usable handshake — i.e. a host too old to answer `hello` at all, which must
|
|
40
|
+
* keep working with every guard standing down.
|
|
41
|
+
*
|
|
42
|
+
* Deliberately total: it never throws and never inspects the transport. A
|
|
43
|
+
* TRANSPORT failure is not a legacy host, so callers must let that propagate
|
|
44
|
+
* rather than fold it into null — silently disabling the guards for the rest of
|
|
45
|
+
* a connection is exactly the failure this negotiation exists to prevent.
|
|
46
|
+
* Owning the parse here keeps the two clients from drifting apart. */
|
|
47
|
+
export function parseHello(reply) {
|
|
48
|
+
if (!reply || reply.ok !== true || !Array.isArray(reply.ops))
|
|
49
|
+
return null;
|
|
50
|
+
return {
|
|
51
|
+
protocolVersion: Number(reply.protocolVersion),
|
|
52
|
+
platform: String(reply.platform),
|
|
53
|
+
ops: reply.ops,
|
|
54
|
+
screenshotAvailable: reply.screenshotAvailable === true,
|
|
55
|
+
authRequired: reply.authRequired === true,
|
|
56
|
+
...(typeof reply.moduleVersion === 'string' ? { moduleVersion: reply.moduleVersion } : {}),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/** Identify both halves of the pairing for an error message. */
|
|
60
|
+
function describePairing(caps) {
|
|
61
|
+
const host = caps.moduleVersion ?? 'version not reported (module predates moduleVersion)';
|
|
62
|
+
return `host module ${host}, protocol ${caps.protocolVersion}, ${caps.platform}; `
|
|
63
|
+
+ `npm client ${clientVersion()}, protocol ${CLIENT_PROTOCOL_VERSION}`;
|
|
64
|
+
}
|
|
65
|
+
/** Throw when the host speaks a protocol major this client cannot. Called once
|
|
66
|
+
* per connection, right after the handshake. */
|
|
67
|
+
export function assertProtocolSupported(caps) {
|
|
68
|
+
if (!(caps.protocolVersion > CLIENT_PROTOCOL_VERSION))
|
|
69
|
+
return;
|
|
70
|
+
throw new Error(`bridge protocol ${caps.protocolVersion} is newer than this client understands `
|
|
71
|
+
+ `(${CLIENT_PROTOCOL_VERSION}). The protocol major only moves on a BREAKING change, so this `
|
|
72
|
+
+ `npm client is too old for the host's module — upgrade juce-webview-agent-bridge.\n ${describePairing(caps)}`);
|
|
73
|
+
}
|
|
74
|
+
/** Throw an actionable error when the host bridge lacks the op an API needs.
|
|
75
|
+
* Without this the host replies `unknown op: <x>`, which callers either surface
|
|
76
|
+
* raw or (for ops whose reply is not checked) swallow into silently wrong
|
|
77
|
+
* behaviour — neither of which points at the real cause: a stale module pin. */
|
|
78
|
+
export function requireOp(caps, op, api) {
|
|
79
|
+
if (caps === null || caps.ops.includes(op))
|
|
80
|
+
return; // null = handshake unavailable; stay out of the way
|
|
81
|
+
throw new Error(`${api} needs the "${op}" op, which the host's juce_webview_agent_bridge module does not provide. `
|
|
82
|
+
+ `The plugin was built against an older module than this client expects — bump its FetchContent `
|
|
83
|
+
+ `GIT_TAG and rebuild the plugin.\n ${describePairing(caps)}\n host ops: ${caps.ops.join(', ')}`);
|
|
84
|
+
}
|
|
15
85
|
/** Locate a running bridge's {port, token}.
|
|
16
86
|
* The host writes them on start so clients never guess: each instance registers
|
|
17
87
|
* <home>/.web_agent_bridge.d/<port>.json (so several hosts — e.g. multiple
|
package/tools/web-agent.mjs
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
*/
|
|
26
26
|
import net from 'node:net';
|
|
27
27
|
import path from 'node:path';
|
|
28
|
-
import { DEFAULT_PORT, loadDiscovery, onJsonLines } from './shared.mjs';
|
|
28
|
+
import { DEFAULT_PORT, assertProtocolSupported, loadDiscovery, onJsonLines, parseHello, requireOp } from './shared.mjs';
|
|
29
29
|
const argv = process.argv.slice(2);
|
|
30
30
|
const opt = (name, def) => {
|
|
31
31
|
const i = argv.indexOf(name);
|
|
@@ -104,7 +104,31 @@ const fillSnippet = (sel, val) => `(() => {
|
|
|
104
104
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
105
105
|
return 'ok';
|
|
106
106
|
})()`;
|
|
107
|
+
// Commands that must survive a version mismatch instead of being blocked by it:
|
|
108
|
+
// `ping` answers "is anything alive", and `hello` is the very tool you reach for
|
|
109
|
+
// to SEE a mismatch. Refusing them on a protocol bump would remove the two
|
|
110
|
+
// diagnostics you need most at exactly the moment you need them.
|
|
111
|
+
const DIAGNOSTIC_CMDS = new Set(['ping', 'hello']);
|
|
112
|
+
/** Negotiate once per CLI run: refuse a host whose protocol major is newer than
|
|
113
|
+
this client, and expose its op set to the per-command guards. Null means the
|
|
114
|
+
host is too old to answer `hello`, which must keep working untouched. */
|
|
115
|
+
let hostCaps = null;
|
|
116
|
+
async function negotiate() {
|
|
117
|
+
hostCaps = parseHello(await request({ op: 'hello' }));
|
|
118
|
+
if (hostCaps)
|
|
119
|
+
assertProtocolSupported(hostCaps);
|
|
120
|
+
}
|
|
121
|
+
/** Gate a command on an op the host may not have: without this an older module
|
|
122
|
+
answers `unknown op: <x>`, which reads as a client bug rather than the stale
|
|
123
|
+
module pin it actually is. */
|
|
124
|
+
function requireHostOp(op, api) {
|
|
125
|
+
requireOp(hostCaps, op, api);
|
|
126
|
+
}
|
|
107
127
|
async function main() {
|
|
128
|
+
// One handshake per run, before any command runs: a host advertising a newer
|
|
129
|
+
// protocol major is refused here rather than half-served command by command.
|
|
130
|
+
if (cmd && !DIAGNOSTIC_CMDS.has(cmd))
|
|
131
|
+
await negotiate();
|
|
108
132
|
switch (cmd) {
|
|
109
133
|
case 'ping': {
|
|
110
134
|
const r = await request({ op: 'ping' });
|
|
@@ -112,6 +136,7 @@ async function main() {
|
|
|
112
136
|
break;
|
|
113
137
|
}
|
|
114
138
|
case 'layerdebug': {
|
|
139
|
+
requireHostOp('layerdebug', 'the `layerdebug` command');
|
|
115
140
|
const enabled = rest[0] !== 'off';
|
|
116
141
|
const r = await request({ op: 'layerdebug', enabled });
|
|
117
142
|
console.log(r.ok
|
|
@@ -120,6 +145,7 @@ async function main() {
|
|
|
120
145
|
break;
|
|
121
146
|
}
|
|
122
147
|
case 'layertree': {
|
|
148
|
+
requireHostOp('layertree', 'the `layertree` command');
|
|
123
149
|
const r = await request({ op: 'layertree' });
|
|
124
150
|
console.log(r.ok ? r.text : `failed: ${r.error || 'unavailable'}`);
|
|
125
151
|
break;
|
|
@@ -127,7 +153,8 @@ async function main() {
|
|
|
127
153
|
case 'hello': {
|
|
128
154
|
const r = await request({ op: 'hello' });
|
|
129
155
|
console.log(fmt({
|
|
130
|
-
protocolVersion: r.protocolVersion,
|
|
156
|
+
protocolVersion: r.protocolVersion, moduleVersion: r.moduleVersion ?? '(not reported)',
|
|
157
|
+
platform: r.platform,
|
|
131
158
|
screenshotAvailable: r.screenshotAvailable, authRequired: r.authRequired, ops: r.ops,
|
|
132
159
|
}));
|
|
133
160
|
break;
|
|
@@ -181,6 +208,7 @@ async function main() {
|
|
|
181
208
|
// with a different CWD (its .app bundle), so a relative path would both
|
|
182
209
|
// trip JUCE's File-ctor assertion (juce_File.cpp:219 wants absolute) and
|
|
183
210
|
// write the PNG next to the bundle instead of where the caller expects.
|
|
211
|
+
requireHostOp('shot', 'the `shot` command');
|
|
184
212
|
const out = rest[0] ? path.resolve(rest[0]) : undefined;
|
|
185
213
|
const sel = rest[1];
|
|
186
214
|
let rect;
|