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.
@@ -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;
@@ -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,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -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); });