dsh-builtin-browser 0.2.0 → 0.3.1
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/README.en.md +50 -5
- package/README.md +62 -10
- package/client.js +44 -8
- package/desktop-bridge/install.mjs +98 -0
- package/desktop-bridge/plugin-browser-bridge.js +385 -0
- package/lib/browser-electron/bridge-connection.d.ts +58 -0
- package/lib/browser-electron/bridge-connection.js +133 -0
- package/lib/browser-electron/desktop-bridge-host.d.ts +124 -0
- package/lib/browser-electron/desktop-bridge-host.js +297 -0
- package/lib/browser-electron/entry.js +81 -13
- package/lib/browser-electron/history-store.d.ts +5 -1
- package/lib/browser-electron/history-store.js +12 -1
- package/lib/browser-electron/provider.d.ts +42 -1
- package/lib/browser-electron/provider.js +96 -24
- package/lib/browser-electron/remote-host.d.ts +9 -1
- package/lib/browser-electron/remote-host.js +17 -3
- package/lib/browser-electron/settings-store.d.ts +5 -0
- package/lib/browser-electron/settings-store.js +24 -1
- package/lib/browser-electron/system-browser.d.ts +132 -0
- package/lib/browser-electron/system-browser.js +380 -0
- package/lib/browser-electron/virtual-cursor.d.ts +27 -2
- package/lib/browser-electron/virtual-cursor.js +77 -7
- package/lib/tool-browser/index.js +51 -15
- package/package.json +2 -1
- package/src/browser-electron/bridge-connection.ts +145 -0
- package/src/browser-electron/desktop-bridge-host.ts +305 -0
- package/src/browser-electron/entry.ts +84 -13
- package/src/browser-electron/history-store.ts +11 -2
- package/src/browser-electron/provider.ts +124 -25
- package/src/browser-electron/remote-host.ts +17 -2
- package/src/browser-electron/settings-store.ts +30 -1
- package/src/browser-electron/system-browser.ts +371 -0
- package/src/browser-electron/virtual-cursor.ts +76 -6
- package/src/tool-browser/index.ts +51 -15
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin browser bridge (desktop side).
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS
|
|
5
|
+
* The `dsh-builtin-browser` plugin runs inside the Node-mode desktop host, where
|
|
6
|
+
* no Electron API exists — so today it spawns its own Electron window, completely
|
|
7
|
+
* parallel to the desktop shell. To make the sidebar carry the agent's page
|
|
8
|
+
* instead (one page, human and agent on it), somebody with Electron access has to
|
|
9
|
+
* own the view. That somebody is this process.
|
|
10
|
+
*
|
|
11
|
+
* WHAT IT DOES
|
|
12
|
+
* Exposes the desktop's webview guests (the sidebar browser's pages) over a
|
|
13
|
+
* token-authenticated loopback socket, and forwards CDP to them through
|
|
14
|
+
* `webContents.debugger`. It deliberately does NOT create windows or views yet:
|
|
15
|
+
* the first question is whether a sidebar guest can be driven from outside at
|
|
16
|
+
* all, and that is what `list` + `cdp` answer.
|
|
17
|
+
*
|
|
18
|
+
* HOW IT IS STARTED
|
|
19
|
+
* `lib/main.js` imports this module once the app is ready (see the bridge import
|
|
20
|
+
* appended before its `export {}`), and the endpoint is published to
|
|
21
|
+
* `$DSH_HOME/dsh-builtin-browser-bridge.json` so the plugin can find it without
|
|
22
|
+
* any user configuration.
|
|
23
|
+
*/
|
|
24
|
+
import { createServer } from 'node:net'
|
|
25
|
+
import { randomBytes } from 'node:crypto'
|
|
26
|
+
import { writeFileSync, mkdirSync } from 'node:fs'
|
|
27
|
+
import { join } from 'node:path'
|
|
28
|
+
import { app, webContents } from 'electron'
|
|
29
|
+
|
|
30
|
+
/** Absolute path of the endpoint file the plugin reads. */
|
|
31
|
+
function endpointFile() {
|
|
32
|
+
const home = process.env.DSH_HOME ?? ''
|
|
33
|
+
return join(home, 'dsh-builtin-browser-bridge.json')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** One CDP command against a guest, serialized per guest. */
|
|
37
|
+
const queues = new Map()
|
|
38
|
+
|
|
39
|
+
function guestById(id) {
|
|
40
|
+
for (const contents of webContents.getAllWebContents()) {
|
|
41
|
+
if (contents.id === id) return contents
|
|
42
|
+
}
|
|
43
|
+
return undefined
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Describe one candidate guest. */
|
|
47
|
+
function describe(contents) {
|
|
48
|
+
let url = ''
|
|
49
|
+
let title = ''
|
|
50
|
+
try { url = contents.getURL() } catch { /* destroyed */ }
|
|
51
|
+
try { title = contents.getTitle() } catch { /* destroyed */ }
|
|
52
|
+
return {
|
|
53
|
+
id: contents.id,
|
|
54
|
+
type: contents.getType(),
|
|
55
|
+
url,
|
|
56
|
+
title,
|
|
57
|
+
destroyed: contents.isDestroyed(),
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Send one CDP command to a guest, attaching its debugger on first use.
|
|
63
|
+
* Attaching is per-webContents and idempotent; a destroyed guest drops the
|
|
64
|
+
* attachment so a later guest with a recycled id is not mistaken for this one.
|
|
65
|
+
*/
|
|
66
|
+
async function sendCdp(id, method, params) {
|
|
67
|
+
const guest = guestById(id)
|
|
68
|
+
if (guest === undefined || guest.isDestroyed()) throw new Error(`guest ${id} is not available`)
|
|
69
|
+
const previous = queues.get(id) ?? Promise.resolve()
|
|
70
|
+
const next = previous.then(async () => {
|
|
71
|
+
const current = guestById(id)
|
|
72
|
+
if (current === undefined || current.isDestroyed()) throw new Error(`guest ${id} is not available`)
|
|
73
|
+
if (!current.debugger.isAttached()) current.debugger.attach('1.3')
|
|
74
|
+
return await current.debugger.sendCommand(method, params ?? {})
|
|
75
|
+
})
|
|
76
|
+
// Keep the chain alive on failure so one bad command does not wedge the guest.
|
|
77
|
+
queues.set(id, next.catch(() => undefined))
|
|
78
|
+
return await next
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Handle one JSON request line; every answer is a JSON line too. */
|
|
82
|
+
async function handle(request) {
|
|
83
|
+
const op = String(request?.op ?? '')
|
|
84
|
+
if (op === 'list') {
|
|
85
|
+
const all = webContents.getAllWebContents().map(describe)
|
|
86
|
+
return { ok: true, guests: all, sidebar: all.filter(g => g.type === 'webview') }
|
|
87
|
+
}
|
|
88
|
+
if (op === 'cdp') {
|
|
89
|
+
const id = Number(request.id)
|
|
90
|
+
if (!Number.isFinite(id)) throw new Error('cdp requires a numeric id')
|
|
91
|
+
const result = await sendCdp(id, String(request.method ?? ''), request.params)
|
|
92
|
+
return { ok: true, result }
|
|
93
|
+
}
|
|
94
|
+
if (op === 'ensureSidebar') {
|
|
95
|
+
const url = request.url === undefined ? '' : String(request.url)
|
|
96
|
+
// Materialize a sidebar guest, optionally navigating it.
|
|
97
|
+
//
|
|
98
|
+
// The sidebar browser creates its guest lazily: an un-navigated sidebar is
|
|
99
|
+
// only an address bar and owns no webContents at all, so "there is no guest"
|
|
100
|
+
// is the normal state rather than a failure. When one already exists this is
|
|
101
|
+
// a no-op; otherwise the shell's own UI is driven to open the right sidebar
|
|
102
|
+
// and submit an address — the same thing a human would do, and the only
|
|
103
|
+
// supported way to make the sidebar own a page.
|
|
104
|
+
const existing = webContents.getAllWebContents().filter(contents => contents.getType() === 'webview')
|
|
105
|
+
if (existing.length > 0) {
|
|
106
|
+
const first = existing[0]
|
|
107
|
+
if (request.url !== undefined && String(request.url) !== '') {
|
|
108
|
+
await sendCdp(first.id, 'Page.navigate', { url: String(request.url) })
|
|
109
|
+
}
|
|
110
|
+
return { ok: true, created: false, id: first.id }
|
|
111
|
+
}
|
|
112
|
+
const shell = webContents.getAllWebContents().find(contents => contents.getType() === 'window')
|
|
113
|
+
if (shell === undefined) throw new Error('no shell window to drive')
|
|
114
|
+
// 1. Open the sidebar and put the caret in its address field IF that field is
|
|
115
|
+
// reachable. It is not always: the sidebar may show another panel, or the
|
|
116
|
+
// browser tab may be present but not active, in which case no address input
|
|
117
|
+
// exists in the document at all. That must not be fatal — the restore route
|
|
118
|
+
// below needs no address bar, and treating "no input" as a hard error is
|
|
119
|
+
// exactly what made the first call after a restart fail.
|
|
120
|
+
const prepared = await sendCdp(shell.id, 'Runtime.evaluate', {
|
|
121
|
+
expression: shellPrepareSidebar(),
|
|
122
|
+
returnByValue: true,
|
|
123
|
+
})
|
|
124
|
+
const verdict = String(prepared?.result?.value ?? '')
|
|
125
|
+
// 2. Materialize a guest. Two routes, cheapest first:
|
|
126
|
+
// a. the sidebar's own "restore last page" affordance — it navigates
|
|
127
|
+
// without depending on how the address field handles focus or input;
|
|
128
|
+
// b. otherwise type into the address field and submit with REAL key input
|
|
129
|
+
// (synthetic events do not move React's controlled input, and the field
|
|
130
|
+
// also loses focus to re-renders, so both halves are needed).
|
|
131
|
+
const restored = await sendCdp(shell.id, 'Runtime.evaluate', {
|
|
132
|
+
expression: `(() => {
|
|
133
|
+
const nodes = Array.from(document.querySelectorAll('button,a,[role=button],div[role=link]'));
|
|
134
|
+
const hit = nodes.find(n => /恢复页面|恢复上次|上次打开/.test((n.textContent || '').trim()));
|
|
135
|
+
if (hit === undefined) return 'NO_RESTORE';
|
|
136
|
+
hit.click();
|
|
137
|
+
return 'RESTORED';
|
|
138
|
+
})()`,
|
|
139
|
+
returnByValue: true,
|
|
140
|
+
})
|
|
141
|
+
if (restored?.result?.value !== 'RESTORED') {
|
|
142
|
+
if (url !== '') {
|
|
143
|
+
// Set the value the way React accepts it, then submit with real input.
|
|
144
|
+
await sendCdp(shell.id, 'Runtime.evaluate', {
|
|
145
|
+
expression: `(() => {
|
|
146
|
+
const inputs = Array.from(document.querySelectorAll('input')).filter(i => /HTTP|地址|url/i.test((i.placeholder || '') + (i.getAttribute('aria-label') || '')));
|
|
147
|
+
if (inputs.length === 0) return 'NO_ADDRESS_BAR';
|
|
148
|
+
const input = inputs[inputs.length - 1];
|
|
149
|
+
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
|
|
150
|
+
setter.call(input, ${JSON.stringify(String(request.url ?? ''))});
|
|
151
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
152
|
+
input.focus();
|
|
153
|
+
return 'TYPED';
|
|
154
|
+
})()`,
|
|
155
|
+
returnByValue: true,
|
|
156
|
+
})
|
|
157
|
+
for (const type of ['keyDown', 'keyUp']) {
|
|
158
|
+
await sendCdp(shell.id, 'Input.dispatchKeyEvent', {
|
|
159
|
+
type,
|
|
160
|
+
key: 'Enter',
|
|
161
|
+
code: 'Enter',
|
|
162
|
+
windowsVirtualKeyCode: 13,
|
|
163
|
+
nativeVirtualKeyCode: 13,
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// The guest attaches asynchronously once the renderer creates the webview.
|
|
169
|
+
for (let attempt = 0; attempt < 24; attempt++) {
|
|
170
|
+
await new Promise(resolve => setTimeout(resolve, 500))
|
|
171
|
+
const created = webContents.getAllWebContents().filter(contents => contents.getType() === 'webview')
|
|
172
|
+
if (created.length > 0) {
|
|
173
|
+
// Now that a guest exists, navigation is a plain CDP call: no UI involved.
|
|
174
|
+
if (url !== '') {
|
|
175
|
+
await sendCdp(created[0].id, 'Page.navigate', { url }).catch(() => undefined)
|
|
176
|
+
}
|
|
177
|
+
return { ok: true, created: true, id: created[0].id, via: restored?.result?.value === 'RESTORED' ? 'restore' : 'address' }
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
throw new Error(`the sidebar did not create a browser guest (prepare=${String(verdict)}, restore=${String(restored?.result?.value)})`)
|
|
181
|
+
}
|
|
182
|
+
if (op === 'ensureTabs') {
|
|
183
|
+
// Grow the sidebar's browser tab strip to `count` tabs and report every guest.
|
|
184
|
+
//
|
|
185
|
+
// Tabs are the renderer's, so this cannot create them directly — it drives the
|
|
186
|
+
// sidebar's own "new tab" control, the same affordance a human uses, and then
|
|
187
|
+
// reports the guest ids. Whatever already exists is reused, so repeated calls
|
|
188
|
+
// are cheap and a human-opened tab is never orphaned.
|
|
189
|
+
const want = Math.max(1, Math.min(8, Number(request.count ?? 1)))
|
|
190
|
+
const shell = webContents.getAllWebContents().find(contents => contents.getType() === 'window')
|
|
191
|
+
if (shell === undefined) throw new Error('no shell window to drive')
|
|
192
|
+
let lastVerdict = ''
|
|
193
|
+
for (let attempt = 0; attempt < 30; attempt++) {
|
|
194
|
+
const guests = webContents.getAllWebContents().filter(contents => contents.getType() === 'webview')
|
|
195
|
+
if (guests.length >= want) return { ok: true, ids: guests.map(contents => contents.id), verdict: lastVerdict }
|
|
196
|
+
const clicked = await sendCdp(shell.id, 'Runtime.evaluate', {
|
|
197
|
+
expression: `(() => {
|
|
198
|
+
const nodes = Array.from(document.querySelectorAll('button,[role=button]'));
|
|
199
|
+
const hit = nodes.find(b => /新标签页|新建标签|new tab/i.test((b.getAttribute('aria-label') || '') + (b.textContent || '') + (b.getAttribute('title') || '')));
|
|
200
|
+
if (hit === undefined) return 'NO_NEW_TAB_CONTROL';
|
|
201
|
+
hit.click();
|
|
202
|
+
return 'CLICKED';
|
|
203
|
+
})()`,
|
|
204
|
+
returnByValue: true,
|
|
205
|
+
})
|
|
206
|
+
lastVerdict = String(clicked?.result?.value ?? '')
|
|
207
|
+
if (lastVerdict === 'NO_NEW_TAB_CONTROL') {
|
|
208
|
+
throw new Error('the sidebar exposes no new-tab control (is the browser tab open?)')
|
|
209
|
+
}
|
|
210
|
+
await new Promise(resolve => setTimeout(resolve, 600))
|
|
211
|
+
}
|
|
212
|
+
throw new Error(`the sidebar stopped at fewer than ${want} tabs`)
|
|
213
|
+
}
|
|
214
|
+
if (op === 'closeSidebarBrowser') {
|
|
215
|
+
// Release the sidebar's browser pages by closing the tabs that host them.
|
|
216
|
+
//
|
|
217
|
+
// This is the desktop equivalent of "close the browser window": the guests are
|
|
218
|
+
// destroyed, so that agent's session ends and its next use starts a fresh page —
|
|
219
|
+
// while cookies and history live in the partition and on disk, and therefore
|
|
220
|
+
// survive (requirements §3).
|
|
221
|
+
//
|
|
222
|
+
// `titles` narrows the release to specific pages. It matters as soon as more
|
|
223
|
+
// than one session is running: each one owns its own tab (requirements §4), and
|
|
224
|
+
// ending one session must not tear down another's page. With no titles given the
|
|
225
|
+
// call means "release everything", which is only appropriate when the caller
|
|
226
|
+
// knows it owns them all.
|
|
227
|
+
const wanted = Array.isArray(request.titles) ? request.titles.map(title => String(title).slice(0, 24)).filter(title => title !== '') : undefined
|
|
228
|
+
const guests = webContents.getAllWebContents().filter(contents => contents.getType() === 'webview')
|
|
229
|
+
const titles = []
|
|
230
|
+
for (const guest of guests) {
|
|
231
|
+
try {
|
|
232
|
+
if (guest.isDestroyed()) continue
|
|
233
|
+
const title = guest.getTitle()
|
|
234
|
+
// With a filter, only the guest we intend to close contributes its title —
|
|
235
|
+
// otherwise a tab belonging to another session could match by accident.
|
|
236
|
+
if (wanted !== undefined && !wanted.some(w => title.startsWith(w))) continue
|
|
237
|
+
titles.push(title)
|
|
238
|
+
} catch { /* gone mid-read */ }
|
|
239
|
+
}
|
|
240
|
+
if (guests.length === 0) return { ok: true, closed: 0 }
|
|
241
|
+
const shell = webContents.getAllWebContents().find(contents => contents.getType() === 'window')
|
|
242
|
+
if (shell === undefined) throw new Error('no shell window to drive')
|
|
243
|
+
if (titles.length === 0 && wanted !== undefined) return { ok: true, closed: 0 }
|
|
244
|
+
const clicked = await sendCdp(shell.id, 'Runtime.evaluate', {
|
|
245
|
+
expression: `(() => {
|
|
246
|
+
const wanted = ${JSON.stringify(titles.map(title => title.slice(0, 24)))};
|
|
247
|
+
let closed = 0;
|
|
248
|
+
for (const tab of Array.from(document.querySelectorAll('[role=tab]'))) {
|
|
249
|
+
const label = (tab.textContent || '').trim();
|
|
250
|
+
if (!wanted.some(w => label.startsWith(w))) continue;
|
|
251
|
+
const own = Array.from(tab.querySelectorAll('button')).find(b => /关闭|close/i.test((b.getAttribute('aria-label') || '') + (b.getAttribute('title') || '')));
|
|
252
|
+
const sibling = Array.from(tab.parentElement ? tab.parentElement.querySelectorAll('button') : []).find(b => /关闭|close/i.test((b.getAttribute('aria-label') || '') + (b.getAttribute('title') || '')));
|
|
253
|
+
const button = own ?? sibling;
|
|
254
|
+
if (button !== undefined) { button.click(); closed += 1; }
|
|
255
|
+
}
|
|
256
|
+
return closed;
|
|
257
|
+
})()`,
|
|
258
|
+
returnByValue: true,
|
|
259
|
+
})
|
|
260
|
+
return { ok: true, closed: Number(clicked?.result?.value ?? 0) }
|
|
261
|
+
}
|
|
262
|
+
if (op === 'collapseSidebar') {
|
|
263
|
+
// Fold the sidebar away so the agent works without occupying the screen. The
|
|
264
|
+
// guest keeps running — collapsing is presentation, exactly as §3 asks.
|
|
265
|
+
const shell = webContents.getAllWebContents().find(contents => contents.getType() === 'window')
|
|
266
|
+
if (shell === undefined) throw new Error('no shell window to drive')
|
|
267
|
+
const folded = await sendCdp(shell.id, 'Runtime.evaluate', {
|
|
268
|
+
expression: `(() => {
|
|
269
|
+
const button = Array.from(document.querySelectorAll('button')).find(b => /收起右侧边栏/.test((b.getAttribute('aria-label') || '') + (b.textContent || '')));
|
|
270
|
+
if (button === undefined) return 'NOT_OPEN';
|
|
271
|
+
button.click();
|
|
272
|
+
return 'COLLAPSED';
|
|
273
|
+
})()`,
|
|
274
|
+
returnByValue: true,
|
|
275
|
+
})
|
|
276
|
+
return { ok: true, result: folded?.result?.value }
|
|
277
|
+
}
|
|
278
|
+
throw new Error(`unknown op ${JSON.stringify(op)}`)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* In-page script that opens the right sidebar and focuses its address field.
|
|
283
|
+
*
|
|
284
|
+
* Only preparation happens here; the address itself is typed with real input
|
|
285
|
+
* (see `ensureSidebar`), because a controlled React input ignores text assigned
|
|
286
|
+
* through synthetic events.
|
|
287
|
+
* @returns a self-contained expression evaluating to a short verdict string.
|
|
288
|
+
*/
|
|
289
|
+
function shellPrepareSidebar() {
|
|
290
|
+
return `(() => {
|
|
291
|
+
const labelOf = el => ((el.getAttribute && el.getAttribute('aria-label')) || el.textContent || '').trim();
|
|
292
|
+
let opened = false;
|
|
293
|
+
for (const button of Array.from(document.querySelectorAll('button'))) {
|
|
294
|
+
if (/打开右侧边栏|右侧边栏/.test(labelOf(button))) { button.click(); opened = true; break; }
|
|
295
|
+
}
|
|
296
|
+
const inputs = Array.from(document.querySelectorAll('input')).filter(i => /HTTP|地址|url/i.test((i.placeholder || '') + (i.getAttribute('aria-label') || '')));
|
|
297
|
+
if (inputs.length === 0) return 'NO_ADDRESS_BAR:' + JSON.stringify(Array.from(document.querySelectorAll('input')).map(i => i.placeholder).slice(0, 6));
|
|
298
|
+
inputs[inputs.length - 1].focus();
|
|
299
|
+
return opened ? 'OPENED_AND_FOCUSED' : 'FOCUSED';
|
|
300
|
+
})()`
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Publish the endpoint, and keep it published.
|
|
305
|
+
*
|
|
306
|
+
* A single write at startup is not enough: two shells can overlap during a
|
|
307
|
+
* restart (the outgoing one may write LAST, leaving the file pointing at a dead
|
|
308
|
+
* pid), and a reader that trusts it then fails with ECONNREFUSED for reasons
|
|
309
|
+
* that have nothing to do with the plugin. So the file is rewritten periodically
|
|
310
|
+
* by the process that actually owns the listener, and readers are told the pid
|
|
311
|
+
* so they can verify liveness themselves.
|
|
312
|
+
*/
|
|
313
|
+
function publishEndpoint(port, token) {
|
|
314
|
+
const payload = {
|
|
315
|
+
port,
|
|
316
|
+
token,
|
|
317
|
+
pid: process.pid,
|
|
318
|
+
startedAt: new Date().toISOString(),
|
|
319
|
+
updatedAt: new Date().toISOString(),
|
|
320
|
+
}
|
|
321
|
+
try {
|
|
322
|
+
const file = endpointFile()
|
|
323
|
+
mkdirSync(join(file, '..'), { recursive: true })
|
|
324
|
+
writeFileSync(file, JSON.stringify(payload, null, 2) + '\n')
|
|
325
|
+
return true
|
|
326
|
+
} catch (error) {
|
|
327
|
+
console.error('[dsh-browser-bridge] could not publish the endpoint file:', error)
|
|
328
|
+
return false
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Start the bridge: loopback listener + published endpoint file + token. */
|
|
333
|
+
export function start() {
|
|
334
|
+
const token = randomBytes(24).toString('hex')
|
|
335
|
+
const server = createServer(socket => {
|
|
336
|
+
let buffer = ''
|
|
337
|
+
let authed = false
|
|
338
|
+
socket.on('data', chunk => {
|
|
339
|
+
buffer += chunk.toString('utf8')
|
|
340
|
+
let newline = buffer.indexOf('\n')
|
|
341
|
+
while (newline !== -1) {
|
|
342
|
+
const line = buffer.slice(0, newline).trim()
|
|
343
|
+
buffer = buffer.slice(newline + 1)
|
|
344
|
+
newline = buffer.indexOf('\n')
|
|
345
|
+
if (line === '') continue
|
|
346
|
+
let request
|
|
347
|
+
try { request = JSON.parse(line) } catch { socket.write(JSON.stringify({ ok: false, error: 'bad json' }) + '\n'); continue }
|
|
348
|
+
if (!authed) {
|
|
349
|
+
if (request.token !== token) { socket.write(JSON.stringify({ ok: false, error: 'bad token' }) + '\n'); socket.destroy(); return }
|
|
350
|
+
authed = true
|
|
351
|
+
// The authenticating message is NOT a command: a client that sends the
|
|
352
|
+
// token on its own line would otherwise be answered `unknown op ""`.
|
|
353
|
+
// (A client may also put the token on its first real request; both work.)
|
|
354
|
+
if (typeof request.op !== 'string' || request.op === '') continue
|
|
355
|
+
}
|
|
356
|
+
void handle(request)
|
|
357
|
+
.then(answer => socket.write(JSON.stringify(answer) + '\n'))
|
|
358
|
+
.catch(error => socket.write(JSON.stringify({ ok: false, error: String(error?.message ?? error) }) + '\n'))
|
|
359
|
+
}
|
|
360
|
+
})
|
|
361
|
+
socket.on('error', () => { /* client went away */ })
|
|
362
|
+
})
|
|
363
|
+
server.on('error', error => { console.error('[dsh-browser-bridge] listen failed:', error) })
|
|
364
|
+
// Loopback only, port chosen by the OS: the endpoint file is the discovery
|
|
365
|
+
// mechanism, so a fixed port would only add collisions.
|
|
366
|
+
server.listen(0, '127.0.0.1', () => {
|
|
367
|
+
const address = server.address()
|
|
368
|
+
const port = typeof address === 'object' && address !== null ? address.port : 0
|
|
369
|
+
if (publishEndpoint(port, token)) {
|
|
370
|
+
console.log(`[dsh-browser-bridge] listening on 127.0.0.1:${port} (pid ${process.pid})`)
|
|
371
|
+
}
|
|
372
|
+
})
|
|
373
|
+
// Re-publish: a reader must never be handed an endpoint owned by a dead shell.
|
|
374
|
+
const keepalive = setInterval(() => {
|
|
375
|
+
const address = server.address()
|
|
376
|
+
const port = typeof address === 'object' && address !== null ? address.port : 0
|
|
377
|
+
if (port !== 0) publishEndpoint(port, token)
|
|
378
|
+
}, 15_000)
|
|
379
|
+
keepalive.unref?.()
|
|
380
|
+
app.on('will-quit', () => {
|
|
381
|
+
clearInterval(keepalive)
|
|
382
|
+
try { server.close() } catch { /* already closed */ }
|
|
383
|
+
})
|
|
384
|
+
return server
|
|
385
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport for the desktop shell's browser bridge: one long-lived connection to a
|
|
3
|
+
* loopback TCP service.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS IS ITS OWN MODULE
|
|
6
|
+
* The bridge used to be reached with a fresh connection per request, which measured
|
|
7
|
+
* at ~25ms per call (TCP handshake + token exchange) against ~0.2ms when the socket
|
|
8
|
+
* is reused. A single browser action issues several CDP commands, so that cost was
|
|
9
|
+
* multiplied on every tool call — the difference between a snappy agent and a
|
|
10
|
+
* visibly sluggish one. Reuse is therefore a property of the transport, and keeping
|
|
11
|
+
* it here means the view host above does not have to think about sockets at all.
|
|
12
|
+
*
|
|
13
|
+
* PROTOCOL
|
|
14
|
+
* The first line on a connection carries the token; the service marks the socket
|
|
15
|
+
* authenticated and ignores that line as a command. Requests and answers are then
|
|
16
|
+
* newline-delimited JSON, one answer per request, in order.
|
|
17
|
+
*
|
|
18
|
+
* @module dsh-browser/browser-electron/bridge-connection
|
|
19
|
+
*/
|
|
20
|
+
/** Where the shell publishes its bridge endpoint. */
|
|
21
|
+
export interface BridgeEndpoint {
|
|
22
|
+
readonly port: number;
|
|
23
|
+
readonly token: string;
|
|
24
|
+
readonly pid: number;
|
|
25
|
+
readonly updatedAt?: string;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* A reusable connection to the bridge.
|
|
29
|
+
*
|
|
30
|
+
* Requests are serialised by construction: one socket, one in-flight request at a
|
|
31
|
+
* time, answered in order. A broken socket is discarded so the next call dials
|
|
32
|
+
* again — a dead connection must never become a dead plugin.
|
|
33
|
+
*/
|
|
34
|
+
export declare class BridgeConnection {
|
|
35
|
+
private readonly endpoint;
|
|
36
|
+
private socket;
|
|
37
|
+
private buffer;
|
|
38
|
+
private readonly queue;
|
|
39
|
+
/**
|
|
40
|
+
* @param endpoint - the shell's published bridge endpoint.
|
|
41
|
+
*/
|
|
42
|
+
constructor(endpoint: BridgeEndpoint);
|
|
43
|
+
/**
|
|
44
|
+
* Send one request, reusing the socket if it is still healthy.
|
|
45
|
+
* @param request - the request body (the token is added here).
|
|
46
|
+
* @param timeoutMs - how long to wait for the answer before dropping the socket.
|
|
47
|
+
* @returns the bridge's answer.
|
|
48
|
+
*/
|
|
49
|
+
call(request: Record<string, unknown>, timeoutMs?: number): Promise<Record<string, unknown>>;
|
|
50
|
+
/** Close the socket and fail anything still waiting on it. */
|
|
51
|
+
close(): void;
|
|
52
|
+
/** The live socket, connecting and authenticating it on first use. */
|
|
53
|
+
private ensureSocket;
|
|
54
|
+
/** Resolve queued requests in order as answers arrive. */
|
|
55
|
+
private onData;
|
|
56
|
+
/** Tear the socket down, failing pending work. */
|
|
57
|
+
private reset;
|
|
58
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport for the desktop shell's browser bridge: one long-lived connection to a
|
|
3
|
+
* loopback TCP service.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS IS ITS OWN MODULE
|
|
6
|
+
* The bridge used to be reached with a fresh connection per request, which measured
|
|
7
|
+
* at ~25ms per call (TCP handshake + token exchange) against ~0.2ms when the socket
|
|
8
|
+
* is reused. A single browser action issues several CDP commands, so that cost was
|
|
9
|
+
* multiplied on every tool call — the difference between a snappy agent and a
|
|
10
|
+
* visibly sluggish one. Reuse is therefore a property of the transport, and keeping
|
|
11
|
+
* it here means the view host above does not have to think about sockets at all.
|
|
12
|
+
*
|
|
13
|
+
* PROTOCOL
|
|
14
|
+
* The first line on a connection carries the token; the service marks the socket
|
|
15
|
+
* authenticated and ignores that line as a command. Requests and answers are then
|
|
16
|
+
* newline-delimited JSON, one answer per request, in order.
|
|
17
|
+
*
|
|
18
|
+
* @module dsh-browser/browser-electron/bridge-connection
|
|
19
|
+
*/
|
|
20
|
+
import { connect } from 'node:net';
|
|
21
|
+
/**
|
|
22
|
+
* A reusable connection to the bridge.
|
|
23
|
+
*
|
|
24
|
+
* Requests are serialised by construction: one socket, one in-flight request at a
|
|
25
|
+
* time, answered in order. A broken socket is discarded so the next call dials
|
|
26
|
+
* again — a dead connection must never become a dead plugin.
|
|
27
|
+
*/
|
|
28
|
+
export class BridgeConnection {
|
|
29
|
+
endpoint;
|
|
30
|
+
socket;
|
|
31
|
+
buffer = '';
|
|
32
|
+
queue = [];
|
|
33
|
+
/**
|
|
34
|
+
* @param endpoint - the shell's published bridge endpoint.
|
|
35
|
+
*/
|
|
36
|
+
constructor(endpoint) {
|
|
37
|
+
this.endpoint = endpoint;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Send one request, reusing the socket if it is still healthy.
|
|
41
|
+
* @param request - the request body (the token is added here).
|
|
42
|
+
* @param timeoutMs - how long to wait for the answer before dropping the socket.
|
|
43
|
+
* @returns the bridge's answer.
|
|
44
|
+
*/
|
|
45
|
+
async call(request, timeoutMs = 20_000) {
|
|
46
|
+
const socket = this.ensureSocket();
|
|
47
|
+
return await new Promise((resolve, reject) => {
|
|
48
|
+
const timer = setTimeout(() => {
|
|
49
|
+
const index = this.queue.findIndex(entry => entry.resolve === resolve);
|
|
50
|
+
if (index !== -1)
|
|
51
|
+
this.queue.splice(index, 1);
|
|
52
|
+
const error = new Error(`dsh-builtin-browser: bridge timed out after ${timeoutMs}ms`);
|
|
53
|
+
this.reset(error);
|
|
54
|
+
reject(error);
|
|
55
|
+
}, timeoutMs);
|
|
56
|
+
this.queue.push({
|
|
57
|
+
resolve: answer => { clearTimeout(timer); resolve(answer); },
|
|
58
|
+
reject: error => { clearTimeout(timer); reject(error); },
|
|
59
|
+
});
|
|
60
|
+
try {
|
|
61
|
+
socket.write(JSON.stringify({ token: this.endpoint.token, ...request }) + '\n');
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
clearTimeout(timer);
|
|
65
|
+
this.queue.pop();
|
|
66
|
+
const failure = error instanceof Error ? error : new Error(String(error));
|
|
67
|
+
this.reset(failure);
|
|
68
|
+
reject(failure);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
/** Close the socket and fail anything still waiting on it. */
|
|
73
|
+
close() {
|
|
74
|
+
this.reset(new Error('dsh-builtin-browser: bridge closed'));
|
|
75
|
+
}
|
|
76
|
+
/** The live socket, connecting and authenticating it on first use. */
|
|
77
|
+
ensureSocket() {
|
|
78
|
+
if (this.socket !== undefined)
|
|
79
|
+
return this.socket;
|
|
80
|
+
const socket = connect({ host: '127.0.0.1', port: this.endpoint.port });
|
|
81
|
+
socket.setEncoding('utf8');
|
|
82
|
+
socket.on('data', chunk => this.onData(String(chunk)));
|
|
83
|
+
socket.on('error', error => this.reset(error));
|
|
84
|
+
socket.on('close', () => this.reset(new Error('dsh-builtin-browser: bridge connection closed')));
|
|
85
|
+
this.buffer = '';
|
|
86
|
+
this.socket = socket;
|
|
87
|
+
socket.write(JSON.stringify({ token: this.endpoint.token }) + '\n');
|
|
88
|
+
return socket;
|
|
89
|
+
}
|
|
90
|
+
/** Resolve queued requests in order as answers arrive. */
|
|
91
|
+
onData(chunk) {
|
|
92
|
+
this.buffer += chunk;
|
|
93
|
+
let newline = this.buffer.indexOf('\n');
|
|
94
|
+
while (newline !== -1) {
|
|
95
|
+
const line = this.buffer.slice(0, newline).trim();
|
|
96
|
+
this.buffer = this.buffer.slice(newline + 1);
|
|
97
|
+
newline = this.buffer.indexOf('\n');
|
|
98
|
+
if (line === '')
|
|
99
|
+
continue;
|
|
100
|
+
const entry = this.queue.shift();
|
|
101
|
+
if (entry === undefined)
|
|
102
|
+
continue;
|
|
103
|
+
try {
|
|
104
|
+
const answer = JSON.parse(line);
|
|
105
|
+
// A rejected token means the shell restarted with a new one, so this socket
|
|
106
|
+
// can never succeed again: answer (the caller needs the error) and drop it,
|
|
107
|
+
// so the next call dials afresh instead of retrying a doomed credential.
|
|
108
|
+
if (answer.ok === false && answer.error === 'bad token') {
|
|
109
|
+
entry.resolve(answer);
|
|
110
|
+
this.reset(new Error('dsh-builtin-browser: the bridge rejected our token'));
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
entry.resolve(answer);
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
entry.reject(new Error(`dsh-builtin-browser: malformed bridge answer (${String(error)})`));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** Tear the socket down, failing pending work. */
|
|
121
|
+
reset(error) {
|
|
122
|
+
if (this.socket !== undefined) {
|
|
123
|
+
const socket = this.socket;
|
|
124
|
+
this.socket = undefined;
|
|
125
|
+
socket.removeAllListeners();
|
|
126
|
+
socket.destroy();
|
|
127
|
+
}
|
|
128
|
+
this.buffer = '';
|
|
129
|
+
const pending = this.queue.splice(0, this.queue.length);
|
|
130
|
+
for (const entry of pending)
|
|
131
|
+
entry.reject(error);
|
|
132
|
+
}
|
|
133
|
+
}
|