dsh-builtin-browser 0.4.2 → 0.4.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1753 -0
- package/README.en.md +63 -20
- package/README.md +60 -20
- package/client.js +433 -24
- package/desktop-bridge/install.mjs +66 -6
- package/desktop-bridge/plugin-browser-bridge.js +425 -163
- package/docs/architecture.md +4 -4
- package/docs/tool-reference.md +3 -3
- package/docs/user-guide.md +50 -2
- package/lib/browser/types.d.ts +9 -2
- package/lib/browser-electron/bridge-connection.d.ts +18 -21
- package/lib/browser-electron/bridge-connection.js +42 -1
- package/lib/browser-electron/desktop-bridge-host.d.ts +69 -36
- package/lib/browser-electron/desktop-bridge-host.js +151 -86
- package/lib/browser-electron/entry.d.ts +20 -0
- package/lib/browser-electron/entry.js +110 -7
- package/lib/browser-electron/history-store.d.ts +11 -2
- package/lib/browser-electron/history-store.js +27 -5
- package/lib/browser-electron/host-main.js +11 -1
- package/lib/browser-electron/provider.d.ts +62 -2
- package/lib/browser-electron/provider.js +28 -12
- package/lib/browser-electron/remote-host.js +19 -6
- package/lib/browser-electron/rpc-ops.d.ts +18 -0
- package/lib/browser-electron/rpc-ops.js +36 -0
- package/lib/browser-electron/settings-route.d.ts +6 -6
- package/lib/browser-electron/settings-route.js +48 -8
- package/lib/browser-electron/settings-store.js +57 -16
- package/lib/browser-electron/system-browser.d.ts +31 -9
- package/lib/browser-electron/system-browser.js +116 -27
- package/lib/index.js +0 -1
- package/lib/tool-browser/element-fields.d.ts +145 -0
- package/lib/tool-browser/element-fields.js +152 -0
- package/lib/tool-browser/index.js +26 -60
- package/package.json +6 -2
- package/src/browser/types.ts +9 -2
- package/src/browser-electron/bridge-connection.ts +37 -1
- package/src/browser-electron/desktop-bridge-host.ts +146 -84
- package/src/browser-electron/entry.ts +113 -8
- package/src/browser-electron/history-store.ts +32 -7
- package/src/browser-electron/host-main.ts +10 -1
- package/src/browser-electron/provider.ts +91 -18
- package/src/browser-electron/remote-host.ts +19 -6
- package/src/browser-electron/rpc-ops.ts +41 -0
- package/src/browser-electron/settings-route.ts +54 -13
- package/src/browser-electron/settings-store.ts +56 -17
- package/src/browser-electron/system-browser.ts +121 -28
- package/src/index.ts +3 -2
- package/src/tool-browser/element-fields.ts +167 -0
- package/src/tool-browser/index.ts +31 -60
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* @module dsh-browser/tool-browser
|
|
15
15
|
*/
|
|
16
16
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
17
|
+
import { ELEMENT_ITEM_SCHEMA, NODE_ITEM_SCHEMA, projectElement, projectNode, } from './element-fields.js';
|
|
17
18
|
/**
|
|
18
19
|
* Register a cleanup that runs once when the process exits.
|
|
19
20
|
*
|
|
@@ -278,24 +279,8 @@ export function apply(ctx, config = {}) {
|
|
|
278
279
|
elements: {
|
|
279
280
|
type: 'array',
|
|
280
281
|
required: true,
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
additionalProperties: false,
|
|
284
|
-
properties: {
|
|
285
|
-
ref: { type: 'number', required: true },
|
|
286
|
-
kind: { type: 'string', required: true },
|
|
287
|
-
label: { type: 'string', required: true },
|
|
288
|
-
x: { type: 'number', required: true },
|
|
289
|
-
y: { type: 'number', required: true },
|
|
290
|
-
frame: { type: 'boolean' },
|
|
291
|
-
// The page-side script computes this for every element and the tool layer
|
|
292
|
-
// passes it through. Without it in the schema, a declared
|
|
293
|
-
// additionalProperties: false rejects the whole result at run time — which is
|
|
294
|
-
// how this was found, on the very first real call, after a static review had
|
|
295
|
-
// called the change verified.
|
|
296
|
-
selector: { type: 'string' },
|
|
297
|
-
},
|
|
298
|
-
},
|
|
282
|
+
// Same shared item schema as browser_open's, so the two cannot drift apart.
|
|
283
|
+
items: ELEMENT_ITEM_SCHEMA,
|
|
299
284
|
},
|
|
300
285
|
challenge: {
|
|
301
286
|
type: 'object',
|
|
@@ -329,7 +314,7 @@ export function apply(ctx, config = {}) {
|
|
|
329
314
|
return {
|
|
330
315
|
url: snapshot.url,
|
|
331
316
|
...snapshot.title !== undefined ? { title: snapshot.title } : {},
|
|
332
|
-
elements: snapshot.elements.map(el => (
|
|
317
|
+
elements: snapshot.elements.map(el => projectElement(el)),
|
|
333
318
|
truncated: snapshot.truncated,
|
|
334
319
|
...snapshot.challenge !== undefined ? { challenge: snapshot.challenge } : {},
|
|
335
320
|
};
|
|
@@ -395,7 +380,7 @@ export function apply(ctx, config = {}) {
|
|
|
395
380
|
return {
|
|
396
381
|
url: snapshot.url,
|
|
397
382
|
...snapshot.title !== undefined ? { title: snapshot.title } : {},
|
|
398
|
-
elements: snapshot.elements.map(el => (
|
|
383
|
+
elements: snapshot.elements.map(el => projectElement(el)),
|
|
399
384
|
truncated: snapshot.truncated,
|
|
400
385
|
...snapshot.challenge !== undefined ? { challenge: snapshot.challenge } : {},
|
|
401
386
|
};
|
|
@@ -421,28 +406,8 @@ export function apply(ctx, config = {}) {
|
|
|
421
406
|
nodes: {
|
|
422
407
|
type: 'array',
|
|
423
408
|
required: true,
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
additionalProperties: false,
|
|
427
|
-
properties: {
|
|
428
|
-
ref: { type: 'number', required: true },
|
|
429
|
-
role: { type: 'string', required: true },
|
|
430
|
-
name: { type: 'string', required: true },
|
|
431
|
-
value: { type: 'string' },
|
|
432
|
-
states: { type: 'array', required: true, items: { type: 'string' } },
|
|
433
|
-
depth: { type: 'number', required: true },
|
|
434
|
-
tag: { type: 'string', required: true },
|
|
435
|
-
x: { type: 'number', required: true },
|
|
436
|
-
y: { type: 'number', required: true },
|
|
437
|
-
frame: { type: 'boolean' },
|
|
438
|
-
// The page-side script computes this for every element and the tool layer
|
|
439
|
-
// passes it through. Without it in the schema, a declared
|
|
440
|
-
// additionalProperties: false rejects the whole result at run time — which is
|
|
441
|
-
// how this was found, on the very first real call, after a static review had
|
|
442
|
-
// called the change verified.
|
|
443
|
-
selector: { type: 'string' },
|
|
444
|
-
},
|
|
445
|
-
},
|
|
409
|
+
// Same shared table as the node projection.
|
|
410
|
+
items: NODE_ITEM_SCHEMA,
|
|
446
411
|
},
|
|
447
412
|
},
|
|
448
413
|
},
|
|
@@ -491,23 +456,18 @@ export function apply(ctx, config = {}) {
|
|
|
491
456
|
...result.title !== undefined ? { title: result.title } : {},
|
|
492
457
|
count: result.count,
|
|
493
458
|
truncated: result.truncated,
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
selector: n.selector,
|
|
507
|
-
x: n.x,
|
|
508
|
-
y: n.y,
|
|
509
|
-
...n.frame === true ? { frame: true } : {},
|
|
510
|
-
})),
|
|
459
|
+
// The projection drops `value: null` (the schema declares a string, and the contract is
|
|
460
|
+
// that an absent value means "not applicable"), so the run-time shape and the inferred
|
|
461
|
+
// type differ by exactly that. The field table is what keeps the schema and the
|
|
462
|
+
// projection in agreement; this assertion is the type-level echo of that.
|
|
463
|
+
// The projection and the schema are now generated from one field table, so the shape
|
|
464
|
+
// they agree on is guaranteed at run time. TypeScript cannot see that agreement — it
|
|
465
|
+
// infers the output type from the schema object and compares it against the provider's
|
|
466
|
+
// interface, which describes the provider's internal shape rather than the contract
|
|
467
|
+
// (`value` nullable internally, absent on the wire; `selector` required by the
|
|
468
|
+
// interface, optional in the inferred schema). The assertion bridges that last gap; the
|
|
469
|
+
// test that walks every registered tool is what checks the agreement for real.
|
|
470
|
+
nodes: result.nodes.map(n => projectNode(n)),
|
|
511
471
|
};
|
|
512
472
|
},
|
|
513
473
|
}));
|
|
@@ -1266,7 +1226,7 @@ export function apply(ctx, config = {}) {
|
|
|
1266
1226
|
path: { type: 'string', description: 'The file path the screenshot was saved to, when savePath was given.' },
|
|
1267
1227
|
},
|
|
1268
1228
|
},
|
|
1269
|
-
render: (_args, value) => [{ type: 'text', text: `Screenshot captured (${Math.round(value.dataUrl.length * 3 / 4 / 1024)} KiB)${value.path !== undefined ? ` saved to ${value.path}` : ''}.` }],
|
|
1229
|
+
render: (_args, value) => [{ type: 'text', text: `Screenshot captured (${Math.round(value.dataUrl.length * 3 / 4 / 1024)} KiB${value.width !== undefined && value.height !== undefined ? `, ${value.width}x${value.height}` : ''})${value.path !== undefined ? ` saved to ${value.path}` : ''}.` }],
|
|
1270
1230
|
},
|
|
1271
1231
|
timeoutMs,
|
|
1272
1232
|
isConcurrencySafe: () => true,
|
|
@@ -1286,6 +1246,12 @@ export function apply(ctx, config = {}) {
|
|
|
1286
1246
|
return {
|
|
1287
1247
|
dataUrl: shot.dataUrl,
|
|
1288
1248
|
...shot.path !== undefined ? { path: shot.path } : {},
|
|
1249
|
+
// The provider measures these when it scales, the schema declares them, and the seam
|
|
1250
|
+
// type carries them — but this mapping is an allow-list, so leaving them out dropped
|
|
1251
|
+
// them silently. Same shape of bug as the a11y selector, found the same way: by
|
|
1252
|
+
// reading the three places that mention a field and noticing this one did not.
|
|
1253
|
+
...shot.width !== undefined ? { width: shot.width } : {},
|
|
1254
|
+
...shot.height !== undefined ? { height: shot.height } : {},
|
|
1289
1255
|
};
|
|
1290
1256
|
},
|
|
1291
1257
|
}));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-builtin-browser",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.6",
|
|
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",
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
"cordis.patch.yml",
|
|
41
41
|
"README.md",
|
|
42
42
|
"README.en.md",
|
|
43
|
+
"CHANGELOG.md",
|
|
43
44
|
"LICENSE",
|
|
44
45
|
"scripts"
|
|
45
46
|
],
|
|
@@ -74,7 +75,10 @@
|
|
|
74
75
|
"@deepseek-ai/dsh-llm": ">=0.1.1-rc.2 <0.3.0",
|
|
75
76
|
"@deepseek-ai/dsh-tools": ">=0.1.1-rc.2 <0.3.0",
|
|
76
77
|
"@deepseek-ai/dsh-system-prompt": ">=0.1.1-rc.2 <0.3.0",
|
|
77
|
-
"@deepseek-ai/schemastery": "^3.18.1"
|
|
78
|
+
"@deepseek-ai/schemastery": "^3.18.1",
|
|
79
|
+
"@deepseek-ai/dsh-client-locale": ">=0.1.1-rc.2 <0.3.0",
|
|
80
|
+
"@deepseek-ai/dsh-client-ui-settings": ">=0.1.1-rc.2 <0.3.0",
|
|
81
|
+
"@deepseek-ai/dsh-client-ui-slots": ">=0.1.1-rc.2 <0.3.0"
|
|
78
82
|
},
|
|
79
83
|
"devDependencies": {
|
|
80
84
|
"@types/node": "^22.0.0",
|
package/src/browser/types.ts
CHANGED
|
@@ -396,8 +396,15 @@ export interface BrowserA11yNode {
|
|
|
396
396
|
readonly role: string
|
|
397
397
|
/** Accessible name (aria-label, label text, placeholder, own text, …). */
|
|
398
398
|
readonly name: string
|
|
399
|
-
/**
|
|
400
|
-
|
|
399
|
+
/**
|
|
400
|
+
* Current value of input-like nodes; absent when the node has none.
|
|
401
|
+
*
|
|
402
|
+
* The provider produces `null` for "not applicable" and the tool layer drops it, so absence
|
|
403
|
+
* and null mean the same thing here — which is what the output schema declares (`string`,
|
|
404
|
+
* not nullable). Saying `string | null` described the provider's internal shape rather than
|
|
405
|
+
* the contract, and the two disagreed at every call.
|
|
406
|
+
*/
|
|
407
|
+
readonly value?: string
|
|
401
408
|
/** ARIA/DOM states: enabled/disabled/checked/unchecked/expanded/… */
|
|
402
409
|
readonly states: readonly string[]
|
|
403
410
|
/** DOM depth (within its document), for reconstructing the tree. */
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
1
2
|
/**
|
|
2
3
|
* Transport for the desktop shell's browser bridge: one long-lived connection to a
|
|
3
4
|
* loopback TCP service.
|
|
@@ -80,8 +81,41 @@ export class BridgeConnection {
|
|
|
80
81
|
|
|
81
82
|
/**
|
|
82
83
|
* @param endpoint - the shell's published bridge endpoint.
|
|
84
|
+
* @param endpointPath - where that endpoint is republished, so a moved port can be followed.
|
|
83
85
|
*/
|
|
84
|
-
constructor(private
|
|
86
|
+
constructor(private endpoint: BridgeEndpoint, private readonly endpointPath?: string) {}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Re-read the published endpoint, and adopt it if the shell has moved.
|
|
90
|
+
*
|
|
91
|
+
* The bridge binds an ephemeral port and picks a NEW one whenever its server is recreated. The
|
|
92
|
+
* endpoint file is rewritten every fifteen seconds, but a host that adopted the old one keeps
|
|
93
|
+
* dialling a dead port forever: measured here as `connect ECONNREFUSED 127.0.0.1:52276` while
|
|
94
|
+
* the live bridge sat on 52411. Discarding the socket is not enough — the comment below says a
|
|
95
|
+
* dead connection must never become a dead plugin, and a stale PORT makes exactly that happen.
|
|
96
|
+
*
|
|
97
|
+
* Cheap and safe: one small file read per call, no socket work.
|
|
98
|
+
*
|
|
99
|
+
* @returns true when a different endpoint was adopted.
|
|
100
|
+
*/
|
|
101
|
+
private refreshEndpoint(): boolean {
|
|
102
|
+
if (this.endpointPath === undefined) return false
|
|
103
|
+
try {
|
|
104
|
+
const raw = JSON.parse(readFileSync(this.endpointPath, 'utf8')) as Partial<BridgeEndpoint>
|
|
105
|
+
if (typeof raw.port !== 'number' || typeof raw.token !== 'string') return false
|
|
106
|
+
if (raw.port === this.endpoint.port && raw.token === this.endpoint.token) return false
|
|
107
|
+
this.endpoint = {
|
|
108
|
+
port: raw.port,
|
|
109
|
+
token: raw.token,
|
|
110
|
+
pid: typeof raw.pid === 'number' ? raw.pid : this.endpoint.pid,
|
|
111
|
+
}
|
|
112
|
+
// The old socket belongs to the old port.
|
|
113
|
+
this.reset(new Error('dsh-builtin-browser: the bridge moved; reconnecting to the new port'))
|
|
114
|
+
return true
|
|
115
|
+
} catch {
|
|
116
|
+
return false
|
|
117
|
+
}
|
|
118
|
+
}
|
|
85
119
|
|
|
86
120
|
/**
|
|
87
121
|
* Send one request, reusing the socket if it is still healthy.
|
|
@@ -102,6 +136,8 @@ export class BridgeConnection {
|
|
|
102
136
|
// straight-line re-dial rather than a retry loop: an earlier revision re-entered call()
|
|
103
137
|
// from inside its own promise executor, which left an orphaned pending entry behind and,
|
|
104
138
|
// when the condition could not clear, spun until the process ran out of heap.
|
|
139
|
+
// Follow the shell if its bridge moved to a new port (see refreshEndpoint).
|
|
140
|
+
this.refreshEndpoint()
|
|
105
141
|
let socket = this.ensureSocket()
|
|
106
142
|
if (this.serialisable && this.queue.length > 0) {
|
|
107
143
|
this.reset(new Error('dsh-builtin-browser: bridge connection reopened so replies stay attributable'))
|
|
@@ -45,7 +45,9 @@ export function bridgeEndpointPath(): string {
|
|
|
45
45
|
*/
|
|
46
46
|
function isGuestGone(error: unknown): boolean {
|
|
47
47
|
const message = error instanceof Error ? error.message : String(error)
|
|
48
|
-
|
|
48
|
+
// The panel path reports its own failure when the renderer never produced a page, and that is the
|
|
49
|
+
// same class as a vanished guest — worth one retry on a fresh panel — so it is listed here too.
|
|
50
|
+
return /guest \d+ is not available|sidebar unavailable|produced no page/i.test(message)
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
/**
|
|
@@ -58,16 +60,53 @@ function isGuestGone(error: unknown): boolean {
|
|
|
58
60
|
*/
|
|
59
61
|
export class DesktopBridgeViewHost implements ElectronBrowserViewHost {
|
|
60
62
|
private readonly connection: BridgeConnection
|
|
61
|
-
private readonly views = new Map<string, number>()
|
|
62
63
|
/**
|
|
63
|
-
*
|
|
64
|
+
* One entry per view this host handed out: the conversation it serves, and the guest backing it.
|
|
64
65
|
*
|
|
65
|
-
* The
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
66
|
+
* The conversation belongs to the ENTRY, not to this host. A single plugin process serves every
|
|
67
|
+
* conversation — DSH runs them all through one host instance — so an owner held on the host
|
|
68
|
+
* would be one conversation's id applied to all of them, and a page could still land in
|
|
69
|
+
* another's sidebar however carefully the bridge checked it.
|
|
69
70
|
*/
|
|
70
|
-
private readonly
|
|
71
|
+
private readonly views = new Map<string, { guest?: number; owner: string }>()
|
|
72
|
+
/**
|
|
73
|
+
* This process's identity with the bridge, for tab ownership.
|
|
74
|
+
*
|
|
75
|
+
* Every DSH session is its own plugin process, and the sidebar is one surface they all share.
|
|
76
|
+
* A session can only see its own `views` map, so before the bridge kept a ledger it adopted
|
|
77
|
+
* whichever tab it found — including one another session had opened, which is how a URL from
|
|
78
|
+
* one session appeared in another's sidebar. The id is per process, so two sessions never
|
|
79
|
+
* collide and one session's tabs are never handed to another; it is deliberately not the DSH
|
|
80
|
+
* session id, which this layer has no access to and does not need.
|
|
81
|
+
*
|
|
82
|
+
* The whole ledger rests on that premise, so it was measured rather than assumed: with two
|
|
83
|
+
* sessions running, the desktop had TWO host processes (plus one main, two renderers and a
|
|
84
|
+
* gpu/utility pair). If sessions ever shared a process, every owner check in the bridge would
|
|
85
|
+
* compare equal and the bleed would return in a new shape — so a change to how DSH spawns
|
|
86
|
+
* sessions invalidates this file, not just this comment.
|
|
87
|
+
*
|
|
88
|
+
* It is now the CONVERSATION id rather than an invented uuid, read from the plugin's ctx by the
|
|
89
|
+
* same path DSH's own desktop host uses. That matters because the shell keeps one sidebar per
|
|
90
|
+
* conversation and writes its session id onto each sidebar container (on the React fiber,
|
|
91
|
+
* measured on the running app) — so the bridge can only answer "is this sidebar mine" if it has
|
|
92
|
+
* a comparable id. With an invented uuid it never could, which is how a command came to be typed
|
|
93
|
+
* into another conversation's address bar.
|
|
94
|
+
*
|
|
95
|
+
* Falls back to a random uuid when the host cannot supply one — an older DSH, or a non-desktop
|
|
96
|
+
* composition. That is no worse than before: the bridge then knows only "this process", and it
|
|
97
|
+
* refuses to operate on a sidebar it cannot prove is its own.
|
|
98
|
+
*/
|
|
99
|
+
private readonly owner: string
|
|
100
|
+
/**
|
|
101
|
+
* Guests whose view is gone but whose page may still be open in the sidebar, with the
|
|
102
|
+
* conversation each belongs to.
|
|
103
|
+
*
|
|
104
|
+
* The provider destroys its view handles before it asks for a release, so the mapping is dropped
|
|
105
|
+
* by then — keeping the guest ids here is what lets us close only our own tabs when several
|
|
106
|
+
* conversations are running (requirements §4: each session gets its own page). The owner rides
|
|
107
|
+
* along for the same reason it does in {@link views}: a release names one conversation.
|
|
108
|
+
*/
|
|
109
|
+
private readonly orphaned = new Map<number, string>()
|
|
71
110
|
/**
|
|
72
111
|
* Registered by the provider, and deliberately never invoked on this carrier.
|
|
73
112
|
*
|
|
@@ -83,8 +122,9 @@ export class DesktopBridgeViewHost implements ElectronBrowserViewHost {
|
|
|
83
122
|
/**
|
|
84
123
|
* @param endpoint - the shell's published bridge endpoint.
|
|
85
124
|
*/
|
|
86
|
-
private constructor(endpoint: BridgeEndpoint) {
|
|
87
|
-
this.
|
|
125
|
+
private constructor(endpoint: BridgeEndpoint, sessionId?: string) {
|
|
126
|
+
this.owner = sessionId !== undefined && sessionId !== String.fromCharCode(39,39) ? sessionId : randomUUID()
|
|
127
|
+
this.connection = new BridgeConnection(endpoint, bridgeEndpointPath())
|
|
88
128
|
}
|
|
89
129
|
|
|
90
130
|
/**
|
|
@@ -95,7 +135,7 @@ export class DesktopBridgeViewHost implements ElectronBrowserViewHost {
|
|
|
95
135
|
* caller falls back to self-hosting, which always works.
|
|
96
136
|
* @returns the host, or undefined when no bridge is available.
|
|
97
137
|
*/
|
|
98
|
-
static async discover(): Promise<DesktopBridgeViewHost | undefined> {
|
|
138
|
+
static async discover(sessionId?: string): Promise<DesktopBridgeViewHost | undefined> {
|
|
99
139
|
let endpoint: BridgeEndpoint
|
|
100
140
|
try {
|
|
101
141
|
const raw = JSON.parse(readFileSync(bridgeEndpointPath(), 'utf8')) as Partial<BridgeEndpoint>
|
|
@@ -107,10 +147,10 @@ export class DesktopBridgeViewHost implements ElectronBrowserViewHost {
|
|
|
107
147
|
try {
|
|
108
148
|
// A throwaway connection for discovery: the adopted host opens its own, and a
|
|
109
149
|
// rejected endpoint must not leave a socket behind.
|
|
110
|
-
const probe = new BridgeConnection(endpoint)
|
|
150
|
+
const probe = new BridgeConnection(endpoint, bridgeEndpointPath())
|
|
111
151
|
try {
|
|
112
152
|
const answer = await probe.call({ op: 'list' }, 5_000)
|
|
113
|
-
return answer.ok === true ? new DesktopBridgeViewHost(endpoint) : undefined
|
|
153
|
+
return answer.ok === true ? new DesktopBridgeViewHost(endpoint, sessionId) : undefined
|
|
114
154
|
} finally {
|
|
115
155
|
probe.close()
|
|
116
156
|
}
|
|
@@ -126,61 +166,58 @@ export class DesktopBridgeViewHost implements ElectronBrowserViewHost {
|
|
|
126
166
|
}
|
|
127
167
|
|
|
128
168
|
/**
|
|
129
|
-
* The guest
|
|
169
|
+
* The guest backing a view, materialized on first use.
|
|
130
170
|
*
|
|
131
|
-
* The
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
* every existing guest is already spoken for.
|
|
171
|
+
* The conversation that called owns exactly one sidebar page on this carrier — a human and the
|
|
172
|
+
* agent look at the same page — so every view resolves to that conversation's own guest, and the
|
|
173
|
+
* page does not multiply behind the provider's own tab bookkeeping.
|
|
174
|
+
*
|
|
175
|
+
* A cached guest is used as-is: probing it first cost a round-trip on every command, so liveness
|
|
176
|
+
* is established by the command failing instead.
|
|
138
177
|
* @param viewId - the view whose guest is wanted.
|
|
139
|
-
* @param url - address to use when
|
|
178
|
+
* @param url - address to use when this conversation has no page yet.
|
|
140
179
|
*/
|
|
180
|
+
|
|
141
181
|
private async guestFor(viewId: string, url?: string): Promise<number> {
|
|
142
|
-
const
|
|
182
|
+
const entry = this.views.get(viewId)
|
|
143
183
|
// No liveness check here: verifying the cached guest cost a round-trip on every
|
|
144
184
|
// command. A guest that has gone away is detected by the command itself failing,
|
|
145
185
|
// and `createView`'s sendCommand then discards the cache and calls back in here.
|
|
146
|
-
if (
|
|
186
|
+
if (entry?.guest !== undefined) return entry.guest
|
|
147
187
|
|
|
148
|
-
// The
|
|
149
|
-
//
|
|
150
|
-
|
|
188
|
+
// The conversation THIS VIEW serves — read from the view, never from the process.
|
|
189
|
+
//
|
|
190
|
+
// The difference is the whole bug. This host is one instance shared by every conversation, so
|
|
191
|
+
// `this.owner` alone can only be "whichever conversation this process happens to name", and
|
|
192
|
+
// naming the wrong one is exactly how a page requested in one conversation appeared in
|
|
193
|
+
// another. The view was created for a conversation (`createView(owner)`), so that is what it
|
|
194
|
+
// asks for its page.
|
|
195
|
+
//
|
|
196
|
+
// Nothing here asks which conversation is on screen: `ensureSidebar` reaches this
|
|
197
|
+
// conversation's own panel by id through the renderer, rather than by pressing the shell's
|
|
198
|
+
// shortcut or clicking its card, both of which the shell delivers to whatever it displays.
|
|
199
|
+
const owner = entry?.owner ?? this.owner
|
|
200
|
+
const answer = await this.connection.call({
|
|
151
201
|
op: 'ensureSidebar',
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
let free = ids.find(id => !taken.has(id))
|
|
160
|
-
if (free === undefined) {
|
|
161
|
-
// Every tab is already driving something: the strip has to grow.
|
|
162
|
-
ids = await this.guestIds(ids.length + 1)
|
|
163
|
-
free = ids.find(id => !taken.has(id))
|
|
202
|
+
owner,
|
|
203
|
+
...url !== undefined && url !== '' ? { url } : {},
|
|
204
|
+
}, 30_000)
|
|
205
|
+
if (answer.ok !== true) throw new Error(`dsh-builtin-browser: sidebar unavailable (${String(answer.error)})`)
|
|
206
|
+
const id = Number(answer.id)
|
|
207
|
+
if (!Number.isFinite(id) || id <= 0) {
|
|
208
|
+
throw new Error('dsh-builtin-browser: the sidebar reported no browser page for this conversation')
|
|
164
209
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
return free
|
|
210
|
+
this.views.set(viewId, { guest: id, owner })
|
|
211
|
+
return id
|
|
168
212
|
}
|
|
169
213
|
|
|
170
214
|
/**
|
|
171
|
-
*
|
|
172
|
-
*
|
|
215
|
+
* @param owner - the conversation this view serves. Recorded WITH the view: one host instance
|
|
216
|
+
* serves every conversation, so the owner cannot live on the host.
|
|
173
217
|
*/
|
|
174
|
-
|
|
175
|
-
const answer = await this.connection.call({ op: 'ensureTabs', count })
|
|
176
|
-
if (answer.ok !== true) throw new Error(`dsh-builtin-browser: could not open a sidebar tab (${String(answer.error)})`)
|
|
177
|
-
const ids = Array.isArray(answer.ids) ? answer.ids.map(Number).filter(Number.isFinite) : []
|
|
178
|
-
if (ids.length === 0) throw new Error('dsh-builtin-browser: the sidebar reported no browser tabs')
|
|
179
|
-
return ids
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
createView(): ElectronViewHandle {
|
|
218
|
+
createView(owner?: string): ElectronViewHandle {
|
|
183
219
|
const viewId = randomUUID()
|
|
220
|
+
this.views.set(viewId, { owner: owner !== undefined && owner !== '' ? owner : this.owner })
|
|
184
221
|
const navigateUrl = (method: string, params?: Record<string, unknown>): string | undefined =>
|
|
185
222
|
method === 'Page.navigate' ? String((params as { url?: unknown })?.url ?? '') : undefined
|
|
186
223
|
const run = async (guest: number, method: string, params?: Record<string, unknown>): Promise<Record<string, unknown>> => {
|
|
@@ -205,7 +242,10 @@ export class DesktopBridgeViewHost implements ElectronBrowserViewHost {
|
|
|
205
242
|
// The human closed the tab: the page is gone, so take a fresh one and
|
|
206
243
|
// replay the command once. This is the "closing the interface ends the
|
|
207
244
|
// session" rule, now paid for only when it actually happens.
|
|
208
|
-
|
|
245
|
+
// Drop the guest but keep the entry: the conversation it serves is what the retry needs,
|
|
246
|
+
// and removing the entry would lose it back to the process-wide fallback owner.
|
|
247
|
+
const lost = this.views.get(viewId)
|
|
248
|
+
if (lost !== undefined) delete lost.guest
|
|
209
249
|
guest = await this.guestFor(viewId, url)
|
|
210
250
|
return await run(guest, method, params)
|
|
211
251
|
}
|
|
@@ -214,41 +254,44 @@ export class DesktopBridgeViewHost implements ElectronBrowserViewHost {
|
|
|
214
254
|
}
|
|
215
255
|
|
|
216
256
|
destroyView(handle: ElectronViewHandle): void {
|
|
217
|
-
const
|
|
218
|
-
if (
|
|
219
|
-
// Remember
|
|
220
|
-
//
|
|
221
|
-
|
|
257
|
+
const entry = this.views.get(handle.id)
|
|
258
|
+
if (entry !== undefined) {
|
|
259
|
+
// Remember the page: it outlives our handle, and a later release must be able to name
|
|
260
|
+
// exactly the tabs this conversation's views opened. The owner comes along so a release
|
|
261
|
+
// naming one conversation does not sweep up another's.
|
|
262
|
+
if (entry.guest !== undefined) this.orphaned.set(entry.guest, entry.owner)
|
|
222
263
|
this.views.delete(handle.id)
|
|
223
264
|
}
|
|
224
265
|
}
|
|
225
266
|
|
|
226
267
|
/**
|
|
227
|
-
*
|
|
228
|
-
*
|
|
268
|
+
* Bring this view's tab to the front in the sidebar.
|
|
269
|
+
*
|
|
270
|
+
* This used to be empty, on the reasoning that the sidebar is already on screen so there is
|
|
271
|
+
* nothing to show. That confused "the sidebar is visible" with "this page is the one being
|
|
272
|
+
* looked at": opening a page left it behind whatever the human had in front, which is issue
|
|
273
|
+
* #23. The other two carriers both do this — the self-hosted window re-adds the view and the
|
|
274
|
+
* system browser calls `Page.bringToFront` — so this carrier was the odd one out.
|
|
275
|
+
*
|
|
276
|
+
* Best-effort by design: the tab strip belongs to the shell's renderer, and failing to raise
|
|
277
|
+
* it must not fail the navigation that has already happened.
|
|
278
|
+
* @param handle - the view to bring forward.
|
|
229
279
|
*/
|
|
230
|
-
showView(): void {
|
|
280
|
+
showView(handle: ElectronViewHandle): void {
|
|
281
|
+
// `owner` is required: the bridge refuses to bring forward a guest held by another session.
|
|
282
|
+
// It comes from the view, not the process — with several conversations live, a process-wide
|
|
283
|
+
// owner would raise the wrong conversation's tab.
|
|
284
|
+
const owner = this.views.get(handle.id)?.owner ?? this.owner
|
|
285
|
+
void this.connection.call({ op: 'showTab', viewId: Number(handle.id), owner }, 5_000).catch(() => undefined)
|
|
286
|
+
}
|
|
231
287
|
|
|
232
|
-
/** Same as {@link showView}: the page is already presented by the shell. */
|
|
233
|
-
async presentView(): Promise<void> {}
|
|
234
288
|
|
|
235
|
-
/**
|
|
236
|
-
* The shell owns window grouping, and one sidebar serves every view, so there
|
|
237
|
-
* is nothing to record here. (It must not write a placeholder guest id either:
|
|
238
|
-
* a view whose guest is not yet materialized has to stay absent from the map,
|
|
239
|
-
* or the first real command would be sent to the placeholder.)
|
|
240
|
-
*/
|
|
241
|
-
groupView(): void {}
|
|
242
289
|
|
|
243
|
-
/** Focus is the shell's to manage, and there is no separate window to raise. */
|
|
244
|
-
async focus(): Promise<void> {}
|
|
245
290
|
|
|
246
291
|
onUserAction(handler: (action: BrowserUserAction) => void): void {
|
|
247
292
|
this.userActionHandler = handler
|
|
248
293
|
}
|
|
249
294
|
|
|
250
|
-
/** The shell reports its own window lifecycle; nothing to subscribe to here. */
|
|
251
|
-
onViewClosed(): void {}
|
|
252
295
|
|
|
253
296
|
/**
|
|
254
297
|
* Release the sidebar's browser pages (requirements §3, `ui.closeWithSession`).
|
|
@@ -257,21 +300,38 @@ export class DesktopBridgeViewHost implements ElectronBrowserViewHost {
|
|
|
257
300
|
* belongs to the shell and would happily keep the page (and its renderer) alive.
|
|
258
301
|
* Closing the tabs is what actually ends the page — and only the page: cookies
|
|
259
302
|
* live in the partition, history on disk, so both survive.
|
|
303
|
+
* @param owner - the conversation whose pages to close. Omit to close every conversation's,
|
|
304
|
+
* which is only correct from a teardown that is itself global.
|
|
260
305
|
* @returns a promise that settles once the shell has been asked.
|
|
261
306
|
*/
|
|
262
|
-
async releasePage(): Promise<void> {
|
|
263
|
-
// Only
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
const mine = [
|
|
267
|
-
this.views
|
|
268
|
-
|
|
307
|
+
async releasePage(owner?: string): Promise<void> {
|
|
308
|
+
// Only this conversation's tabs. The host is ONE instance shared by every conversation, so a
|
|
309
|
+
// release that named none would close pages other conversations are still driving — "every
|
|
310
|
+
// webview currently visible" is precisely what must never be used here.
|
|
311
|
+
const mine: number[] = []
|
|
312
|
+
for (const [viewId, entry] of [...this.views]) {
|
|
313
|
+
if (owner !== undefined && entry.owner !== owner) continue
|
|
314
|
+
if (entry.guest !== undefined) mine.push(entry.guest)
|
|
315
|
+
this.views.delete(viewId)
|
|
316
|
+
}
|
|
317
|
+
// A view destroyed before the release still owns its page; its guest is collected here.
|
|
318
|
+
for (const [guest, holder] of [...this.orphaned]) {
|
|
319
|
+
if (owner !== undefined && holder !== owner) continue
|
|
320
|
+
mine.push(guest)
|
|
321
|
+
this.orphaned.delete(guest)
|
|
322
|
+
}
|
|
269
323
|
// Nothing of ours is open: release nothing. Falling through to an unfiltered
|
|
270
324
|
// release here would close tabs a human opened, or another session's page.
|
|
271
325
|
if (mine.length === 0) return
|
|
272
326
|
let titles: string[]
|
|
273
327
|
try {
|
|
274
|
-
|
|
328
|
+
// `owner` is REQUIRED here now that the bridge filters `list` by it. Without it the bridge
|
|
329
|
+
// falls back to a different owner, so the guests this session holds are filtered out of the
|
|
330
|
+
// answer, `mine.includes(...)` matches nothing, the title list comes back empty and this
|
|
331
|
+
// returns without closing anything — the page stayed open and the human had to close it by
|
|
332
|
+
// hand. Adding the ledger to the bridge without updating this call site is exactly the kind
|
|
333
|
+
// of half-change that made the ownership work take four rounds.
|
|
334
|
+
const answer = await this.connection.call({ op: 'list', owner: owner ?? this.owner }, 5_000)
|
|
275
335
|
const sidebar = Array.isArray(answer.sidebar) ? answer.sidebar as Array<{ id?: unknown; title?: unknown }> : []
|
|
276
336
|
titles = sidebar
|
|
277
337
|
.filter(guest => mine.includes(Number(guest.id)))
|
|
@@ -284,7 +344,9 @@ export class DesktopBridgeViewHost implements ElectronBrowserViewHost {
|
|
|
284
344
|
}
|
|
285
345
|
if (titles.length === 0) return
|
|
286
346
|
try {
|
|
287
|
-
|
|
347
|
+
// `owner` is required for the same reason as above: the bridge closes only the guests the
|
|
348
|
+
// caller holds, so an owner-less call closes nothing and the page stays open.
|
|
349
|
+
await this.connection.call({ op: 'closeSidebarBrowser', titles, owner: owner ?? this.owner }, 10_000)
|
|
288
350
|
} catch {
|
|
289
351
|
// Best effort: the setting expresses a preference, and a shell that cannot
|
|
290
352
|
// be reached is no reason to fail the session teardown that called us.
|