dsh-builtin-browser 0.4.1 → 0.4.5
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 +1699 -0
- package/README.en.md +114 -33
- package/README.md +108 -32
- package/client.js +478 -29
- package/cordis.patch.yml +21 -0
- package/desktop-bridge/install.mjs +66 -6
- package/desktop-bridge/plugin-browser-bridge.js +426 -167
- package/docs/architecture.md +14 -4
- package/docs/tool-reference.md +13 -13
- package/docs/user-guide.md +58 -3
- package/docs/why-browser.md +1 -1
- package/lib/browser/types.d.ts +34 -3
- package/lib/browser-electron/bridge-connection.d.ts +49 -26
- package/lib/browser-electron/bridge-connection.js +185 -22
- 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 +1 -1
- package/lib/browser-electron/entry.js +133 -40
- package/lib/browser-electron/ephemeral-profile.d.ts +37 -0
- package/lib/browser-electron/ephemeral-profile.js +146 -0
- package/lib/browser-electron/history-store.d.ts +36 -2
- package/lib/browser-electron/history-store.js +76 -7
- package/lib/browser-electron/host-main.js +86 -28
- package/lib/browser-electron/provider.d.ts +106 -3
- package/lib/browser-electron/provider.js +407 -60
- package/lib/browser-electron/remote-host.d.ts +52 -3
- package/lib/browser-electron/remote-host.js +259 -25
- 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 +28 -0
- package/lib/browser-electron/settings-route.js +68 -0
- package/lib/browser-electron/settings-store.d.ts +40 -0
- package/lib/browser-electron/settings-store.js +160 -23
- package/lib/browser-electron/system-browser.d.ts +102 -12
- package/lib/browser-electron/system-browser.js +411 -51
- 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 +149 -74
- package/package.json +10 -4
- package/scripts/verify-host-compat.mjs +195 -0
- package/src/browser/types.ts +34 -3
- package/src/browser-electron/bridge-connection.ts +179 -22
- package/src/browser-electron/desktop-bridge-host.ts +146 -84
- package/src/browser-electron/entry.ts +132 -39
- package/src/browser-electron/ephemeral-profile.ts +152 -0
- package/src/browser-electron/history-store.ts +81 -9
- package/src/browser-electron/host-main.ts +115 -36
- package/src/browser-electron/provider.ts +492 -69
- package/src/browser-electron/remote-host.ts +276 -38
- package/src/browser-electron/rpc-ops.ts +41 -0
- package/src/browser-electron/settings-route.ts +87 -0
- package/src/browser-electron/settings-store.ts +173 -24
- package/src/browser-electron/system-browser.ts +408 -39
- package/src/index.ts +3 -2
- package/src/tool-browser/element-fields.ts +167 -0
- package/src/tool-browser/index.ts +155 -76
|
@@ -39,7 +39,9 @@ export function bridgeEndpointPath() {
|
|
|
39
39
|
*/
|
|
40
40
|
function isGuestGone(error) {
|
|
41
41
|
const message = error instanceof Error ? error.message : String(error);
|
|
42
|
-
|
|
42
|
+
// The panel path reports its own failure when the renderer never produced a page, and that is the
|
|
43
|
+
// same class as a vanished guest — worth one retry on a fresh panel — so it is listed here too.
|
|
44
|
+
return /guest \d+ is not available|sidebar unavailable|produced no page/i.test(message);
|
|
43
45
|
}
|
|
44
46
|
/**
|
|
45
47
|
* The desktop sidebar, presented as a browser view host.
|
|
@@ -51,16 +53,53 @@ function isGuestGone(error) {
|
|
|
51
53
|
*/
|
|
52
54
|
export class DesktopBridgeViewHost {
|
|
53
55
|
connection;
|
|
56
|
+
/**
|
|
57
|
+
* One entry per view this host handed out: the conversation it serves, and the guest backing it.
|
|
58
|
+
*
|
|
59
|
+
* The conversation belongs to the ENTRY, not to this host. A single plugin process serves every
|
|
60
|
+
* conversation — DSH runs them all through one host instance — so an owner held on the host
|
|
61
|
+
* would be one conversation's id applied to all of them, and a page could still land in
|
|
62
|
+
* another's sidebar however carefully the bridge checked it.
|
|
63
|
+
*/
|
|
54
64
|
views = new Map();
|
|
55
65
|
/**
|
|
56
|
-
*
|
|
66
|
+
* This process's identity with the bridge, for tab ownership.
|
|
67
|
+
*
|
|
68
|
+
* Every DSH session is its own plugin process, and the sidebar is one surface they all share.
|
|
69
|
+
* A session can only see its own `views` map, so before the bridge kept a ledger it adopted
|
|
70
|
+
* whichever tab it found — including one another session had opened, which is how a URL from
|
|
71
|
+
* one session appeared in another's sidebar. The id is per process, so two sessions never
|
|
72
|
+
* collide and one session's tabs are never handed to another; it is deliberately not the DSH
|
|
73
|
+
* session id, which this layer has no access to and does not need.
|
|
74
|
+
*
|
|
75
|
+
* The whole ledger rests on that premise, so it was measured rather than assumed: with two
|
|
76
|
+
* sessions running, the desktop had TWO host processes (plus one main, two renderers and a
|
|
77
|
+
* gpu/utility pair). If sessions ever shared a process, every owner check in the bridge would
|
|
78
|
+
* compare equal and the bleed would return in a new shape — so a change to how DSH spawns
|
|
79
|
+
* sessions invalidates this file, not just this comment.
|
|
80
|
+
*
|
|
81
|
+
* It is now the CONVERSATION id rather than an invented uuid, read from the plugin's ctx by the
|
|
82
|
+
* same path DSH's own desktop host uses. That matters because the shell keeps one sidebar per
|
|
83
|
+
* conversation and writes its session id onto each sidebar container (on the React fiber,
|
|
84
|
+
* measured on the running app) — so the bridge can only answer "is this sidebar mine" if it has
|
|
85
|
+
* a comparable id. With an invented uuid it never could, which is how a command came to be typed
|
|
86
|
+
* into another conversation's address bar.
|
|
57
87
|
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
* session gets its own page).
|
|
88
|
+
* Falls back to a random uuid when the host cannot supply one — an older DSH, or a non-desktop
|
|
89
|
+
* composition. That is no worse than before: the bridge then knows only "this process", and it
|
|
90
|
+
* refuses to operate on a sidebar it cannot prove is its own.
|
|
62
91
|
*/
|
|
63
|
-
|
|
92
|
+
owner;
|
|
93
|
+
/**
|
|
94
|
+
* Guests whose view is gone but whose page may still be open in the sidebar, with the
|
|
95
|
+
* conversation each belongs to.
|
|
96
|
+
*
|
|
97
|
+
* The provider destroys its view handles before it asks for a release, so the mapping is dropped
|
|
98
|
+
* by then — keeping the guest ids here is what lets us close only our own tabs when several
|
|
99
|
+
* conversations are running (requirements §4: each session gets its own page). The owner rides
|
|
100
|
+
* along for the same reason it does in {@link views}: a release names one conversation.
|
|
101
|
+
*/
|
|
102
|
+
orphaned = new Map();
|
|
64
103
|
/**
|
|
65
104
|
* Registered by the provider, and deliberately never invoked on this carrier.
|
|
66
105
|
*
|
|
@@ -75,8 +114,9 @@ export class DesktopBridgeViewHost {
|
|
|
75
114
|
/**
|
|
76
115
|
* @param endpoint - the shell's published bridge endpoint.
|
|
77
116
|
*/
|
|
78
|
-
constructor(endpoint) {
|
|
79
|
-
this.
|
|
117
|
+
constructor(endpoint, sessionId) {
|
|
118
|
+
this.owner = sessionId !== undefined && sessionId !== String.fromCharCode(39, 39) ? sessionId : randomUUID();
|
|
119
|
+
this.connection = new BridgeConnection(endpoint, bridgeEndpointPath());
|
|
80
120
|
}
|
|
81
121
|
/**
|
|
82
122
|
* Find a usable desktop bridge, if this surface has one.
|
|
@@ -86,7 +126,7 @@ export class DesktopBridgeViewHost {
|
|
|
86
126
|
* caller falls back to self-hosting, which always works.
|
|
87
127
|
* @returns the host, or undefined when no bridge is available.
|
|
88
128
|
*/
|
|
89
|
-
static async discover() {
|
|
129
|
+
static async discover(sessionId) {
|
|
90
130
|
let endpoint;
|
|
91
131
|
try {
|
|
92
132
|
const raw = JSON.parse(readFileSync(bridgeEndpointPath(), 'utf8'));
|
|
@@ -100,10 +140,10 @@ export class DesktopBridgeViewHost {
|
|
|
100
140
|
try {
|
|
101
141
|
// A throwaway connection for discovery: the adopted host opens its own, and a
|
|
102
142
|
// rejected endpoint must not leave a socket behind.
|
|
103
|
-
const probe = new BridgeConnection(endpoint);
|
|
143
|
+
const probe = new BridgeConnection(endpoint, bridgeEndpointPath());
|
|
104
144
|
try {
|
|
105
145
|
const answer = await probe.call({ op: 'list' }, 5_000);
|
|
106
|
-
return answer.ok === true ? new DesktopBridgeViewHost(endpoint) : undefined;
|
|
146
|
+
return answer.ok === true ? new DesktopBridgeViewHost(endpoint, sessionId) : undefined;
|
|
107
147
|
}
|
|
108
148
|
finally {
|
|
109
149
|
probe.close();
|
|
@@ -119,62 +159,57 @@ export class DesktopBridgeViewHost {
|
|
|
119
159
|
return true;
|
|
120
160
|
}
|
|
121
161
|
/**
|
|
122
|
-
* The guest
|
|
162
|
+
* The guest backing a view, materialized on first use.
|
|
123
163
|
*
|
|
124
|
-
* The
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
* every existing guest is already spoken for.
|
|
164
|
+
* The conversation that called owns exactly one sidebar page on this carrier — a human and the
|
|
165
|
+
* agent look at the same page — so every view resolves to that conversation's own guest, and the
|
|
166
|
+
* page does not multiply behind the provider's own tab bookkeeping.
|
|
167
|
+
*
|
|
168
|
+
* A cached guest is used as-is: probing it first cost a round-trip on every command, so liveness
|
|
169
|
+
* is established by the command failing instead.
|
|
131
170
|
* @param viewId - the view whose guest is wanted.
|
|
132
|
-
* @param url - address to use when
|
|
171
|
+
* @param url - address to use when this conversation has no page yet.
|
|
133
172
|
*/
|
|
134
173
|
async guestFor(viewId, url) {
|
|
135
|
-
const
|
|
174
|
+
const entry = this.views.get(viewId);
|
|
136
175
|
// No liveness check here: verifying the cached guest cost a round-trip on every
|
|
137
176
|
// command. A guest that has gone away is detected by the command itself failing,
|
|
138
177
|
// and `createView`'s sendCommand then discards the cache and calls back in here.
|
|
139
|
-
if (
|
|
140
|
-
return
|
|
141
|
-
// The
|
|
142
|
-
//
|
|
143
|
-
|
|
178
|
+
if (entry?.guest !== undefined)
|
|
179
|
+
return entry.guest;
|
|
180
|
+
// The conversation THIS VIEW serves — read from the view, never from the process.
|
|
181
|
+
//
|
|
182
|
+
// The difference is the whole bug. This host is one instance shared by every conversation, so
|
|
183
|
+
// `this.owner` alone can only be "whichever conversation this process happens to name", and
|
|
184
|
+
// naming the wrong one is exactly how a page requested in one conversation appeared in
|
|
185
|
+
// another. The view was created for a conversation (`createView(owner)`), so that is what it
|
|
186
|
+
// asks for its page.
|
|
187
|
+
//
|
|
188
|
+
// Nothing here asks which conversation is on screen: `ensureSidebar` reaches this
|
|
189
|
+
// conversation's own panel by id through the renderer, rather than by pressing the shell's
|
|
190
|
+
// shortcut or clicking its card, both of which the shell delivers to whatever it displays.
|
|
191
|
+
const owner = entry?.owner ?? this.owner;
|
|
192
|
+
const answer = await this.connection.call({
|
|
144
193
|
op: 'ensureSidebar',
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
if (free === undefined) {
|
|
154
|
-
// Every tab is already driving something: the strip has to grow.
|
|
155
|
-
ids = await this.guestIds(ids.length + 1);
|
|
156
|
-
free = ids.find(id => !taken.has(id));
|
|
194
|
+
owner,
|
|
195
|
+
...url !== undefined && url !== '' ? { url } : {},
|
|
196
|
+
}, 30_000);
|
|
197
|
+
if (answer.ok !== true)
|
|
198
|
+
throw new Error(`dsh-builtin-browser: sidebar unavailable (${String(answer.error)})`);
|
|
199
|
+
const id = Number(answer.id);
|
|
200
|
+
if (!Number.isFinite(id) || id <= 0) {
|
|
201
|
+
throw new Error('dsh-builtin-browser: the sidebar reported no browser page for this conversation');
|
|
157
202
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
this.views.set(viewId, free);
|
|
161
|
-
return free;
|
|
203
|
+
this.views.set(viewId, { guest: id, owner });
|
|
204
|
+
return id;
|
|
162
205
|
}
|
|
163
206
|
/**
|
|
164
|
-
*
|
|
165
|
-
*
|
|
207
|
+
* @param owner - the conversation this view serves. Recorded WITH the view: one host instance
|
|
208
|
+
* serves every conversation, so the owner cannot live on the host.
|
|
166
209
|
*/
|
|
167
|
-
|
|
168
|
-
const answer = await this.connection.call({ op: 'ensureTabs', count });
|
|
169
|
-
if (answer.ok !== true)
|
|
170
|
-
throw new Error(`dsh-builtin-browser: could not open a sidebar tab (${String(answer.error)})`);
|
|
171
|
-
const ids = Array.isArray(answer.ids) ? answer.ids.map(Number).filter(Number.isFinite) : [];
|
|
172
|
-
if (ids.length === 0)
|
|
173
|
-
throw new Error('dsh-builtin-browser: the sidebar reported no browser tabs');
|
|
174
|
-
return ids;
|
|
175
|
-
}
|
|
176
|
-
createView() {
|
|
210
|
+
createView(owner) {
|
|
177
211
|
const viewId = randomUUID();
|
|
212
|
+
this.views.set(viewId, { owner: owner !== undefined && owner !== '' ? owner : this.owner });
|
|
178
213
|
const navigateUrl = (method, params) => method === 'Page.navigate' ? String(params?.url ?? '') : undefined;
|
|
179
214
|
const run = async (guest, method, params) => {
|
|
180
215
|
const answer = await this.connection.call({ op: 'cdp', id: guest, method, params });
|
|
@@ -201,7 +236,11 @@ export class DesktopBridgeViewHost {
|
|
|
201
236
|
// The human closed the tab: the page is gone, so take a fresh one and
|
|
202
237
|
// replay the command once. This is the "closing the interface ends the
|
|
203
238
|
// session" rule, now paid for only when it actually happens.
|
|
204
|
-
|
|
239
|
+
// Drop the guest but keep the entry: the conversation it serves is what the retry needs,
|
|
240
|
+
// and removing the entry would lose it back to the process-wide fallback owner.
|
|
241
|
+
const lost = this.views.get(viewId);
|
|
242
|
+
if (lost !== undefined)
|
|
243
|
+
delete lost.guest;
|
|
205
244
|
guest = await this.guestFor(viewId, url);
|
|
206
245
|
return await run(guest, method, params);
|
|
207
246
|
}
|
|
@@ -209,35 +248,39 @@ export class DesktopBridgeViewHost {
|
|
|
209
248
|
};
|
|
210
249
|
}
|
|
211
250
|
destroyView(handle) {
|
|
212
|
-
const
|
|
213
|
-
if (
|
|
214
|
-
// Remember
|
|
215
|
-
//
|
|
216
|
-
|
|
251
|
+
const entry = this.views.get(handle.id);
|
|
252
|
+
if (entry !== undefined) {
|
|
253
|
+
// Remember the page: it outlives our handle, and a later release must be able to name
|
|
254
|
+
// exactly the tabs this conversation's views opened. The owner comes along so a release
|
|
255
|
+
// naming one conversation does not sweep up another's.
|
|
256
|
+
if (entry.guest !== undefined)
|
|
257
|
+
this.orphaned.set(entry.guest, entry.owner);
|
|
217
258
|
this.views.delete(handle.id);
|
|
218
259
|
}
|
|
219
260
|
}
|
|
220
261
|
/**
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
262
|
+
* Bring this view's tab to the front in the sidebar.
|
|
263
|
+
*
|
|
264
|
+
* This used to be empty, on the reasoning that the sidebar is already on screen so there is
|
|
265
|
+
* nothing to show. That confused "the sidebar is visible" with "this page is the one being
|
|
266
|
+
* looked at": opening a page left it behind whatever the human had in front, which is issue
|
|
267
|
+
* #23. The other two carriers both do this — the self-hosted window re-adds the view and the
|
|
268
|
+
* system browser calls `Page.bringToFront` — so this carrier was the odd one out.
|
|
269
|
+
*
|
|
270
|
+
* Best-effort by design: the tab strip belongs to the shell's renderer, and failing to raise
|
|
271
|
+
* it must not fail the navigation that has already happened.
|
|
272
|
+
* @param handle - the view to bring forward.
|
|
232
273
|
*/
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
274
|
+
showView(handle) {
|
|
275
|
+
// `owner` is required: the bridge refuses to bring forward a guest held by another session.
|
|
276
|
+
// It comes from the view, not the process — with several conversations live, a process-wide
|
|
277
|
+
// owner would raise the wrong conversation's tab.
|
|
278
|
+
const owner = this.views.get(handle.id)?.owner ?? this.owner;
|
|
279
|
+
void this.connection.call({ op: 'showTab', viewId: Number(handle.id), owner }, 5_000).catch(() => undefined);
|
|
280
|
+
}
|
|
236
281
|
onUserAction(handler) {
|
|
237
282
|
this.userActionHandler = handler;
|
|
238
283
|
}
|
|
239
|
-
/** The shell reports its own window lifecycle; nothing to subscribe to here. */
|
|
240
|
-
onViewClosed() { }
|
|
241
284
|
/**
|
|
242
285
|
* Release the sidebar's browser pages (requirements §3, `ui.closeWithSession`).
|
|
243
286
|
*
|
|
@@ -245,22 +288,42 @@ export class DesktopBridgeViewHost {
|
|
|
245
288
|
* belongs to the shell and would happily keep the page (and its renderer) alive.
|
|
246
289
|
* Closing the tabs is what actually ends the page — and only the page: cookies
|
|
247
290
|
* live in the partition, history on disk, so both survive.
|
|
291
|
+
* @param owner - the conversation whose pages to close. Omit to close every conversation's,
|
|
292
|
+
* which is only correct from a teardown that is itself global.
|
|
248
293
|
* @returns a promise that settles once the shell has been asked.
|
|
249
294
|
*/
|
|
250
|
-
async releasePage() {
|
|
251
|
-
// Only
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
const mine = [
|
|
255
|
-
this.views
|
|
256
|
-
|
|
295
|
+
async releasePage(owner) {
|
|
296
|
+
// Only this conversation's tabs. The host is ONE instance shared by every conversation, so a
|
|
297
|
+
// release that named none would close pages other conversations are still driving — "every
|
|
298
|
+
// webview currently visible" is precisely what must never be used here.
|
|
299
|
+
const mine = [];
|
|
300
|
+
for (const [viewId, entry] of [...this.views]) {
|
|
301
|
+
if (owner !== undefined && entry.owner !== owner)
|
|
302
|
+
continue;
|
|
303
|
+
if (entry.guest !== undefined)
|
|
304
|
+
mine.push(entry.guest);
|
|
305
|
+
this.views.delete(viewId);
|
|
306
|
+
}
|
|
307
|
+
// A view destroyed before the release still owns its page; its guest is collected here.
|
|
308
|
+
for (const [guest, holder] of [...this.orphaned]) {
|
|
309
|
+
if (owner !== undefined && holder !== owner)
|
|
310
|
+
continue;
|
|
311
|
+
mine.push(guest);
|
|
312
|
+
this.orphaned.delete(guest);
|
|
313
|
+
}
|
|
257
314
|
// Nothing of ours is open: release nothing. Falling through to an unfiltered
|
|
258
315
|
// release here would close tabs a human opened, or another session's page.
|
|
259
316
|
if (mine.length === 0)
|
|
260
317
|
return;
|
|
261
318
|
let titles;
|
|
262
319
|
try {
|
|
263
|
-
|
|
320
|
+
// `owner` is REQUIRED here now that the bridge filters `list` by it. Without it the bridge
|
|
321
|
+
// falls back to a different owner, so the guests this session holds are filtered out of the
|
|
322
|
+
// answer, `mine.includes(...)` matches nothing, the title list comes back empty and this
|
|
323
|
+
// returns without closing anything — the page stayed open and the human had to close it by
|
|
324
|
+
// hand. Adding the ledger to the bridge without updating this call site is exactly the kind
|
|
325
|
+
// of half-change that made the ownership work take four rounds.
|
|
326
|
+
const answer = await this.connection.call({ op: 'list', owner: owner ?? this.owner }, 5_000);
|
|
264
327
|
const sidebar = Array.isArray(answer.sidebar) ? answer.sidebar : [];
|
|
265
328
|
titles = sidebar
|
|
266
329
|
.filter(guest => mine.includes(Number(guest.id)))
|
|
@@ -275,7 +338,9 @@ export class DesktopBridgeViewHost {
|
|
|
275
338
|
if (titles.length === 0)
|
|
276
339
|
return;
|
|
277
340
|
try {
|
|
278
|
-
|
|
341
|
+
// `owner` is required for the same reason as above: the bridge closes only the guests the
|
|
342
|
+
// caller holds, so an owner-less call closes nothing and the page stays open.
|
|
343
|
+
await this.connection.call({ op: 'closeSidebarBrowser', titles, owner: owner ?? this.owner }, 10_000);
|
|
279
344
|
}
|
|
280
345
|
catch {
|
|
281
346
|
// Best effort: the setting expresses a preference, and a shell that cannot
|
|
@@ -34,7 +34,7 @@ export interface Config {
|
|
|
34
34
|
readonly downloadDir?: string;
|
|
35
35
|
/** Maximum snapshot elements before truncation. Default 60. */
|
|
36
36
|
readonly snapshotMaxElements?: number;
|
|
37
|
-
/** Maximum content characters before truncation when no maxChars is given.
|
|
37
|
+
/** Maximum content characters before truncation when no maxChars is given. No longer read: the cap is per format (html and json 50 000, otherwise 20 000). */
|
|
38
38
|
readonly contentMaxChars?: number;
|
|
39
39
|
}
|
|
40
40
|
export declare const Config: z<Config>;
|
|
@@ -17,8 +17,10 @@ import { join } from 'node:path';
|
|
|
17
17
|
import { defaultHostMainPath, RemoteElectronViewHost } from './remote-host.js';
|
|
18
18
|
import { DesktopBridgeViewHost } from './desktop-bridge-host.js';
|
|
19
19
|
import { detectBrowser, searchSummary, SystemBrowserViewHost } from './system-browser.js';
|
|
20
|
+
import { claimEphemeralProfile, ephemeralProfileName, sweepAbandonedEphemeralProfiles } from './ephemeral-profile.js';
|
|
20
21
|
import { MissingSystemBrowserHost } from './missing-system-browser.js';
|
|
21
22
|
import { SettingsStore } from './settings-store.js';
|
|
23
|
+
import { sameOrigin } from './settings-route.js';
|
|
22
24
|
export { ELECTRON_BROWSER_PROVIDER_ID, ElectronBrowserProvider, } from './provider.js';
|
|
23
25
|
export { RemoteElectronViewHost, defaultHostMainPath } from './remote-host.js';
|
|
24
26
|
/** Cordis plugin name used by loader diagnostics. */
|
|
@@ -33,8 +35,50 @@ export const Config = z.object({
|
|
|
33
35
|
snapshotMaxElements: z.number(),
|
|
34
36
|
contentMaxChars: z.number(),
|
|
35
37
|
});
|
|
38
|
+
/**
|
|
39
|
+
* This process's session id, read the way DSH's own desktop host reads it.
|
|
40
|
+
*
|
|
41
|
+
* `dsh-desktop-host/lib/index.js` does `ctx.get('agents')` then `agents.list()`, and uses
|
|
42
|
+
* `agent.id` directly as a sessionId. One conversation per process, so the list holds this one.
|
|
43
|
+
*
|
|
44
|
+
* Returns undefined rather than throwing when the service is absent — an older host, or a
|
|
45
|
+
* non-desktop composition. The caller falls back to a random id, which is what the bridge used
|
|
46
|
+
* before and is safe: it only means "this process", not "this conversation".
|
|
47
|
+
*
|
|
48
|
+
* @param ctx - the plugin context.
|
|
49
|
+
* @returns the session id, or undefined when it cannot be read.
|
|
50
|
+
*/
|
|
51
|
+
function readSessionId(ctx) {
|
|
52
|
+
try {
|
|
53
|
+
const surface = ctx;
|
|
54
|
+
if (typeof surface.get !== 'function')
|
|
55
|
+
return undefined;
|
|
56
|
+
const agents = surface.get('agents');
|
|
57
|
+
const list = typeof agents?.list === 'function' ? agents.list() : undefined;
|
|
58
|
+
if (!Array.isArray(list) || list.length === 0)
|
|
59
|
+
return undefined;
|
|
60
|
+
const first = list[0];
|
|
61
|
+
return typeof first?.id === 'string' && first.id !== '' ? first.id : undefined;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
36
67
|
/** Register the Electron browser provider with `ctx.browser`. */
|
|
37
68
|
export function apply(ctx, config) {
|
|
69
|
+
// Which session is this process serving?
|
|
70
|
+
//
|
|
71
|
+
// The desktop shell keeps ONE sidebar per conversation — measured: two `[class*=_tabStrip]`
|
|
72
|
+
// containers sat in the DOM at once, each carrying its own session id on the React fiber, and
|
|
73
|
+
// the invisible one's webview was unloaded. So the bridge CAN tell which sidebar belongs to
|
|
74
|
+
// whom; what it lacked was the other half, this process's own identity. Without it, the bridge
|
|
75
|
+
// drove "whatever sidebar is on screen" and — measured on the running shell — typed into another
|
|
76
|
+
// conversation's address bar and navigated that page.
|
|
77
|
+
//
|
|
78
|
+
// The identity is available here, and DSH's own desktop host shows how
|
|
79
|
+
// (`dsh-desktop-host/lib/index.js`: `ctx.get('agents').list()`, with `agent.id` used directly as
|
|
80
|
+
// a sessionId). One conversation per process, so the list holds this one.
|
|
81
|
+
const sessionId = readSessionId(ctx);
|
|
38
82
|
// One settings document per plugin instance: the settings panel writes it, the
|
|
39
83
|
// provider reads it live, and both ends agree on the same file.
|
|
40
84
|
const settings = new SettingsStore();
|
|
@@ -83,9 +127,29 @@ export function apply(ctx, config) {
|
|
|
83
127
|
ctx.logger?.info?.(`dsh-builtin-browser: ${note}`);
|
|
84
128
|
}
|
|
85
129
|
catch (error) {
|
|
86
|
-
// Never leave the surface without a provider.
|
|
87
|
-
|
|
88
|
-
|
|
130
|
+
// Never leave the surface without a provider. The half-built host is disposed FIRST: an
|
|
131
|
+
// adopt that got as far as constructing it may already hold a socket, a listener or a child
|
|
132
|
+
// process, and dropping it on the floor is how a failed upgrade becomes a leak that only
|
|
133
|
+
// shows up as a stray Electron in the task manager.
|
|
134
|
+
try {
|
|
135
|
+
host.dispose?.();
|
|
136
|
+
}
|
|
137
|
+
catch { /* the fallback below matters more than this */ }
|
|
138
|
+
// The fallback registration gets its own guard, because THIS is the path taken when the
|
|
139
|
+
// context is already gone — and then `ctx.browser` throws `cannot get required service
|
|
140
|
+
// "browser" in inactive context`. Unguarded, that second throw escaped the async retry
|
|
141
|
+
// that calls `adopt`, became an unhandled rejection, and killed the host process with
|
|
142
|
+
// exit code 1: a failed upgrade, which should only ever cost a warning, took the whole
|
|
143
|
+
// session down instead. Observed 2026-10-05 (crash-…-host.log, phase: running).
|
|
144
|
+
try {
|
|
145
|
+
unregister = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(new RemoteElectronViewHost(defaultHostMainPath()), providerConfig));
|
|
146
|
+
ctx.logger?.warn?.(`dsh-builtin-browser: could not adopt ${note} (${String(error)})`);
|
|
147
|
+
}
|
|
148
|
+
catch (fallbackError) {
|
|
149
|
+
ctx.logger?.warn?.(`dsh-builtin-browser: could not adopt ${note} (${String(error)}), and the fallback `
|
|
150
|
+
+ `registration also failed (${String(fallbackError)}) — no browser provider is `
|
|
151
|
+
+ 'registered for this context');
|
|
152
|
+
}
|
|
89
153
|
}
|
|
90
154
|
};
|
|
91
155
|
// A browser the user explicitly asked for outranks every automatic choice: the
|
|
@@ -102,8 +166,8 @@ export function apply(ctx, config) {
|
|
|
102
166
|
// that choice real — otherwise a user who picked Chrome would silently get the sidebar,
|
|
103
167
|
// and a user who picked a browser that is not installed would never see the explanation
|
|
104
168
|
// written for exactly that case, because the sidebar would quietly replace it.
|
|
105
|
-
const explicitChoice = channel !== 'bundled' && channel !== 'auto';
|
|
106
|
-
if (channel !== 'bundled') {
|
|
169
|
+
const explicitChoice = channel !== 'bundled' && channel !== 'auto' && channel !== 'sidebar';
|
|
170
|
+
if (channel !== 'bundled' && channel !== 'sidebar') {
|
|
107
171
|
const detected = detectBrowser(channel);
|
|
108
172
|
if (detected === undefined) {
|
|
109
173
|
// `auto` promises to take whatever is available, so falling back is what it
|
|
@@ -118,32 +182,83 @@ export function apply(ctx, config) {
|
|
|
118
182
|
}
|
|
119
183
|
}
|
|
120
184
|
else {
|
|
121
|
-
|
|
185
|
+
// Same profile root as settings-store, history-store and desktop-bridge-host: the
|
|
186
|
+
// `?.dsh` component used to be missing HERE only, so a desktop launched from a
|
|
187
|
+
// shortcut (no DSH_HOME in the environment) put its browser profile in
|
|
188
|
+
// `C:\Users\<user>\dsh-builtin-browser-host\` while its settings and history lived in
|
|
189
|
+
// `~/.dsh/dsh-builtin-browser-host/` — two half-profiles that never met, and a
|
|
190
|
+
// settings panel whose switches appeared not to apply.
|
|
191
|
+
const home = process.env.DSH_HOME ?? join(homedir(), '.dsh');
|
|
122
192
|
// Login state lives in the browser profile, so the cookies setting decides where
|
|
123
193
|
// that profile goes: a stable directory keeps the user signed in across restarts
|
|
124
194
|
// (the point of using their own browser), while turning persistence off gets a
|
|
125
195
|
// throwaway directory that is removed when the browser is released.
|
|
126
196
|
const persist = settings.get().cookies.persist;
|
|
197
|
+
const profileRoot = join(home, BROWSER_PROFILE_DIR);
|
|
127
198
|
const profileDir = persist
|
|
128
|
-
? join(
|
|
129
|
-
: join(
|
|
199
|
+
? join(profileRoot, `${detected.kind}-profile`)
|
|
200
|
+
: join(profileRoot, ephemeralProfileName(detected.kind, randomUUID()));
|
|
201
|
+
if (!persist) {
|
|
202
|
+
// A process killed outright never runs its own cleanup (see ephemeral-profile.ts),
|
|
203
|
+
// so the sweep happens HERE — at the only moment that can still act on a profile
|
|
204
|
+
// whose owner is gone — and before this run claims a directory of its own.
|
|
205
|
+
sweepAbandonedEphemeralProfiles(profileRoot, detected.kind);
|
|
206
|
+
claimEphemeralProfile(profileDir);
|
|
207
|
+
}
|
|
130
208
|
adopt(new SystemBrowserViewHost(detected, profileDir, [], persist ? undefined : profileDir), `will drive the installed ${detected.kind} (${detected.path}${persist ? '' : ', ephemeral profile'})`);
|
|
131
209
|
}
|
|
132
210
|
}
|
|
133
211
|
// An explicit choice was already adopted above; discovering the sidebar here would
|
|
134
212
|
// replace it, which is exactly what this guard prevents.
|
|
135
|
-
|
|
213
|
+
//
|
|
214
|
+
// `sidebar` is explicit too, but it names the sidebar, so it is the one explicit choice that
|
|
215
|
+
// must FALL THROUGH to the discovery below instead of returning here.
|
|
216
|
+
if (explicitChoice && channel !== 'sidebar')
|
|
136
217
|
return;
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
218
|
+
// The shell may not be up yet. `dsh web` starts before — or entirely without — the desktop app
|
|
219
|
+
// that hosts the bridge, and a one-shot discovery meant a bridge that appeared a second later
|
|
220
|
+
// was never adopted for the rest of the process's life. Retry a handful of times over roughly
|
|
221
|
+
// half a minute, then stop: this is a convenience, not a promise, and the session the user is
|
|
222
|
+
// already driving in the self-hosted window is not worth replacing minutes into their work.
|
|
223
|
+
// `adopt` is itself one-shot (its `upgraded` guard), so the only possible effect of a retry
|
|
224
|
+
// that lands late is adopting a bridge that showed up just after startup.
|
|
225
|
+
void (async () => {
|
|
226
|
+
for (const delay of [0, 2_000, 5_000, 15_000]) {
|
|
227
|
+
if (delay > 0)
|
|
228
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
229
|
+
let sidebar;
|
|
230
|
+
try {
|
|
231
|
+
sidebar = await DesktopBridgeViewHost.discover(sessionId);
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
sidebar = undefined; // discovery is documented not to throw; never let it stop the retries
|
|
235
|
+
}
|
|
236
|
+
if (sidebar === undefined)
|
|
237
|
+
continue;
|
|
238
|
+
adopt(sidebar, 'driving the desktop sidebar browser');
|
|
141
239
|
return;
|
|
142
|
-
|
|
143
|
-
|
|
240
|
+
}
|
|
241
|
+
// The user explicitly asked for the sidebar, so silence would leave them with a setting
|
|
242
|
+
// that did nothing and no way to tell why. This is reachable on `dsh web`, and on a desktop
|
|
243
|
+
// whose shell never published a bridge endpoint. An automatic attempt stays quiet (the
|
|
244
|
+
// self-hosted window is a fine outcome); an explicit request does not.
|
|
245
|
+
if (channel === 'sidebar') {
|
|
246
|
+
ctx.logger?.warn?.('dsh-builtin-browser: the sidebar browser was requested but no desktop bridge was found — '
|
|
247
|
+
+ 'this DSH has no desktop shell, or its bridge never started. Falling back to the bundled '
|
|
248
|
+
+ 'browser; set browser.channel to another value to silence this.');
|
|
249
|
+
}
|
|
250
|
+
})();
|
|
144
251
|
}
|
|
145
252
|
/** Route the settings panel reads and writes. */
|
|
146
253
|
const SETTINGS_ROUTE = '/dsh-builtin-browser/settings';
|
|
254
|
+
/**
|
|
255
|
+
* The directory under the DSH home that holds this plugin's browser profile.
|
|
256
|
+
*
|
|
257
|
+
* Named once, because every consumer of it must agree: `settingsPath()` and the history
|
|
258
|
+
* store resolve their own copy from `$DSH_HOME`, and a profile written under a different
|
|
259
|
+
* root than the settings document is a pair that silently never meets.
|
|
260
|
+
*/
|
|
261
|
+
const BROWSER_PROFILE_DIR = 'dsh-builtin-browser-host';
|
|
147
262
|
/**
|
|
148
263
|
* Expose the settings document over the host's web server, which is how the
|
|
149
264
|
* settings panel reads and writes it. A host without that service (headless /
|
|
@@ -169,31 +284,9 @@ function installSettingsRoute(ctx, settings) {
|
|
|
169
284
|
// No web server on this surface: the settings file remains the interface.
|
|
170
285
|
}
|
|
171
286
|
}
|
|
172
|
-
/**
|
|
173
|
-
* Same-origin guard. Settings are machine-local configuration, so a page loaded
|
|
174
|
-
* elsewhere must not be able to read or rewrite them.
|
|
175
|
-
* @param request - the incoming request.
|
|
176
|
-
* @returns whether the request may touch the settings document.
|
|
177
|
-
*/
|
|
178
|
-
function sameOrigin(request) {
|
|
179
|
-
const site = String(request.headers['sec-fetch-site'] ?? '').toLowerCase();
|
|
180
|
-
if (site === 'cross-site')
|
|
181
|
-
return false;
|
|
182
|
-
const origin = String(request.headers.origin ?? '').trim();
|
|
183
|
-
if (origin === 'null')
|
|
184
|
-
return false;
|
|
185
|
-
if (origin === '')
|
|
186
|
-
return true;
|
|
187
|
-
try {
|
|
188
|
-
return new URL(origin).host.toLowerCase() === String(request.headers.host ?? '').trim().toLowerCase();
|
|
189
|
-
}
|
|
190
|
-
catch {
|
|
191
|
-
return false;
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
287
|
/**
|
|
195
288
|
* One settings round-trip: `GET` reads the document, `PUT`/`POST` merges a
|
|
196
|
-
* partial patch into it.
|
|
289
|
+
* partial patch into it. Admission lives in `settings-route.ts` so it can be tested.
|
|
197
290
|
* @param request - the incoming request.
|
|
198
291
|
* @param response - the response to write.
|
|
199
292
|
* @param settings - the settings document.
|
|
@@ -206,11 +299,11 @@ async function handleSettingsRequest(request, response, settings) {
|
|
|
206
299
|
});
|
|
207
300
|
response.end(JSON.stringify(body));
|
|
208
301
|
};
|
|
209
|
-
|
|
302
|
+
const method = request.method ?? 'GET';
|
|
303
|
+
if (!sameOrigin(request, method !== 'GET')) {
|
|
210
304
|
json(403, { ok: false, error: 'cross-origin request refused' });
|
|
211
305
|
return;
|
|
212
306
|
}
|
|
213
|
-
const method = request.method ?? 'GET';
|
|
214
307
|
if (method === 'GET') {
|
|
215
308
|
json(200, { ok: true, settings: settings.get(), path: settings.path() });
|
|
216
309
|
return;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** Test seams; production passes neither. */
|
|
2
|
+
export interface SweepOptions {
|
|
3
|
+
/** The clock, for the age check. */
|
|
4
|
+
readonly now?: number;
|
|
5
|
+
/** Liveness test for an owner pid. */
|
|
6
|
+
readonly alive?: (pid: number) => boolean;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* The directory name an ephemeral profile for `kind` gets, before it is claimed.
|
|
10
|
+
*
|
|
11
|
+
* The sweep matches on the same shape, so the two must agree: a name built anywhere else
|
|
12
|
+
* is a directory the sweep will never find.
|
|
13
|
+
* @param kind - the detected browser product (`chrome`, `edge`, ...).
|
|
14
|
+
* @param id - a per-run unique suffix.
|
|
15
|
+
* @returns the directory NAME, not a path.
|
|
16
|
+
*/
|
|
17
|
+
export declare function ephemeralProfileName(kind: string, id: string): string;
|
|
18
|
+
/**
|
|
19
|
+
* Mark an ephemeral profile directory as this process's, so a later run can tell whether
|
|
20
|
+
* the process that created it is still there.
|
|
21
|
+
* @param dir - the profile directory (created if it does not exist yet).
|
|
22
|
+
* @param pid - the claiming process, defaulting to this one.
|
|
23
|
+
* @param now - the claim time, defaulting to the current clock.
|
|
24
|
+
* @returns true when the marker was written.
|
|
25
|
+
*/
|
|
26
|
+
export declare function claimEphemeralProfile(dir: string, pid?: number, now?: number): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Delete the ephemeral profiles under `root` that belong to processes which are gone.
|
|
29
|
+
*
|
|
30
|
+
* Called before a new one is claimed, because that is the only moment that can still act
|
|
31
|
+
* on a profile whose owner was killed: nothing runs when the signal lands.
|
|
32
|
+
* @param root - the plugin's profile root (`<DSH_HOME>/dsh-builtin-browser-host`).
|
|
33
|
+
* @param kind - the browser product, which selects the directory-name prefix.
|
|
34
|
+
* @param options - test seams for the clock and the liveness test.
|
|
35
|
+
* @returns the names of the directories that were removed.
|
|
36
|
+
*/
|
|
37
|
+
export declare function sweepAbandonedEphemeralProfiles(root: string, kind: string, options?: SweepOptions): string[];
|