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
|
@@ -122,9 +122,20 @@ function parseFillValue(v) {
|
|
|
122
122
|
return Number(v);
|
|
123
123
|
return v ?? '';
|
|
124
124
|
}
|
|
125
|
-
/**
|
|
126
|
-
|
|
127
|
-
|
|
125
|
+
/**
|
|
126
|
+
* Format a snapshot element list for the model.
|
|
127
|
+
*
|
|
128
|
+
* Coordinates are opt-in. They are only meaningful to a caller that intends to
|
|
129
|
+
* click a pixel position, which means a vision pass; for the far more common case
|
|
130
|
+
* of locating an element by role/label and passing a semantic target, they are the
|
|
131
|
+
* bulkiest and least useful part of every line. Omitting them keeps the listing
|
|
132
|
+
* readable — which is exactly what a model without image input has to work from.
|
|
133
|
+
* @param snapshot - the snapshot payload.
|
|
134
|
+
* @param options - `coords: true` to include each element's viewport position.
|
|
135
|
+
*/
|
|
136
|
+
function formatSnapshot(snapshot, options = {}) {
|
|
137
|
+
const showCoords = options.coords === true;
|
|
138
|
+
const lines = snapshot.elements.map(el => `[${el.ref}] ${el.kind}: ${el.label}${el.frame === true ? ' (iframe)' : ''}${showCoords ? ` (${el.x},${el.y})` : ''}`);
|
|
128
139
|
const header = `URL: ${snapshot.url}${snapshot.title !== undefined ? `\nTitle: ${snapshot.title}` : ''}`;
|
|
129
140
|
const body = lines.length > 0 ? lines.join('\n') : '(no interactive elements found)';
|
|
130
141
|
const tail = snapshot.truncated === true ? '\n(snapshot truncated)' : '';
|
|
@@ -193,7 +204,10 @@ export function apply(ctx, config = {}) {
|
|
|
193
204
|
},
|
|
194
205
|
},
|
|
195
206
|
},
|
|
196
|
-
|
|
207
|
+
// browser_open is an overview of what just loaded, not a locator: it has no
|
|
208
|
+
// `coords` parameter, so coordinates stay off here (browser_snapshot owns
|
|
209
|
+
// that switch for callers that actually target pixels).
|
|
210
|
+
render: (args, value) => [{ type: 'text', text: formatSnapshot(value, { coords: args.coords === true }) }],
|
|
197
211
|
},
|
|
198
212
|
timeoutMs,
|
|
199
213
|
isConcurrencySafe: () => false, // opens tabs / navigates; exclusive within a task
|
|
@@ -219,8 +233,10 @@ export function apply(ctx, config = {}) {
|
|
|
219
233
|
}));
|
|
220
234
|
ctx.tools.register(defineTool({
|
|
221
235
|
name: 'browser_snapshot',
|
|
222
|
-
description: 'Return an AI-friendly snapshot of the current shared-browser page: numbered interactive elements (inputs, buttons, links) the model can cite.
|
|
223
|
-
parameters: {
|
|
236
|
+
description: 'Return an AI-friendly snapshot of the current shared-browser page: numbered interactive elements (inputs, buttons, links) the model can cite. This is the primary way to understand a page, and it needs no image input — every listed element can afterwards be addressed by a semantic target (browser_click, browser_type, browser_check, …) rather than by coordinates.',
|
|
237
|
+
parameters: {
|
|
238
|
+
coords: { type: 'boolean', description: 'Also print each element\'s viewport coordinates. Off by default; only useful when a vision pass is going to click a pixel position.' },
|
|
239
|
+
},
|
|
224
240
|
output: {
|
|
225
241
|
schema: {
|
|
226
242
|
type: 'object',
|
|
@@ -256,7 +272,7 @@ export function apply(ctx, config = {}) {
|
|
|
256
272
|
},
|
|
257
273
|
},
|
|
258
274
|
},
|
|
259
|
-
render: (
|
|
275
|
+
render: (args, value) => [{ type: 'text', text: formatSnapshot(value, { coords: args.coords === true }) }],
|
|
260
276
|
},
|
|
261
277
|
timeoutMs,
|
|
262
278
|
isConcurrencySafe: () => true,
|
|
@@ -281,6 +297,7 @@ export function apply(ctx, config = {}) {
|
|
|
281
297
|
parameters: {
|
|
282
298
|
includeHidden: { type: 'boolean', description: 'Include hidden elements (default false).' },
|
|
283
299
|
maxNodes: { type: 'number', description: 'Maximum nodes (default 500, range 10-5000).' },
|
|
300
|
+
coords: { type: 'boolean', description: 'Include each element\'s viewport coordinates. Off by default: a semantic target needs no coordinates, and they are pure noise unless a vision pass is going to click a pixel position.' },
|
|
284
301
|
},
|
|
285
302
|
output: {
|
|
286
303
|
schema: {
|
|
@@ -313,14 +330,24 @@ export function apply(ctx, config = {}) {
|
|
|
313
330
|
},
|
|
314
331
|
},
|
|
315
332
|
},
|
|
316
|
-
render: (
|
|
333
|
+
render: (args, value) => {
|
|
317
334
|
const nodes = value.nodes;
|
|
335
|
+
// Built for reading, not for imaging: containment is shown as indentation
|
|
336
|
+
// (the depth was already collected and simply thrown away), an empty state
|
|
337
|
+
// list adds nothing, and coordinates appear only when asked for — a model
|
|
338
|
+
// working from the DOM never needs them, and they are the bulkiest part of
|
|
339
|
+
// every line.
|
|
340
|
+
const showCoords = args.coords === true;
|
|
318
341
|
const lines = nodes.map(n => {
|
|
319
342
|
const valuePart = n.value !== undefined && n.value !== null ? ` value="${String(n.value).slice(0, 60)}"` : '';
|
|
320
|
-
|
|
343
|
+
const indent = ' '.repeat(Math.max(0, Math.min(8, Number(n.depth) || 0)));
|
|
344
|
+
const coordsPart = showCoords ? ` (${n.x},${n.y})` : '';
|
|
345
|
+
const statePart = Array.isArray(n.states) && n.states.length > 0 ? ` states=[${n.states.join(',')}]` : '';
|
|
346
|
+
return `${indent}[${n.ref}] ${n.role} "${n.name}"${valuePart}${coordsPart}${statePart}${n.frame === true ? ' (iframe)' : ''}`;
|
|
321
347
|
});
|
|
322
348
|
const header = `URL: ${value.url}${value.title !== undefined ? `\nTitle: ${value.title}` : ''}`;
|
|
323
|
-
|
|
349
|
+
const hint = showCoords ? '' : '\n(coordinates omitted — pass coords: true to include them, or click by semantic target)';
|
|
350
|
+
return [{ type: 'text', text: `${header}\n\n${lines.length > 0 ? lines.join('\n') : '(no accessible interactive nodes)'}${value.truncated === true ? '\n(truncated)' : ''}${hint}` }];
|
|
324
351
|
},
|
|
325
352
|
},
|
|
326
353
|
timeoutMs,
|
|
@@ -553,7 +580,7 @@ export function apply(ctx, config = {}) {
|
|
|
553
580
|
}));
|
|
554
581
|
ctx.tools.register(defineTool({
|
|
555
582
|
name: 'browser_click',
|
|
556
|
-
description: 'Click in the shared browser.
|
|
583
|
+
description: 'Click in the shared browser. Prefer the semantic target: pass target {by: css|text|xpath, value, index?} and the element is located from the DOM, scrolled into view and clicked at its centre — this needs no image input and is the only form that works without vision. The alternative is viewport coordinates (x/y), which are meaningful only after a vision model has located the element on a browser_screenshot; coordinate clicks are refused outright under the non-visual strategy. Provide exactly one of target or x/y.',
|
|
557
584
|
parameters: {
|
|
558
585
|
target: {
|
|
559
586
|
type: 'object',
|
|
@@ -565,8 +592,8 @@ export function apply(ctx, config = {}) {
|
|
|
565
592
|
},
|
|
566
593
|
description: 'Locate the element semantically and click it (css/text/xpath).',
|
|
567
594
|
},
|
|
568
|
-
x: { type: 'number', description: 'Viewport x coordinate (CSS px),
|
|
569
|
-
y: { type: 'number', description: 'Viewport y coordinate (CSS px).' },
|
|
595
|
+
x: { type: 'number', description: 'Viewport x coordinate (CSS px), taken from a browser_screenshot read by a vision model. Prefer a semantic target — it works without image input.' },
|
|
596
|
+
y: { type: 'number', description: 'Viewport y coordinate (CSS px), taken from the same screenshot as x.' },
|
|
570
597
|
},
|
|
571
598
|
output: {
|
|
572
599
|
schema: { type: 'object', additionalProperties: false, properties: { clicked: { type: 'boolean', required: true } } },
|
|
@@ -1044,7 +1071,7 @@ export function apply(ctx, config = {}) {
|
|
|
1044
1071
|
}));
|
|
1045
1072
|
ctx.tools.register(defineTool({
|
|
1046
1073
|
name: 'browser_screenshot',
|
|
1047
|
-
description: 'Capture the current shared-browser page as a screenshot (PNG default, JPEG optional).
|
|
1074
|
+
description: 'Capture the current shared-browser page as a screenshot (PNG default, JPEG optional). This is for models that can read images: layout checks, charts, designs, CAPTCHAs, or locating an element by eye before clicking its coordinates. A model without image input gains nothing from it — browser_snapshot, browser_a11y and browser_content carry the same page as text, and browser_scrape extracts structured data. Supports full-page capture, save-to-file, JPEG encoding, and downscaling (maxWidth/maxHeight) to cut vision-tool token cost. JPEG is only available on the self-hosted native path; the desktop-shell path returns PNG.',
|
|
1048
1075
|
parameters: {
|
|
1049
1076
|
fullPage: { type: 'boolean', description: 'Capture the full scrollable page instead of the viewport (default false).' },
|
|
1050
1077
|
savePath: { type: 'string', description: 'Absolute file path to also save the image to (e.g. for read_image vision location). Must resolve inside the configured downloadDir (default: the system Downloads folder, localized names such as ~/下载 included); an existing file is never overwritten.' },
|
|
@@ -1296,6 +1323,8 @@ export function apply(ctx, config = {}) {
|
|
|
1296
1323
|
parameters: {
|
|
1297
1324
|
limit: { type: 'number', description: 'Maximum entries to return (default 30, capped at 200).' },
|
|
1298
1325
|
domain: { type: 'string', description: 'Only visits whose hostname contains this text (case-insensitive).' },
|
|
1326
|
+
query: { type: 'string', description: 'Only visits whose URL or title contains this text (case-insensitive).' },
|
|
1327
|
+
session: { type: 'string', description: 'Only visits recorded by this task/session label — use it to separate your own pages from another session\'s or the human\'s.' },
|
|
1299
1328
|
},
|
|
1300
1329
|
output: {
|
|
1301
1330
|
schema: {
|
|
@@ -1343,7 +1372,14 @@ export function apply(ctx, config = {}) {
|
|
|
1343
1372
|
const rawLimit = typeof args.limit === 'number' && Number.isFinite(args.limit) ? args.limit : 30;
|
|
1344
1373
|
const limit = Math.max(1, Math.min(200, Math.floor(rawLimit)));
|
|
1345
1374
|
const domain = typeof args.domain === 'string' && args.domain.trim() !== '' ? args.domain.trim() : undefined;
|
|
1346
|
-
const
|
|
1375
|
+
const query = typeof args.query === 'string' && args.query.trim() !== '' ? args.query.trim() : undefined;
|
|
1376
|
+
const session = typeof args.session === 'string' && args.session.trim() !== '' ? args.session.trim() : undefined;
|
|
1377
|
+
const entries = browser.visited({
|
|
1378
|
+
limit,
|
|
1379
|
+
...domain !== undefined ? { domain } : {},
|
|
1380
|
+
...query !== undefined ? { query } : {},
|
|
1381
|
+
...session !== undefined ? { session } : {},
|
|
1382
|
+
});
|
|
1347
1383
|
return {
|
|
1348
1384
|
count: entries.length,
|
|
1349
1385
|
entries: entries.map(entry => ({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-builtin-browser",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Shared real browser plugin for DeepSeek Harness: install-and-use — a visible native browser the human can take over, driven by the agent over CDP. DOM-level interaction (React/Vue safe), per-task session isolation, cookie persistence (browser_auth), CAPTCHA detection, batch form fill (browser_fill), authenticated download, history/replay, action restriction",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
"lib",
|
|
36
36
|
"src",
|
|
37
37
|
"docs",
|
|
38
|
+
"desktop-bridge",
|
|
38
39
|
"client.js",
|
|
39
40
|
"cordis.patch.yml",
|
|
40
41
|
"README.md",
|
|
@@ -0,0 +1,145 @@
|
|
|
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
|
+
|
|
21
|
+
import { connect, type Socket } from 'node:net'
|
|
22
|
+
|
|
23
|
+
/** Where the shell publishes its bridge endpoint. */
|
|
24
|
+
export interface BridgeEndpoint {
|
|
25
|
+
readonly port: number
|
|
26
|
+
readonly token: string
|
|
27
|
+
readonly pid: number
|
|
28
|
+
readonly updatedAt?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** One queued request, waiting for its answer. */
|
|
32
|
+
interface PendingCall {
|
|
33
|
+
readonly resolve: (answer: Record<string, unknown>) => void
|
|
34
|
+
readonly reject: (error: Error) => void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A reusable connection to the bridge.
|
|
39
|
+
*
|
|
40
|
+
* Requests are serialised by construction: one socket, one in-flight request at a
|
|
41
|
+
* time, answered in order. A broken socket is discarded so the next call dials
|
|
42
|
+
* again — a dead connection must never become a dead plugin.
|
|
43
|
+
*/
|
|
44
|
+
export class BridgeConnection {
|
|
45
|
+
private socket: Socket | undefined
|
|
46
|
+
private buffer = ''
|
|
47
|
+
private readonly queue: PendingCall[] = []
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param endpoint - the shell's published bridge endpoint.
|
|
51
|
+
*/
|
|
52
|
+
constructor(private readonly endpoint: BridgeEndpoint) {}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Send one request, reusing the socket if it is still healthy.
|
|
56
|
+
* @param request - the request body (the token is added here).
|
|
57
|
+
* @param timeoutMs - how long to wait for the answer before dropping the socket.
|
|
58
|
+
* @returns the bridge's answer.
|
|
59
|
+
*/
|
|
60
|
+
async call(request: Record<string, unknown>, timeoutMs = 20_000): Promise<Record<string, unknown>> {
|
|
61
|
+
const socket = this.ensureSocket()
|
|
62
|
+
return await new Promise<Record<string, unknown>>((resolve, reject) => {
|
|
63
|
+
const timer = setTimeout(() => {
|
|
64
|
+
const index = this.queue.findIndex(entry => entry.resolve === resolve)
|
|
65
|
+
if (index !== -1) this.queue.splice(index, 1)
|
|
66
|
+
const error = new Error(`dsh-builtin-browser: bridge timed out after ${timeoutMs}ms`)
|
|
67
|
+
this.reset(error)
|
|
68
|
+
reject(error)
|
|
69
|
+
}, timeoutMs)
|
|
70
|
+
this.queue.push({
|
|
71
|
+
resolve: answer => { clearTimeout(timer); resolve(answer) },
|
|
72
|
+
reject: error => { clearTimeout(timer); reject(error) },
|
|
73
|
+
})
|
|
74
|
+
try {
|
|
75
|
+
socket.write(JSON.stringify({ token: this.endpoint.token, ...request }) + '\n')
|
|
76
|
+
} catch (error) {
|
|
77
|
+
clearTimeout(timer)
|
|
78
|
+
this.queue.pop()
|
|
79
|
+
const failure = error instanceof Error ? error : new Error(String(error))
|
|
80
|
+
this.reset(failure)
|
|
81
|
+
reject(failure)
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Close the socket and fail anything still waiting on it. */
|
|
87
|
+
close(): void {
|
|
88
|
+
this.reset(new Error('dsh-builtin-browser: bridge closed'))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The live socket, connecting and authenticating it on first use. */
|
|
92
|
+
private ensureSocket(): Socket {
|
|
93
|
+
if (this.socket !== undefined) return this.socket
|
|
94
|
+
const socket = connect({ host: '127.0.0.1', port: this.endpoint.port })
|
|
95
|
+
socket.setEncoding('utf8')
|
|
96
|
+
socket.on('data', chunk => this.onData(String(chunk)))
|
|
97
|
+
socket.on('error', error => this.reset(error))
|
|
98
|
+
socket.on('close', () => this.reset(new Error('dsh-builtin-browser: bridge connection closed')))
|
|
99
|
+
this.buffer = ''
|
|
100
|
+
this.socket = socket
|
|
101
|
+
socket.write(JSON.stringify({ token: this.endpoint.token }) + '\n')
|
|
102
|
+
return socket
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Resolve queued requests in order as answers arrive. */
|
|
106
|
+
private onData(chunk: string): void {
|
|
107
|
+
this.buffer += chunk
|
|
108
|
+
let newline = this.buffer.indexOf('\n')
|
|
109
|
+
while (newline !== -1) {
|
|
110
|
+
const line = this.buffer.slice(0, newline).trim()
|
|
111
|
+
this.buffer = this.buffer.slice(newline + 1)
|
|
112
|
+
newline = this.buffer.indexOf('\n')
|
|
113
|
+
if (line === '') continue
|
|
114
|
+
const entry = this.queue.shift()
|
|
115
|
+
if (entry === undefined) continue
|
|
116
|
+
try {
|
|
117
|
+
const answer = JSON.parse(line) as Record<string, unknown>
|
|
118
|
+
// A rejected token means the shell restarted with a new one, so this socket
|
|
119
|
+
// can never succeed again: answer (the caller needs the error) and drop it,
|
|
120
|
+
// so the next call dials afresh instead of retrying a doomed credential.
|
|
121
|
+
if (answer.ok === false && answer.error === 'bad token') {
|
|
122
|
+
entry.resolve(answer)
|
|
123
|
+
this.reset(new Error('dsh-builtin-browser: the bridge rejected our token'))
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
entry.resolve(answer)
|
|
127
|
+
} catch (error) {
|
|
128
|
+
entry.reject(new Error(`dsh-builtin-browser: malformed bridge answer (${String(error)})`))
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Tear the socket down, failing pending work. */
|
|
134
|
+
private reset(error: Error): void {
|
|
135
|
+
if (this.socket !== undefined) {
|
|
136
|
+
const socket = this.socket
|
|
137
|
+
this.socket = undefined
|
|
138
|
+
socket.removeAllListeners()
|
|
139
|
+
socket.destroy()
|
|
140
|
+
}
|
|
141
|
+
this.buffer = ''
|
|
142
|
+
const pending = this.queue.splice(0, this.queue.length)
|
|
143
|
+
for (const entry of pending) entry.reject(error)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Desktop-sidebar browser host: drive the page the desktop shell shows in its
|
|
3
|
+
* sidebar, instead of spawning a second, parallel Electron window.
|
|
4
|
+
*
|
|
5
|
+
* WHY
|
|
6
|
+
* The plugin runs inside the desktop's Node-mode host, where there is no Electron
|
|
7
|
+
* API — which is why it self-hosts a whole browser today. The shell, however, can
|
|
8
|
+
* own views, and its sidebar already displays real web pages. This host borrows
|
|
9
|
+
* that page over the shell's bridge (see apps/desktop/bridge/plugin-browser-bridge.js),
|
|
10
|
+
* so the agent and the human end up on ONE page instead of two.
|
|
11
|
+
*
|
|
12
|
+
* CONTRACT
|
|
13
|
+
* It implements the same `ElectronBrowserViewHost` seam the self-hosted host does,
|
|
14
|
+
* so the provider, the tools, browsing history, the synthetic cursor and the
|
|
15
|
+
* teardown rules are all unchanged: only the carrier differs. Everything is
|
|
16
|
+
* lazily materialized — a fresh shell has no sidebar guest until something asks
|
|
17
|
+
* for a page, and the bridge creates one on demand.
|
|
18
|
+
*
|
|
19
|
+
* FALLBACK
|
|
20
|
+
* `discover()` returns undefined whenever the shell offers no usable bridge
|
|
21
|
+
* (older desktop build, plain `dsh web`, bridge not started), and the entry point
|
|
22
|
+
* then keeps the self-hosted Electron it has always used.
|
|
23
|
+
* @module dsh-browser/browser-electron/desktop-bridge-host
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { BridgeConnection, type BridgeEndpoint } from './bridge-connection.js'
|
|
27
|
+
import { readFileSync } from 'node:fs'
|
|
28
|
+
import { connect, type Socket } from 'node:net'
|
|
29
|
+
import { homedir } from 'node:os'
|
|
30
|
+
import { join } from 'node:path'
|
|
31
|
+
import { randomUUID } from 'node:crypto'
|
|
32
|
+
import type { ElectronBrowserViewHost, ElectronViewHandle } from './provider.js'
|
|
33
|
+
import type { BrowserUserAction } from './provider.js'
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
/** Absolute path of the endpoint file the shell writes. */
|
|
37
|
+
export function bridgeEndpointPath(): string {
|
|
38
|
+
const home = process.env.DSH_HOME ?? join(homedir(), '.dsh')
|
|
39
|
+
return join(home, 'dsh-builtin-browser-bridge.json')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Whether a command failed because the page is no longer there (as opposed to
|
|
44
|
+
* failing on its own merits). Only these are worth retrying on a fresh guest.
|
|
45
|
+
* @param error - the failure to classify.
|
|
46
|
+
*/
|
|
47
|
+
function isGuestGone(error: unknown): boolean {
|
|
48
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
49
|
+
return /guest \d+ is not available|sidebar unavailable|did not create a browser guest/i.test(message)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The desktop sidebar, presented as a browser view host.
|
|
54
|
+
*
|
|
55
|
+
* One sidebar exists per shell window, so every view this host hands out refers
|
|
56
|
+
* to the same guest: the provider keeps its tab bookkeeping, and the tabs simply
|
|
57
|
+
* share the visible page. That is the intended behaviour for this carrier — the
|
|
58
|
+
* point is a single page both parties can see.
|
|
59
|
+
*/
|
|
60
|
+
export class DesktopBridgeViewHost implements ElectronBrowserViewHost {
|
|
61
|
+
private readonly connection: BridgeConnection
|
|
62
|
+
private readonly views = new Map<string, number>()
|
|
63
|
+
/**
|
|
64
|
+
* Guests whose view is gone but whose page may still be open in the sidebar.
|
|
65
|
+
*
|
|
66
|
+
* The provider destroys its view handles before it asks for a release, so the
|
|
67
|
+
* mapping is dropped by then — keeping the guest ids here is what lets us close
|
|
68
|
+
* only our own tabs when several sessions are running (requirements §4: each
|
|
69
|
+
* session gets its own page).
|
|
70
|
+
*/
|
|
71
|
+
private readonly orphaned = new Set<number>()
|
|
72
|
+
private userActionHandler: ((action: BrowserUserAction) => void) | undefined
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @param endpoint - the shell's published bridge endpoint.
|
|
76
|
+
*/
|
|
77
|
+
private constructor(private readonly endpoint: BridgeEndpoint) {
|
|
78
|
+
this.connection = new BridgeConnection(endpoint)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Find a usable desktop bridge, if this surface has one.
|
|
83
|
+
*
|
|
84
|
+
* A stale endpoint (a shell that already exited, leaving its file behind) is
|
|
85
|
+
* rejected here rather than surfacing later as a mysterious ECONNREFUSED: the
|
|
86
|
+
* caller falls back to self-hosting, which always works.
|
|
87
|
+
* @returns the host, or undefined when no bridge is available.
|
|
88
|
+
*/
|
|
89
|
+
static async discover(): Promise<DesktopBridgeViewHost | undefined> {
|
|
90
|
+
let endpoint: BridgeEndpoint
|
|
91
|
+
try {
|
|
92
|
+
const raw = JSON.parse(readFileSync(bridgeEndpointPath(), 'utf8')) as Partial<BridgeEndpoint>
|
|
93
|
+
if (typeof raw.port !== 'number' || typeof raw.token !== 'string' || typeof raw.pid !== 'number') return undefined
|
|
94
|
+
endpoint = { port: raw.port, token: raw.token, pid: raw.pid, ...raw.updatedAt !== undefined ? { updatedAt: raw.updatedAt } : {} }
|
|
95
|
+
} catch {
|
|
96
|
+
return undefined
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
// A throwaway connection for discovery: the adopted host opens its own, and a
|
|
100
|
+
// rejected endpoint must not leave a socket behind.
|
|
101
|
+
const probe = new BridgeConnection(endpoint)
|
|
102
|
+
try {
|
|
103
|
+
const answer = await probe.call({ op: 'list' }, 5_000)
|
|
104
|
+
return answer.ok === true ? new DesktopBridgeViewHost(endpoint) : undefined
|
|
105
|
+
} finally {
|
|
106
|
+
probe.close()
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
// Dead endpoint or an older bridge: self-hosting remains the answer.
|
|
110
|
+
return undefined
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Whether this host can back views: the bridge already answered `list`. */
|
|
115
|
+
available(): boolean {
|
|
116
|
+
return true
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The guest id backing a view, materialized on first use.
|
|
121
|
+
*
|
|
122
|
+
* The sidebar browser is itself a multi-tab surface, so each view gets its own
|
|
123
|
+
* tab's guest: the provider's tab bookkeeping then maps onto real tabs the human
|
|
124
|
+
* can see and switch between. Two rules keep that honest:
|
|
125
|
+
* - a cached guest is used as-is: probing it first cost a round-trip on every
|
|
126
|
+
* command, so liveness is established by the command failing instead;
|
|
127
|
+
* - a fresh view takes an unclaimed guest, growing the tab strip only when
|
|
128
|
+
* every existing guest is already spoken for.
|
|
129
|
+
* @param viewId - the view whose guest is wanted.
|
|
130
|
+
* @param url - address to use when a sidebar browser has to be opened first.
|
|
131
|
+
*/
|
|
132
|
+
private async guestFor(viewId: string, url?: string): Promise<number> {
|
|
133
|
+
const existing = this.views.get(viewId)
|
|
134
|
+
// No liveness check here: verifying the cached guest cost a round-trip on every
|
|
135
|
+
// command. A guest that has gone away is detected by the command itself failing,
|
|
136
|
+
// and `createView`'s sendCommand then discards the cache and calls back in here.
|
|
137
|
+
if (existing !== undefined) return existing
|
|
138
|
+
|
|
139
|
+
// The browser tab must exist before its strip can be extended: `ensureSidebar`
|
|
140
|
+
// opens the sidebar (and, on a fresh shell, materializes the first guest).
|
|
141
|
+
const sidebar = await this.connection.call({
|
|
142
|
+
op: 'ensureSidebar',
|
|
143
|
+
...url !== undefined ? { url } : {},
|
|
144
|
+
})
|
|
145
|
+
if (sidebar.ok !== true) throw new Error(`dsh-builtin-browser: sidebar unavailable (${String(sidebar.error)})`)
|
|
146
|
+
|
|
147
|
+
const wanted = this.views.size + 1
|
|
148
|
+
let ids = await this.guestIds(Math.max(1, wanted))
|
|
149
|
+
const taken = new Set(this.views.values())
|
|
150
|
+
let free = ids.find(id => !taken.has(id))
|
|
151
|
+
if (free === undefined) {
|
|
152
|
+
// Every tab is already driving something: the strip has to grow.
|
|
153
|
+
ids = await this.guestIds(ids.length + 1)
|
|
154
|
+
free = ids.find(id => !taken.has(id))
|
|
155
|
+
}
|
|
156
|
+
if (free === undefined) throw new Error('dsh-builtin-browser: the sidebar reported no free browser tab')
|
|
157
|
+
this.views.set(viewId, free)
|
|
158
|
+
return free
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Ask for at least `count` sidebar tabs and return their guest ids.
|
|
163
|
+
* @param count - minimum number of tabs.
|
|
164
|
+
*/
|
|
165
|
+
private async guestIds(count: number): Promise<number[]> {
|
|
166
|
+
const answer = await this.connection.call({ op: 'ensureTabs', count })
|
|
167
|
+
if (answer.ok !== true) throw new Error(`dsh-builtin-browser: could not open a sidebar tab (${String(answer.error)})`)
|
|
168
|
+
const ids = Array.isArray(answer.ids) ? answer.ids.map(Number).filter(Number.isFinite) : []
|
|
169
|
+
if (ids.length === 0) throw new Error('dsh-builtin-browser: the sidebar reported no browser tabs')
|
|
170
|
+
return ids
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
createView(): ElectronViewHandle {
|
|
174
|
+
const viewId = randomUUID()
|
|
175
|
+
const navigateUrl = (method: string, params?: Record<string, unknown>): string | undefined =>
|
|
176
|
+
method === 'Page.navigate' ? String((params as { url?: unknown })?.url ?? '') : undefined
|
|
177
|
+
const run = async (guest: number, method: string, params?: Record<string, unknown>): Promise<Record<string, unknown>> => {
|
|
178
|
+
const answer = await this.connection.call({ op: 'cdp', id: guest, method, params })
|
|
179
|
+
if (answer.ok !== true) throw new Error(`dsh-builtin-browser: sidebar command failed: ${String(answer.error)}`)
|
|
180
|
+
// The bridge forwards whatever CDP answered; the seam expects an object.
|
|
181
|
+
return (answer.result ?? {}) as Record<string, unknown>
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
id: viewId,
|
|
185
|
+
sendCommand: async (method: string, params?: Record<string, unknown>) => {
|
|
186
|
+
// No liveness probe up front. Checking first cost a whole round-trip on
|
|
187
|
+
// every command; instead the command runs and its failure is what triggers
|
|
188
|
+
// recovery. Only a genuinely missing guest is retried, so a real error (a
|
|
189
|
+
// bad selector, a timeout) still surfaces unchanged.
|
|
190
|
+
const url = navigateUrl(method, params)
|
|
191
|
+
let guest = await this.guestFor(viewId, url)
|
|
192
|
+
try {
|
|
193
|
+
return await run(guest, method, params)
|
|
194
|
+
} catch (error) {
|
|
195
|
+
if (!isGuestGone(error)) throw error
|
|
196
|
+
// The human closed the tab: the page is gone, so take a fresh one and
|
|
197
|
+
// replay the command once. This is the "closing the interface ends the
|
|
198
|
+
// session" rule, now paid for only when it actually happens.
|
|
199
|
+
this.views.delete(viewId)
|
|
200
|
+
guest = await this.guestFor(viewId, url)
|
|
201
|
+
return await run(guest, method, params)
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
destroyView(handle: ElectronViewHandle): void {
|
|
208
|
+
const guest = this.views.get(handle.id)
|
|
209
|
+
if (guest !== undefined) {
|
|
210
|
+
// Remember it: the page outlives our handle, and a later release must be able
|
|
211
|
+
// to name exactly the tabs this plugin opened.
|
|
212
|
+
this.orphaned.add(guest)
|
|
213
|
+
this.views.delete(handle.id)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Nothing to show: the sidebar is already on screen, and showing it again is
|
|
219
|
+
* the shell's business (it owns the tab strip and the visibility toggle).
|
|
220
|
+
*/
|
|
221
|
+
showView(): void {}
|
|
222
|
+
|
|
223
|
+
/** Same as {@link showView}: the page is already presented by the shell. */
|
|
224
|
+
async presentView(): Promise<void> {}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The shell owns window grouping, and one sidebar serves every view, so there
|
|
228
|
+
* is nothing to record here. (It must not write a placeholder guest id either:
|
|
229
|
+
* a view whose guest is not yet materialized has to stay absent from the map,
|
|
230
|
+
* or the first real command would be sent to the placeholder.)
|
|
231
|
+
*/
|
|
232
|
+
groupView(): void {}
|
|
233
|
+
|
|
234
|
+
/** Focus is the shell's to manage, and there is no separate window to raise. */
|
|
235
|
+
async focus(): Promise<void> {}
|
|
236
|
+
|
|
237
|
+
onUserAction(handler: (action: BrowserUserAction) => void): void {
|
|
238
|
+
this.userActionHandler = handler
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The shell reports its own window lifecycle; nothing to subscribe to here. */
|
|
242
|
+
onViewClosed(): void {}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Release the sidebar's browser pages (requirements §3, `ui.closeWithSession`).
|
|
246
|
+
*
|
|
247
|
+
* Destroying our own view handles is not enough on this carrier: the sidebar
|
|
248
|
+
* belongs to the shell and would happily keep the page (and its renderer) alive.
|
|
249
|
+
* Closing the tabs is what actually ends the page — and only the page: cookies
|
|
250
|
+
* live in the partition, history on disk, so both survive.
|
|
251
|
+
* @returns a promise that settles once the shell has been asked.
|
|
252
|
+
*/
|
|
253
|
+
async releasePage(): Promise<void> {
|
|
254
|
+
// Only our own tabs: the ids come from the views we handed out (and their
|
|
255
|
+
// orphans), never from "every webview currently visible". With two sessions
|
|
256
|
+
// live, releasing one must leave the other's page alone.
|
|
257
|
+
const mine = [...this.views.values(), ...this.orphaned]
|
|
258
|
+
this.views.clear()
|
|
259
|
+
this.orphaned.clear()
|
|
260
|
+
// Nothing of ours is open: release nothing. Falling through to an unfiltered
|
|
261
|
+
// release here would close tabs a human opened, or another session's page.
|
|
262
|
+
if (mine.length === 0) return
|
|
263
|
+
let titles: string[]
|
|
264
|
+
try {
|
|
265
|
+
const answer = await this.connection.call({ op: 'list' }, 5_000)
|
|
266
|
+
const sidebar = Array.isArray(answer.sidebar) ? answer.sidebar as Array<{ id?: unknown; title?: unknown }> : []
|
|
267
|
+
titles = sidebar
|
|
268
|
+
.filter(guest => mine.includes(Number(guest.id)))
|
|
269
|
+
.map(guest => String(guest.title ?? ''))
|
|
270
|
+
.filter(title => title !== '')
|
|
271
|
+
} catch {
|
|
272
|
+
// Could not read the titles back; releasing nothing is safer than closing
|
|
273
|
+
// tabs that belong to somebody else.
|
|
274
|
+
return
|
|
275
|
+
}
|
|
276
|
+
if (titles.length === 0) return
|
|
277
|
+
try {
|
|
278
|
+
await this.connection.call({ op: 'closeSidebarBrowser', titles }, 10_000)
|
|
279
|
+
} catch {
|
|
280
|
+
// Best effort: the setting expresses a preference, and a shell that cannot
|
|
281
|
+
// be reached is no reason to fail the session teardown that called us.
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Fold the sidebar away without ending the page (`ui.autoExpandOnce` is off, or
|
|
287
|
+
* the caller wants the screen back while work continues).
|
|
288
|
+
* @returns a promise that settles once the shell has been asked.
|
|
289
|
+
*/
|
|
290
|
+
async collapse(): Promise<void> {
|
|
291
|
+
try {
|
|
292
|
+
await this.connection.call({ op: 'collapseSidebar' }, 10_000)
|
|
293
|
+
} catch {
|
|
294
|
+
// Presentation only; failing to fold is never worth an error.
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** No child process of our own to stop; close the shared connection instead. */
|
|
299
|
+
dispose(): void {
|
|
300
|
+
this.connection.close()
|
|
301
|
+
this.views.clear()
|
|
302
|
+
this.orphaned.clear()
|
|
303
|
+
this.userActionHandler = undefined
|
|
304
|
+
}
|
|
305
|
+
}
|