juce-webview-agent-bridge 0.3.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/LICENSE +21 -0
- package/README.md +232 -0
- package/package.json +51 -0
- package/tools/e2e.d.mts +359 -0
- package/tools/e2e.mjs +998 -0
- package/tools/shared.d.mts +21 -0
- package/tools/shared.mjs +76 -0
- package/tools/web-agent.d.mts +2 -0
- package/tools/web-agent.mjs +251 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Socket } from 'node:net';
|
|
2
|
+
export interface Discovery {
|
|
3
|
+
port?: number;
|
|
4
|
+
token?: string;
|
|
5
|
+
[key: string]: unknown;
|
|
6
|
+
}
|
|
7
|
+
/** The port the host tries first (it scans upward on collision); clients fall
|
|
8
|
+
* back to it when no discovery file exists. Mirrors WebAgentBridge::start(). */
|
|
9
|
+
export declare const DEFAULT_PORT = 8930;
|
|
10
|
+
/** Locate a running bridge's {port, token}.
|
|
11
|
+
* The host writes them on start so clients never guess: each instance registers
|
|
12
|
+
* <home>/.web_agent_bridge.d/<port>.json (so several hosts — e.g. multiple
|
|
13
|
+
* plugin instances in a DAW — don't clobber each other), plus the single legacy
|
|
14
|
+
* <home>/.web_agent_bridge.json for older single-instance hosts. Enumerate the
|
|
15
|
+
* registry and pick the requested port (or the lowest), then fall back to the
|
|
16
|
+
* legacy file. Returns {} when nothing is found. */
|
|
17
|
+
export declare function loadDiscovery(preferredPort?: number): Discovery;
|
|
18
|
+
/** Attach an NDJSON reader to a socket: reassembles newline-delimited JSON
|
|
19
|
+
* lines across TCP chunks (multi-byte-safe via StringDecoder) and calls fn
|
|
20
|
+
* with each parsed message. Unparseable or blank lines are skipped. */
|
|
21
|
+
export declare function onJsonLines(sock: Pick<Socket, 'on'>, fn: (message: Record<string, unknown>) => void): void;
|
package/tools/shared.mjs
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* shared.mjs — the facts both clients (web-agent.mjs, e2e.mjs) must agree on:
|
|
3
|
+
* bridge discovery and NDJSON wire framing. Single source of truth — the two
|
|
4
|
+
* clients used to carry private copies of this logic, and the copies drifted
|
|
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 >= 18 built-ins only.
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
12
|
+
/** The port the host tries first (it scans upward on collision); clients fall
|
|
13
|
+
* back to it when no discovery file exists. Mirrors WebAgentBridge::start(). */
|
|
14
|
+
export const DEFAULT_PORT = 8930;
|
|
15
|
+
/** Locate a running bridge's {port, token}.
|
|
16
|
+
* The host writes them on start so clients never guess: each instance registers
|
|
17
|
+
* <home>/.web_agent_bridge.d/<port>.json (so several hosts — e.g. multiple
|
|
18
|
+
* plugin instances in a DAW — don't clobber each other), plus the single legacy
|
|
19
|
+
* <home>/.web_agent_bridge.json for older single-instance hosts. Enumerate the
|
|
20
|
+
* registry and pick the requested port (or the lowest), then fall back to the
|
|
21
|
+
* legacy file. Returns {} when nothing is found. */
|
|
22
|
+
export function loadDiscovery(preferredPort) {
|
|
23
|
+
const home = os.homedir();
|
|
24
|
+
const dir = path.join(home, '.web_agent_bridge.d');
|
|
25
|
+
const readJson = (p) => { try {
|
|
26
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
} };
|
|
31
|
+
if (preferredPort) {
|
|
32
|
+
const d = readJson(path.join(dir, `${preferredPort}.json`));
|
|
33
|
+
if (d)
|
|
34
|
+
return d;
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
const insts = fs.readdirSync(dir).filter((f) => f.endsWith('.json'))
|
|
38
|
+
.map((f) => readJson(path.join(dir, f)))
|
|
39
|
+
.filter((d) => d !== null && typeof d.port === 'number')
|
|
40
|
+
.sort((a, b) => a.port - b.port);
|
|
41
|
+
if (preferredPort) {
|
|
42
|
+
const m = insts.find((d) => d.port === preferredPort);
|
|
43
|
+
if (m)
|
|
44
|
+
return m;
|
|
45
|
+
}
|
|
46
|
+
if (insts.length)
|
|
47
|
+
return insts[0];
|
|
48
|
+
}
|
|
49
|
+
catch { /* no dir -> fall through to legacy */ }
|
|
50
|
+
return readJson(path.join(home, '.web_agent_bridge.json')) || {};
|
|
51
|
+
}
|
|
52
|
+
/** Attach an NDJSON reader to a socket: reassembles newline-delimited JSON
|
|
53
|
+
* lines across TCP chunks (multi-byte-safe via StringDecoder) and calls fn
|
|
54
|
+
* with each parsed message. Unparseable or blank lines are skipped. */
|
|
55
|
+
export function onJsonLines(sock, fn) {
|
|
56
|
+
let acc = '';
|
|
57
|
+
const dec = new StringDecoder('utf8');
|
|
58
|
+
sock.on('data', (d) => {
|
|
59
|
+
acc += dec.write(d);
|
|
60
|
+
let nl;
|
|
61
|
+
while ((nl = acc.indexOf('\n')) >= 0) {
|
|
62
|
+
const line = acc.slice(0, nl);
|
|
63
|
+
acc = acc.slice(nl + 1);
|
|
64
|
+
if (!line.trim())
|
|
65
|
+
continue;
|
|
66
|
+
let m;
|
|
67
|
+
try {
|
|
68
|
+
m = JSON.parse(line);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
fn(m);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* CLI client for juce_webview_agent_bridge.
|
|
4
|
+
*
|
|
5
|
+
* Talks the newline-delimited JSON protocol to a plugin/app that embeds the
|
|
6
|
+
* web_agent_bridge module, so an external agent gets a browser-like toolkit on
|
|
7
|
+
* the live WebView: eval, console/network stream, DOM, click/fill, screenshot.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* juce-webview-agent-bridge eval "<js>" run JS, print result/error
|
|
11
|
+
* juce-webview-agent-bridge dom [selector] outerHTML of selector (or <html>)
|
|
12
|
+
* juce-webview-agent-bridge click <selector> element.click() (isTrusted=false)
|
|
13
|
+
* juce-webview-agent-bridge fill <selector> <val> React-safe value set + input event
|
|
14
|
+
* juce-webview-agent-bridge capture <on|off> toggle response-body capture
|
|
15
|
+
* juce-webview-agent-bridge backlog dump the page ring buffer
|
|
16
|
+
* juce-webview-agent-bridge logs [--backlog] stream console/network (Ctrl-C to stop); --backlog dumps recent history first
|
|
17
|
+
* juce-webview-agent-bridge shot [out.png] [sel] native screenshot (macOS/Windows); with a selector, crop to that element
|
|
18
|
+
* juce-webview-agent-bridge layerdebug [on|off] WebKit compositing overlays: layer borders + repaint counters (macOS)
|
|
19
|
+
* juce-webview-agent-bridge layertree dump the remote CALayer tree as text (macOS, programmatic layer census)
|
|
20
|
+
* juce-webview-agent-bridge ping liveness check
|
|
21
|
+
* juce-webview-agent-bridge hello capabilities handshake (version, platform, ops, screenshotAvailable)
|
|
22
|
+
*
|
|
23
|
+
* Options: --port <n> (default $WEB_AGENT_PORT or 8930)
|
|
24
|
+
* --host <h> (default 127.0.0.1)
|
|
25
|
+
*/
|
|
26
|
+
import net from 'node:net';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
import { DEFAULT_PORT, loadDiscovery, onJsonLines } from './shared.mjs';
|
|
29
|
+
const argv = process.argv.slice(2);
|
|
30
|
+
const opt = (name, def) => {
|
|
31
|
+
const i = argv.indexOf(name);
|
|
32
|
+
if (i >= 0 && i + 1 < argv.length) {
|
|
33
|
+
const v = argv[i + 1];
|
|
34
|
+
argv.splice(i, 2);
|
|
35
|
+
return v;
|
|
36
|
+
}
|
|
37
|
+
return def;
|
|
38
|
+
};
|
|
39
|
+
const HOST = opt('--host', '127.0.0.1');
|
|
40
|
+
const portArg = opt('--port', process.env.WEB_AGENT_PORT || '');
|
|
41
|
+
const tokenArg = opt('--token', process.env.WEB_AGENT_TOKEN || '');
|
|
42
|
+
const disc = loadDiscovery(portArg ? Number(portArg) : undefined);
|
|
43
|
+
const PORT = Number(portArg || disc.port || DEFAULT_PORT);
|
|
44
|
+
const TOKEN = tokenArg || disc.token || '';
|
|
45
|
+
const [cmd, ...rest] = argv;
|
|
46
|
+
function connect() {
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
const sock = net.connect({ host: HOST, port: PORT }, () => resolve(sock));
|
|
49
|
+
sock.on('error', reject);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
// Send one request line, resolve with the first matching reply (by id).
|
|
53
|
+
function request(obj, { timeoutMs = 15000 } = {}) {
|
|
54
|
+
return new Promise(async (resolve, reject) => {
|
|
55
|
+
let sock;
|
|
56
|
+
try {
|
|
57
|
+
sock = await connect();
|
|
58
|
+
}
|
|
59
|
+
catch (e) {
|
|
60
|
+
return reject(e);
|
|
61
|
+
}
|
|
62
|
+
const id = Math.floor(Math.random() * 1e9);
|
|
63
|
+
const timer = setTimeout(() => { sock.destroy(); reject(new Error('timeout')); }, timeoutMs);
|
|
64
|
+
onJsonLines(sock, (m) => {
|
|
65
|
+
if (m.id === id) {
|
|
66
|
+
clearTimeout(timer);
|
|
67
|
+
sock.end();
|
|
68
|
+
resolve(m);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
sock.on('error', (e) => { clearTimeout(timer); reject(e); });
|
|
72
|
+
sock.write(JSON.stringify({ ...obj, id, ...(TOKEN ? { token: TOKEN } : {}) }) + '\n');
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
async function evalJs(code, timeoutMs = 15000) {
|
|
76
|
+
const r = await request({ op: 'eval', code }, { timeoutMs });
|
|
77
|
+
if (!r.ok)
|
|
78
|
+
throw new Error(r.error || 'eval failed');
|
|
79
|
+
return r.result;
|
|
80
|
+
}
|
|
81
|
+
function fmt(v) {
|
|
82
|
+
if (typeof v === 'string')
|
|
83
|
+
return v;
|
|
84
|
+
try {
|
|
85
|
+
return JSON.stringify(v, null, 2);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return String(v);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// React-19-safe value setter + input/change dispatch. Focus BEFORE mutating the
|
|
92
|
+
// value, like a real edit — some controlled inputs gate their commit on
|
|
93
|
+
// focus->change->blur ordering and silently drop a value set without focus
|
|
94
|
+
// (same rationale as fillCode in e2e.mjs; keep the two in behavioural lockstep).
|
|
95
|
+
const fillSnippet = (sel, val) => `(() => {
|
|
96
|
+
const el = document.querySelector(${JSON.stringify(sel)});
|
|
97
|
+
if (!el) return 'no element: ' + ${JSON.stringify(sel)};
|
|
98
|
+
if (!(el instanceof window.HTMLInputElement || el instanceof window.HTMLTextAreaElement)) return 'not an input/textarea: ' + el.tagName;
|
|
99
|
+
if (typeof el.focus === 'function') el.focus();
|
|
100
|
+
const proto = el instanceof window.HTMLTextAreaElement ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype;
|
|
101
|
+
const setter = Object.getOwnPropertyDescriptor(proto, 'value').set;
|
|
102
|
+
setter.call(el, ${JSON.stringify(val)});
|
|
103
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
104
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
105
|
+
return 'ok';
|
|
106
|
+
})()`;
|
|
107
|
+
async function main() {
|
|
108
|
+
switch (cmd) {
|
|
109
|
+
case 'ping': {
|
|
110
|
+
const r = await request({ op: 'ping' });
|
|
111
|
+
console.log(r.ok ? `pong (127.0.0.1:${PORT})` : 'no pong');
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
case 'layerdebug': {
|
|
115
|
+
const enabled = rest[0] !== 'off';
|
|
116
|
+
const r = await request({ op: 'layerdebug', enabled });
|
|
117
|
+
console.log(r.ok
|
|
118
|
+
? `compositing overlays ${enabled ? 'ON' : 'OFF'} (layer borders + repaint counters)`
|
|
119
|
+
: `failed: ${r.error || 'unavailable'}`);
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
case 'layertree': {
|
|
123
|
+
const r = await request({ op: 'layertree' });
|
|
124
|
+
console.log(r.ok ? r.text : `failed: ${r.error || 'unavailable'}`);
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
case 'hello': {
|
|
128
|
+
const r = await request({ op: 'hello' });
|
|
129
|
+
console.log(fmt({
|
|
130
|
+
protocolVersion: r.protocolVersion, platform: r.platform,
|
|
131
|
+
screenshotAvailable: r.screenshotAvailable, authRequired: r.authRequired, ops: r.ops,
|
|
132
|
+
}));
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
case 'eval': {
|
|
136
|
+
if (!rest[0])
|
|
137
|
+
throw new Error('usage: eval "<js>"');
|
|
138
|
+
console.log(fmt(await evalJs(rest.join(' '))));
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
case 'dom': {
|
|
142
|
+
const sel = rest[0] || 'html';
|
|
143
|
+
const code = `(() => { const el = document.querySelector(${JSON.stringify(sel)}); return el ? el.outerHTML : 'no element: ' + ${JSON.stringify(sel)}; })()`;
|
|
144
|
+
console.log(fmt(await evalJs(code)));
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
case 'click': {
|
|
148
|
+
if (!rest[0])
|
|
149
|
+
throw new Error('usage: click <selector>');
|
|
150
|
+
const sel = rest.join(' ');
|
|
151
|
+
const code = `(() => { const el = document.querySelector(${JSON.stringify(sel)}); if (!el) return 'no element'; el.scrollIntoView(); el.click(); return 'clicked'; })()`;
|
|
152
|
+
console.log(fmt(await evalJs(code)));
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
case 'fill': {
|
|
156
|
+
if (rest.length < 2)
|
|
157
|
+
throw new Error('usage: fill <selector> <value>');
|
|
158
|
+
const sel = rest[0];
|
|
159
|
+
const val = rest.slice(1).join(' ');
|
|
160
|
+
console.log(fmt(await evalJs(fillSnippet(sel, val))));
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
case 'capture': {
|
|
164
|
+
const on = (rest[0] || 'on') === 'on';
|
|
165
|
+
await evalJs(`(window.__webAgentCapture = ${on}, '${on ? 'on' : 'off'}')`);
|
|
166
|
+
console.log(`response-body capture: ${on ? 'on' : 'off'}`);
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
case 'backlog': {
|
|
170
|
+
const r = await evalJs(`JSON.stringify(window.__webAgentBuffer || [])`);
|
|
171
|
+
const arr = typeof r === 'string' ? JSON.parse(r) : r;
|
|
172
|
+
for (const e of arr)
|
|
173
|
+
printSink(e);
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
case 'shot': {
|
|
177
|
+
// Native capture inside the plugin (ScreenCaptureKit/WGC) — includes WebGL,
|
|
178
|
+
// no external CLI. Optional path: where the plugin writes the PNG. Optional
|
|
179
|
+
// selector: crop to that element's rect (a much smaller PNG / fewer tokens).
|
|
180
|
+
// Resolve to an absolute path against the CLIENT's CWD: the plugin runs
|
|
181
|
+
// with a different CWD (its .app bundle), so a relative path would both
|
|
182
|
+
// trip JUCE's File-ctor assertion (juce_File.cpp:219 wants absolute) and
|
|
183
|
+
// write the PNG next to the bundle instead of where the caller expects.
|
|
184
|
+
const out = rest[0] ? path.resolve(rest[0]) : undefined;
|
|
185
|
+
const sel = rest[1];
|
|
186
|
+
let rect;
|
|
187
|
+
if (sel) {
|
|
188
|
+
const box = await evalJs(`(() => { const el = document.querySelector(${JSON.stringify(sel)}); if (!el) return null; const r = el.getBoundingClientRect(); return { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height) }; })()`);
|
|
189
|
+
if (!box)
|
|
190
|
+
throw new Error('no element: ' + sel);
|
|
191
|
+
rect = box;
|
|
192
|
+
}
|
|
193
|
+
const r = await request({ op: 'shot', ...(out ? { path: out } : {}), ...(rect ? { rect } : {}) }, { timeoutMs: 30000 });
|
|
194
|
+
if (!r.ok)
|
|
195
|
+
throw new Error(r.error || 'native screenshot failed');
|
|
196
|
+
console.log(r.path);
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
case 'logs': {
|
|
200
|
+
// A freshly-connected client only sees events from now on; --backlog first
|
|
201
|
+
// dumps the page ring buffer so you also get the recent history in one go.
|
|
202
|
+
if (rest.includes('--backlog') || rest.includes('-b')) {
|
|
203
|
+
try {
|
|
204
|
+
const buf = await evalJs(`JSON.stringify(window.__webAgentBuffer || [])`);
|
|
205
|
+
for (const e of (typeof buf === 'string' ? JSON.parse(buf) : buf))
|
|
206
|
+
printSink(e);
|
|
207
|
+
}
|
|
208
|
+
catch (e) {
|
|
209
|
+
process.stderr.write(`[juce-webview-agent-bridge] backlog unavailable: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const sock = await connect();
|
|
213
|
+
if (TOKEN)
|
|
214
|
+
sock.write(JSON.stringify({ op: 'auth', token: TOKEN }) + '\n'); // authenticate before streaming
|
|
215
|
+
process.stderr.write(`[juce-webview-agent-bridge] streaming from 127.0.0.1:${PORT} (Ctrl-C to stop)\n`);
|
|
216
|
+
onJsonLines(sock, (m) => { if (m.op === 'sink')
|
|
217
|
+
printSink(m.event); });
|
|
218
|
+
sock.on('error', (e) => { console.error(e.message); process.exit(1); });
|
|
219
|
+
sock.on('close', () => process.exit(0));
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
default:
|
|
223
|
+
console.error('unknown command. run with no valid command to see usage in the header.');
|
|
224
|
+
process.exit(2);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
function printSink(e) {
|
|
228
|
+
if (!e)
|
|
229
|
+
return;
|
|
230
|
+
const ts = new Date(e.t || Date.now()).toISOString().slice(11, 23);
|
|
231
|
+
const d = e.data || {};
|
|
232
|
+
if (e.kind === 'console')
|
|
233
|
+
console.log(`${ts} ${(d.level || 'log').toUpperCase().padEnd(5)} ${(d.args || []).join(' ')}`);
|
|
234
|
+
else if (e.kind === 'error')
|
|
235
|
+
console.log(`${ts} ERROR ${d.message || ''}${d.stack ? '\n' + d.stack : ''}`);
|
|
236
|
+
else if (e.kind === 'net') {
|
|
237
|
+
// data.kind: fetch | xhr | ws | sse | beacon | timing. ws/sse carry event(+dir);
|
|
238
|
+
// request/response bodies + headers ride along only while `capture` is armed.
|
|
239
|
+
const tag = (d.kind || 'net').toUpperCase().padEnd(6);
|
|
240
|
+
const ev = d.event ? d.event + (d.dir ? '/' + d.dir : '') : (d.method || '');
|
|
241
|
+
const req = d.reqBody ? '\n req: ' + d.reqBody : '';
|
|
242
|
+
const body = d.body ? '\n body: ' + d.body : '';
|
|
243
|
+
console.log(`${ts} ${tag}${ev} ${d.status ?? d.code ?? ''} ${d.url || d.name || ''} ${d.ms != null ? d.ms + 'ms' : ''}${req}${body}`);
|
|
244
|
+
}
|
|
245
|
+
else
|
|
246
|
+
console.log(`${ts} ${e.kind} ${fmt(d)}`);
|
|
247
|
+
}
|
|
248
|
+
main()
|
|
249
|
+
.then(() => { if (cmd !== 'logs')
|
|
250
|
+
process.exit(0); })
|
|
251
|
+
.catch((e) => { console.error('error:', e instanceof Error ? e.message : String(e)); process.exit(1); });
|