dsh-builtin-browser 0.4.6 → 0.4.8

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.en.md +62 -491
  3. package/README.md +44 -470
  4. package/client.js +10 -10
  5. package/cordis.patch.yml +4 -4
  6. package/desktop-bridge/plugin-browser-bridge.js +1 -1
  7. package/docs/README.md +8 -4
  8. package/docs/architecture.md +82 -11
  9. package/docs/development.md +38 -0
  10. package/docs/limitations.md +49 -0
  11. package/docs/tool-reference.md +42 -42
  12. package/docs/user-guide.md +93 -37
  13. package/docs/why-browser.md +3 -3
  14. package/lib/browser/types.d.ts +2 -2
  15. package/lib/browser-electron/bridge-connection.d.ts +7 -1
  16. package/lib/browser-electron/bridge-connection.js +58 -3
  17. package/lib/browser-electron/desktop-bridge-host.d.ts +11 -0
  18. package/lib/browser-electron/desktop-bridge-host.js +21 -1
  19. package/lib/browser-electron/entry.d.ts +2 -2
  20. package/lib/browser-electron/entry.js +186 -103
  21. package/lib/browser-electron/history-store.d.ts +1 -1
  22. package/lib/browser-electron/history-store.js +1 -1
  23. package/lib/browser-electron/host-main.js +3 -3
  24. package/lib/browser-electron/provider.d.ts +32 -8
  25. package/lib/browser-electron/provider.js +97 -16
  26. package/lib/browser-electron/remote-host.js +1 -1
  27. package/lib/browser-electron/settings-store.d.ts +14 -6
  28. package/lib/browser-electron/settings-store.js +29 -9
  29. package/lib/index.d.ts +1 -1
  30. package/lib/index.js +1 -1
  31. package/lib/tool-browser/element-fields.d.ts +4 -4
  32. package/lib/tool-browser/element-fields.js +6 -6
  33. package/lib/tool-browser/index.d.ts +6 -6
  34. package/lib/tool-browser/index.js +196 -139
  35. package/package.json +2 -2
  36. package/scripts/verify-host-compat.mjs +6 -6
  37. package/src/browser/types.ts +2 -2
  38. package/src/browser-electron/bridge-connection.ts +61 -3
  39. package/src/browser-electron/desktop-bridge-host.ts +22 -1
  40. package/src/browser-electron/entry.ts +101 -9
  41. package/src/browser-electron/history-store.ts +1 -1
  42. package/src/browser-electron/host-main.ts +3 -3
  43. package/src/browser-electron/provider.ts +135 -31
  44. package/src/browser-electron/remote-host.ts +1 -1
  45. package/src/browser-electron/settings-store.ts +32 -13
  46. package/src/index.ts +1 -1
  47. package/src/tool-browser/element-fields.ts +6 -6
  48. package/src/tool-browser/index.ts +197 -140
@@ -5,7 +5,7 @@
5
5
  * `ctx.electronViewHost`, that host is used (embedded, human-machine shared
6
6
  * view). Otherwise the plugin self-hosts: it spawns its own Electron child
7
7
  * (`host-main.js`) and drives it over a local TCP JSON-RPC socket, so
8
- * installing the plugin is enough for `browser_*` tools to work on any
8
+ * installing the plugin is enough for `dsh_browser_*` tools to work on any
9
9
  * surface.
10
10
  * @module dsh-browser/browser-electron
11
11
  */
@@ -81,9 +81,10 @@ export function apply(ctx, config) {
81
81
  // a sessionId). One conversation per process, so the list holds this one.
82
82
  const sessionId = readSessionId(ctx);
83
83
  // One settings document per plugin instance: the settings panel writes it, the
84
- // provider reads it live, and both ends agree on the same file.
85
- const settings = new SettingsStore();
86
- installSettingsRoute(ctx, settings);
84
+ // provider reads it live, and both ends agree on the same file. The logger is passed so a
85
+ // discarded value (a mistyped channel, most of all) is reported instead of being silently
86
+ // replaced by the default — "my setting did nothing" and "I misspelled it" must not look alike.
87
+ const settings = new SettingsStore(undefined, message => ctx.logger?.warn?.(message));
87
88
  const providerConfig = {
88
89
  httpOnly: config.httpOnly,
89
90
  downloadDir: config.downloadDir,
@@ -96,6 +97,15 @@ export function apply(ctx, config) {
96
97
  if (config.viewHost !== undefined) {
97
98
  const external = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(config.viewHost, providerConfig));
98
99
  ctx.effect(() => () => { external(); });
100
+ // The composition supplied the carrier, so `browser.channel` is not ours to honour on this
101
+ // surface. Saying so is the difference between a setting that cannot apply and a setting that
102
+ // appears not to have worked — the second is what sends people looking for a bug elsewhere.
103
+ const asked = settings.get().browser.channel;
104
+ if (asked !== 'sidebar' && asked !== 'bundled') {
105
+ ctx.logger?.warn?.(`dsh-builtin-browser: browser.channel is "${asked}", but this surface's carrier was `
106
+ + 'supplied by its composition (the desktop shell), which outranks the setting. Set it to '
107
+ + '"sidebar" to record the intent, or run on a surface that picks its own carrier.');
108
+ }
99
109
  return;
100
110
  }
101
111
  // Otherwise start self-hosted — a working browser from the first call, on every
@@ -106,26 +116,69 @@ export function apply(ctx, config) {
106
116
  const selfHosted = new RemoteElectronViewHost(defaultHostMainPath());
107
117
  let unregister = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(selfHosted, providerConfig));
108
118
  let upgraded = false;
119
+ /** The carrier an `adopt` installed, so a later choice can release it. */
120
+ let adoptedCarrier;
109
121
  ctx.effect(() => () => {
110
122
  unregister();
111
123
  selfHosted.dispose();
112
124
  });
125
+ /**
126
+ * Put the self-hosted browser back after an adopted carrier reports that it is gone.
127
+ *
128
+ * This is the half the startup retries never covered: a bridge that was alive at apply time and
129
+ * exited later left every subsequent call dialling a port nobody owns, because the swap happened
130
+ * once and only in one direction. Reaching here means the shell is gone, so the choice made at
131
+ * startup is no longer a choice — the alternative to falling back is a browser surface that does
132
+ * nothing until DSH is restarted.
133
+ * @param adopted - the carrier that reported the loss, disposed once its replacement is in place.
134
+ */
135
+ const restoreSelfHosted = (adopted) => {
136
+ try {
137
+ const restored = new RemoteElectronViewHost(defaultHostMainPath());
138
+ unregister();
139
+ unregister = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(restored, providerConfig));
140
+ ctx.logger?.warn?.('dsh-builtin-browser: the desktop shell backed the sidebar browser and has exited, so the '
141
+ + 'browser this plugin hosts itself takes over. Pages from the sidebar are gone with it; '
142
+ + 'open the page again.');
143
+ }
144
+ catch (error) {
145
+ // Nothing better is available: the adopted carrier is gone and a provider that throws is
146
+ // worse than one that explains.
147
+ ctx.logger?.warn?.(`dsh-builtin-browser: could not fall back to the self-hosted browser: ${String(error)}`);
148
+ }
149
+ finally {
150
+ adopted.dispose?.();
151
+ }
152
+ };
113
153
  /**
114
154
  * Swap the provider to another carrier, releasing the previous one.
115
155
  * @param host - the carrier to adopt.
116
156
  * @param note - a one-line description for the log.
117
157
  */
118
158
  const adopt = (host, note) => {
119
- if (upgraded) {
120
- host.dispose?.();
121
- return;
122
- }
159
+ // This guard used to make the FIRST choice final, disposing anything that arrived later. That
160
+ // is right against two carriers racing in, and exactly wrong for a user changing the setting:
161
+ // `chooseCarrier` runs again on purpose, and a refusal here is indistinguishable from the
162
+ // write having done nothing. The previous carrier is now released by the replacement instead.
123
163
  upgraded = true;
124
164
  try {
125
165
  unregister();
126
166
  unregister = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(host, providerConfig));
127
167
  selfHosted.dispose();
128
168
  ctx.logger?.info?.(`dsh-builtin-browser: ${note}`);
169
+ // A borrowed carrier can lose the process behind it, and this is the only notification that
170
+ // will ever arrive. Registering AFTER the swap succeeded means a listener can never fire
171
+ // against a provider that was never installed.
172
+ host.onLost?.(() => { restoreSelfHosted(host); });
173
+ const previous = adoptedCarrier;
174
+ adoptedCarrier = host;
175
+ // Released AFTER the replacement is installed, so the surface is never without a provider.
176
+ if (previous !== undefined && previous !== host) {
177
+ try {
178
+ previous.dispose?.();
179
+ }
180
+ catch { /* the replacement is already serving */ }
181
+ }
129
182
  }
130
183
  catch (error) {
131
184
  // Never leave the surface without a provider. The half-built host is disposed FIRST: an
@@ -153,103 +206,119 @@ export function apply(ctx, config) {
153
206
  }
154
207
  }
155
208
  };
156
- // A browser the user explicitly asked for outranks every automatic choice: the
157
- // whole point of the setting is that they know which browser they want. It is
158
- // launched with a plugin-owned profile, so their own windows are untouched.
159
- //
160
- // Registration is INERT: constructing the host starts nothing. The browser only
161
- // launches when a command actually needs a page, so merely running DSH never
162
- // spawns a browser window. (An earlier version launched it here, which meant a
163
- // browser appeared the moment the plugin loaded — reported and fixed.)
164
- const channel = settings.get().browser.channel;
165
- // Naming a browser is a decision about which one to use, so it also decides whether the
166
- // desktop sidebar may take over. Skipping discovery for an explicit choice is what makes
167
- // that choice real — otherwise a user who picked Chrome would silently get the sidebar,
168
- // and a user who picked a browser that is not installed would never see the explanation
169
- // written for exactly that case, because the sidebar would quietly replace it.
170
- const explicitChoice = channel !== 'bundled' && channel !== 'auto' && channel !== 'sidebar';
171
- if (channel !== 'bundled' && channel !== 'sidebar') {
172
- const detected = detectBrowser(channel);
173
- if (detected === undefined) {
174
- // `auto` promises to take whatever is available, so falling back is what it
175
- // asked for. Naming a browser is a specific request, and quietly satisfying it
176
- // with a different one turns the real problem ("no Chrome here") into a
177
- // confusing error about Electron later on. Report it instead.
178
- if (channel === 'auto') {
179
- ctx.logger?.warn?.('dsh-builtin-browser: no installed browser was found; using the bundled browser');
209
+ /**
210
+ * Choose and install the carrier that `browser.channel` names RIGHT NOW.
211
+ *
212
+ * A function rather than straight-line code because the setting is read live everywhere else:
213
+ * the settings route writes the file and the provider picks the change up on its next call, so a
214
+ * channel that could only be honoured at apply time was the one setting whose write silently did
215
+ * nothing. Re-entrant by design — changing the channel means replacing the carrier, which is
216
+ * exactly what a second call does.
217
+ */
218
+ const chooseCarrier = () => {
219
+ // A browser the user explicitly asked for outranks every automatic choice: the
220
+ // whole point of the setting is that they know which browser they want. It is
221
+ // launched with a plugin-owned profile, so their own windows are untouched.
222
+ //
223
+ // Registration is INERT: constructing the host starts nothing. The browser only
224
+ // launches when a command actually needs a page, so merely running DSH never
225
+ // spawns a browser window. (An earlier version launched it here, which meant a
226
+ // browser appeared the moment the plugin loaded — reported and fixed.)
227
+ const channel = settings.get().browser.channel;
228
+ // Naming a browser is a decision about which one to use, so it also decides whether the
229
+ // desktop sidebar may take over. Skipping discovery for an explicit choice is what makes
230
+ // that choice real — otherwise a user who picked Chrome would silently get the sidebar,
231
+ // and a user who picked a browser that is not installed would never see the explanation
232
+ // written for exactly that case, because the sidebar would quietly replace it.
233
+ const explicitChoice = channel !== 'bundled' && channel !== 'auto' && channel !== 'sidebar';
234
+ if (channel !== 'bundled' && channel !== 'sidebar') {
235
+ const detected = detectBrowser(channel);
236
+ if (detected === undefined) {
237
+ // `auto` promises to take whatever is available, so falling back is what it
238
+ // asked for. Naming a browser is a specific request, and quietly satisfying it
239
+ // with a different one turns the real problem ("no Chrome here") into a
240
+ // confusing error about Electron later on. Report it instead.
241
+ if (channel === 'auto') {
242
+ ctx.logger?.warn?.('dsh-builtin-browser: no installed browser was found; using the bundled browser');
243
+ }
244
+ else {
245
+ adopt(new MissingSystemBrowserHost(channel, searchSummary(channel)), `the selected ${channel} is not installed — commands will explain how to fix it`);
246
+ }
180
247
  }
181
248
  else {
182
- adopt(new MissingSystemBrowserHost(channel, searchSummary(channel)), `the selected ${channel} is not installed — commands will explain how to fix it`);
183
- }
184
- }
185
- else {
186
- // Same profile root as settings-store, history-store and desktop-bridge-host: the
187
- // `?.dsh` component used to be missing HERE only, so a desktop launched from a
188
- // shortcut (no DSH_HOME in the environment) put its browser profile in
189
- // `C:\Users\<user>\dsh-builtin-browser-host\` while its settings and history lived in
190
- // `~/.dsh/dsh-builtin-browser-host/` — two half-profiles that never met, and a
191
- // settings panel whose switches appeared not to apply.
192
- const home = process.env.DSH_HOME ?? join(homedir(), '.dsh');
193
- // Login state lives in the browser profile, so the cookies setting decides where
194
- // that profile goes: a stable directory keeps the user signed in across restarts
195
- // (the point of using their own browser), while turning persistence off gets a
196
- // throwaway directory that is removed when the browser is released.
197
- const persist = settings.get().cookies.persist;
198
- const profileRoot = join(home, BROWSER_PROFILE_DIR);
199
- adoptLegacyProfileRoot(profileRoot);
200
- const profileDir = persist
201
- ? join(profileRoot, `${detected.kind}-profile`)
202
- : join(profileRoot, ephemeralProfileName(detected.kind, randomUUID()));
203
- if (!persist) {
204
- // A process killed outright never runs its own cleanup (see ephemeral-profile.ts),
205
- // so the sweep happens HERE — at the only moment that can still act on a profile
206
- // whose owner is gone — and before this run claims a directory of its own.
207
- sweepAbandonedEphemeralProfiles(profileRoot, detected.kind);
208
- claimEphemeralProfile(profileDir);
249
+ // Same profile root as settings-store, history-store and desktop-bridge-host: the
250
+ // `?.dsh` component used to be missing HERE only, so a desktop launched from a
251
+ // shortcut (no DSH_HOME in the environment) put its browser profile in
252
+ // `C:\Users\<user>\dsh-builtin-browser-host\` while its settings and history lived in
253
+ // `~/.dsh/dsh-builtin-browser-host/` — two half-profiles that never met, and a
254
+ // settings panel whose switches appeared not to apply.
255
+ const home = process.env.DSH_HOME ?? join(homedir(), '.dsh');
256
+ // Login state lives in the browser profile, so the cookies setting decides where
257
+ // that profile goes: a stable directory keeps the user signed in across restarts
258
+ // (the point of using their own browser), while turning persistence off gets a
259
+ // throwaway directory that is removed when the browser is released.
260
+ const persist = settings.get().cookies.persist;
261
+ const profileRoot = join(home, BROWSER_PROFILE_DIR);
262
+ adoptLegacyProfileRoot(profileRoot);
263
+ const profileDir = persist
264
+ ? join(profileRoot, `${detected.kind}-profile`)
265
+ : join(profileRoot, ephemeralProfileName(detected.kind, randomUUID()));
266
+ if (!persist) {
267
+ // A process killed outright never runs its own cleanup (see ephemeral-profile.ts),
268
+ // so the sweep happens HERE — at the only moment that can still act on a profile
269
+ // whose owner is gone — and before this run claims a directory of its own.
270
+ sweepAbandonedEphemeralProfiles(profileRoot, detected.kind);
271
+ claimEphemeralProfile(profileDir);
272
+ }
273
+ adopt(new SystemBrowserViewHost(detected, profileDir, [], persist ? undefined : profileDir), `will drive the installed ${detected.kind} (${detected.path}${persist ? '' : ', ephemeral profile'})`);
209
274
  }
210
- adopt(new SystemBrowserViewHost(detected, profileDir, [], persist ? undefined : profileDir), `will drive the installed ${detected.kind} (${detected.path}${persist ? '' : ', ephemeral profile'})`);
211
275
  }
212
- }
213
- // An explicit choice was already adopted above; discovering the sidebar here would
214
- // replace it, which is exactly what this guard prevents.
215
- //
216
- // `sidebar` is explicit too, but it names the sidebar, so it is the one explicit choice that
217
- // must FALL THROUGH to the discovery below instead of returning here.
218
- if (explicitChoice && channel !== 'sidebar')
219
- return;
220
- // The shell may not be up yet. `dsh web` starts before — or entirely without — the desktop app
221
- // that hosts the bridge, and a one-shot discovery meant a bridge that appeared a second later
222
- // was never adopted for the rest of the process's life. Retry a handful of times over roughly
223
- // half a minute, then stop: this is a convenience, not a promise, and the session the user is
224
- // already driving in the self-hosted window is not worth replacing minutes into their work.
225
- // `adopt` is itself one-shot (its `upgraded` guard), so the only possible effect of a retry
226
- // that lands late is adopting a bridge that showed up just after startup.
227
- void (async () => {
228
- for (const delay of [0, 2_000, 5_000, 15_000]) {
229
- if (delay > 0)
230
- await new Promise(resolve => setTimeout(resolve, delay));
231
- let sidebar;
232
- try {
233
- sidebar = await DesktopBridgeViewHost.discover(sessionId);
276
+ // An explicit choice was already adopted above; discovering the sidebar here would
277
+ // replace it, which is exactly what this guard prevents.
278
+ //
279
+ // `sidebar` is explicit too, but it names the sidebar, so it is the one explicit choice that
280
+ // must FALL THROUGH to the discovery below instead of returning here.
281
+ if (explicitChoice && channel !== 'sidebar')
282
+ return;
283
+ // The shell may not be up yet. `dsh web` starts before — or entirely without — the desktop app
284
+ // that hosts the bridge, and a one-shot discovery meant a bridge that appeared a second later
285
+ // was never adopted for the rest of the process's life. Retry a handful of times over roughly
286
+ // half a minute, then stop: this is a convenience, not a promise, and the session the user is
287
+ // already driving in the self-hosted window is not worth replacing minutes into their work.
288
+ // `adopt` is itself one-shot (its `upgraded` guard), so the only possible effect of a retry
289
+ // that lands late is adopting a bridge that showed up just after startup.
290
+ void (async () => {
291
+ for (const delay of [0, 2_000, 5_000, 15_000]) {
292
+ if (delay > 0)
293
+ await new Promise(resolve => setTimeout(resolve, delay));
294
+ let sidebar;
295
+ try {
296
+ sidebar = await DesktopBridgeViewHost.discover(sessionId);
297
+ }
298
+ catch {
299
+ sidebar = undefined; // discovery is documented not to throw; never let it stop the retries
300
+ }
301
+ if (sidebar === undefined)
302
+ continue;
303
+ adopt(sidebar, 'driving the desktop sidebar browser');
304
+ return;
234
305
  }
235
- catch {
236
- sidebar = undefined; // discovery is documented not to throw; never let it stop the retries
306
+ // The user explicitly asked for the sidebar, so silence would leave them with a setting
307
+ // that did nothing and no way to tell why. This is reachable on `dsh web`, and on a desktop
308
+ // whose shell never published a bridge endpoint. An automatic attempt stays quiet (the
309
+ // self-hosted window is a fine outcome); an explicit request does not.
310
+ if (channel === 'sidebar') {
311
+ ctx.logger?.warn?.('dsh-builtin-browser: the sidebar browser was requested but no desktop bridge was found — '
312
+ + 'this DSH has no desktop shell, or its bridge never started. Falling back to the bundled '
313
+ + 'browser; set browser.channel to another value to silence this.');
237
314
  }
238
- if (sidebar === undefined)
239
- continue;
240
- adopt(sidebar, 'driving the desktop sidebar browser');
241
- return;
242
- }
243
- // The user explicitly asked for the sidebar, so silence would leave them with a setting
244
- // that did nothing and no way to tell why. This is reachable on `dsh web`, and on a desktop
245
- // whose shell never published a bridge endpoint. An automatic attempt stays quiet (the
246
- // self-hosted window is a fine outcome); an explicit request does not.
247
- if (channel === 'sidebar') {
248
- ctx.logger?.warn?.('dsh-builtin-browser: the sidebar browser was requested but no desktop bridge was found — '
249
- + 'this DSH has no desktop shell, or its bridge never started. Falling back to the bundled '
250
- + 'browser; set browser.channel to another value to silence this.');
251
- }
252
- })();
315
+ })();
316
+ };
317
+ chooseCarrier();
318
+ // Wired AFTER the chooser exists, because the handler re-runs it: a settings write that changes
319
+ // `browser.channel` is a request to switch carriers, and answering `ok` without switching is the
320
+ // one thing a caller cannot tell apart from a write that worked.
321
+ installSettingsRoute(ctx, settings, () => { chooseCarrier(); });
253
322
  }
254
323
  /** Route the settings panel reads and writes. */
255
324
  const SETTINGS_ROUTE = '/dsh-builtin-browser/settings';
@@ -303,7 +372,7 @@ export function adoptLegacyProfileRoot(profileRoot, legacyRoot = join(homedir(),
303
372
  * @param ctx - the plugin context.
304
373
  * @param settings - the settings document to expose.
305
374
  */
306
- function installSettingsRoute(ctx, settings) {
375
+ function installSettingsRoute(ctx, settings, onChannelChange) {
307
376
  const inject = ctx.inject;
308
377
  if (typeof inject !== 'function')
309
378
  return;
@@ -312,7 +381,7 @@ function installSettingsRoute(ctx, settings) {
312
381
  host.effect(() => host.webServer.register({
313
382
  kind: 'exact',
314
383
  path: SETTINGS_ROUTE,
315
- handler: (request, response) => handleSettingsRequest(request, response, settings),
384
+ handler: (request, response) => handleSettingsRequest(request, response, settings, onChannelChange),
316
385
  }, 'dsh-builtin-browser: settings'));
317
386
  });
318
387
  }
@@ -326,8 +395,9 @@ function installSettingsRoute(ctx, settings) {
326
395
  * @param request - the incoming request.
327
396
  * @param response - the response to write.
328
397
  * @param settings - the settings document.
398
+ * @param onChannelChange - re-runs the carrier choice when this write changed `browser.channel`.
329
399
  */
330
- async function handleSettingsRequest(request, response, settings) {
400
+ async function handleSettingsRequest(request, response, settings, onChannelChange) {
331
401
  const json = (status, body) => {
332
402
  response.writeHead(status, {
333
403
  'content-type': 'application/json; charset=utf-8',
@@ -352,7 +422,20 @@ async function handleSettingsRequest(request, response, settings) {
352
422
  try {
353
423
  const body = await readBody(request);
354
424
  const patch = body.trim() === '' ? {} : JSON.parse(body);
355
- json(200, { ok: true, settings: settings.update(patch) });
425
+ const before = settings.get().browser.channel;
426
+ const updated = settings.update(patch);
427
+ // A changed `browser.channel` is a request to switch carriers, not just a value to store. The
428
+ // choice is re-run here so the write takes effect now — and the response says so, because a
429
+ // caller that cannot tell "applied" from "stored for later" has no way to know what it got,
430
+ // which is exactly how a write that did nothing read as a success.
431
+ const channelChanged = updated.browser.channel !== before;
432
+ if (channelChanged)
433
+ onChannelChange?.();
434
+ json(200, {
435
+ ok: true,
436
+ settings: updated,
437
+ ...channelChanged ? { carrierRechosen: true } : {},
438
+ });
356
439
  }
357
440
  catch (error) {
358
441
  json(400, { ok: false, error: `invalid settings patch: ${String(error)}` });
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Persistent browsing history: which pages the shared browser actually visited,
3
- * kept independently from any session's operation log (`browser_history`) and
3
+ * kept independently from any session's operation log (`dsh_browser_history`) and
4
4
  * from the browser process's lifetime.
5
5
  *
6
6
  * It lives beside the browser profile (`$DSH_HOME/dsh-builtin-browser-host/
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Persistent browsing history: which pages the shared browser actually visited,
3
- * kept independently from any session's operation log (`browser_history`) and
3
+ * kept independently from any session's operation log (`dsh_browser_history`) and
4
4
  * from the browser process's lifetime.
5
5
  *
6
6
  * It lives beside the browser profile (`$DSH_HOME/dsh-builtin-browser-host/
@@ -38,7 +38,7 @@ import { dirname, join } from 'node:path';
38
38
  // several Electron instances sharing Roaming\Electron fight over the GPU
39
39
  // cache/session locks, which can leave the window without a display surface
40
40
  // (capturePage then fails). A dedicated userData also persists cookies across
41
- // host restarts (on top of browser_auth). Must run before app is ready.
41
+ // host restarts (on top of dsh_browser_auth). Must run before app is ready.
42
42
  try {
43
43
  const base = process.env.DSH_HOME ?? app.getPath('appData');
44
44
  app.setPath('userData', join(base, 'dsh-builtin-browser-host'));
@@ -785,7 +785,7 @@ async function handle(op, msg) {
785
785
  if (win === undefined || entry === undefined)
786
786
  throw new Error(`focus: unknown view ${viewId}`);
787
787
  // Keys go to the view that holds web focus. A view that was never
788
- // clicked holds none, so the FIRST browser_key of a session was dropped
788
+ // clicked holds none, so the FIRST dsh_browser_key of a session was dropped
789
789
  // by the renderer while CDP still answered the dispatch with success —
790
790
  // and `document.hasFocus()` inside the page even read true.
791
791
  const contents = entry.webContentsView.webContents;
@@ -920,7 +920,7 @@ async function handle(op, msg) {
920
920
  throw new Error('capture produced no image (view not painted)');
921
921
  }
922
922
  // The measured size travels with the image. Without it the native path — which is the
923
- // default self-hosted one — could never answer `browser_screenshot`'s width/height,
923
+ // default self-hosted one — could never answer `dsh_browser_screenshot`'s width/height,
924
924
  // because the seam's only channel was {base64, mime} and the provider had nothing to
925
925
  // forward. The caller's `maxWidth`/`maxHeight` are applied here, so this is the only
926
926
  // place that knows what came back.
@@ -119,6 +119,17 @@ export interface ElectronBrowserViewHost {
119
119
  * which is only correct from a teardown that is itself global.
120
120
  */
121
121
  releasePage?(owner?: string): Promise<void>;
122
+ /**
123
+ * Report that this carrier can no longer serve anything, so its owner may replace it.
124
+ *
125
+ * A carrier that borrows an outside process can lose it: the desktop shell exits, and every call
126
+ * after that would dial a port nobody owns. Without this signal the swap happens only at
127
+ * startup, so the choice made then is final for the process's life — which is what a user
128
+ * experiences as "the browser stopped working until I restart DSH". Optional: a carrier that
129
+ * owns its own lifetime has nothing to report.
130
+ * @param listener - called once, when the carrier concludes it is gone.
131
+ */
132
+ onLost?(listener: () => void): void;
122
133
  /**
123
134
  * Fold the carrier's presentation away while keeping the page alive.
124
135
  *
@@ -274,7 +285,7 @@ export interface ElectronBrowserProviderConfig {
274
285
  */
275
286
  readonly contentMaxChars?: number;
276
287
  /**
277
- * Directory `browser_download` save paths must resolve inside (prevents a
288
+ * Directory `dsh_browser_download` save paths must resolve inside (prevents a
278
289
  * prompt-injected agent from writing arbitrary machine paths). Default:
279
290
  * `~/Downloads`. NOTE: this default is a convenience, not a security
280
291
  * boundary — on Windows it may resolve to OneDrive/redirected paths;
@@ -328,7 +339,7 @@ export declare function layoutSize(metrics: Record<string, unknown>): {
328
339
  viewportHeight: number;
329
340
  } | undefined;
330
341
  /**
331
- * Map CDP cookies onto the shape `browser_auth` exports.
342
+ * Map CDP cookies onto the shape `dsh_browser_auth` exports.
332
343
  *
333
344
  * CDP reports a cookie as domain + path rather than a URL, and in seconds rather than
334
345
  * milliseconds, so both are converted. Entries missing a name, value or domain are
@@ -441,9 +452,9 @@ export declare class ElectronBrowserProvider implements BrowserProvider {
441
452
  /**
442
453
  * Page.navigate resolves at navigation COMMIT, not at load: the new document
443
454
  * is already current (so location.href and document.title are the new page's)
444
- * while its DOM is still being parsed. browser_open snapshots immediately
455
+ * while its DOM is still being parsed. dsh_browser_open snapshots immediately
445
456
  * after navigating, which is why it could report the right title with zero
446
- * interactive elements — and why a separate browser_snapshot right after
457
+ * interactive elements — and why a separate dsh_browser_snapshot right after
447
458
  * always found them.
448
459
  *
449
460
  * Wait — bounded, best-effort — for the new document to settle: readyState
@@ -482,7 +493,7 @@ export declare class ElectronBrowserProvider implements BrowserProvider {
482
493
  *
483
494
  * A renderer only delivers key events to the view that holds web focus. A
484
495
  * view that was created but never clicked holds none — and `Input` events
485
- * injected over CDP do not grant it — so the FIRST `browser_key` of a fresh
496
+ * injected over CDP do not grant it — so the FIRST `dsh_browser_key` of a fresh
486
497
  * session vanished inside the renderer while the command still answered
487
498
  * success. Hosts that cannot focus a view (a test double, a different shell
488
499
  * adapter) simply have no `focus`, and nothing changes for them.
@@ -587,7 +598,7 @@ export declare class ElectronBrowserProvider implements BrowserProvider {
587
598
  * Refuse an action the settings have switched off.
588
599
  *
589
600
  * These switches belong to the OPERATOR. The settings document is written through the
590
- * plugin's own panel and no tool can reach it, so — unlike `browser_restrict`, which the
601
+ * plugin's own panel and no tool can reach it, so — unlike `dsh_browser_restrict`, which the
591
602
  * model owns and can lift at will — a refusal here stands for as long as the setting does.
592
603
  * That is the point of them: a deployment can take page-script execution, downloads, or
593
604
  * login-state writes off the table without relying on the model's cooperation.
@@ -601,7 +612,7 @@ export declare class ElectronBrowserProvider implements BrowserProvider {
601
612
  private assertActionAllowed;
602
613
  /**
603
614
  * Admit a caller-supplied save path for a file the browser writes to disk.
604
- * ONE gate for both `browser_download` and `browser_screenshot`: the path
615
+ * ONE gate for both `dsh_browser_download` and `dsh_browser_screenshot`: the path
605
616
  * must be absolute, must resolve inside `downloadDir`, and must not already
606
617
  * exist. Without it a prompt-injected agent could write — or silently
607
618
  * replace — any file the DSH process can reach, which also escapes the file
@@ -665,7 +676,7 @@ export declare class ElectronBrowserProvider implements BrowserProvider {
665
676
  */
666
677
  private recordVisit;
667
678
  /**
668
- * List persisted visits, newest first — what `browser_visited` reads back.
679
+ * List persisted visits, newest first — what `dsh_browser_visited` reads back.
669
680
  * @param options - result cap and an optional hostname filter.
670
681
  * @returns the matching visits, or an empty list when history is disabled.
671
682
  */
@@ -705,6 +716,19 @@ export declare class ElectronBrowserProvider implements BrowserProvider {
705
716
  private activeTab;
706
717
  /** Append a fresh tab and make it active. */
707
718
  private newTab;
719
+ /**
720
+ * Swap a wedged view for a fresh blank one, keeping the tab's public id.
721
+ *
722
+ * A view whose renderer never commits cannot be read again: every evaluate into it runs out its
723
+ * whole budget. Replacing it is what turns that from permanent into one bad call — the tab id
724
+ * callers hold stays valid, and the next `dsh_browser_open` with the real URL re-populates it.
725
+ *
726
+ * The old view is destroyed AFTER the new one is in place, so a failure here cannot leave the
727
+ * session holding a handle that is already gone.
728
+ * @param s - the session owning the tab.
729
+ * @param tab - the tab to reset.
730
+ */
731
+ private replaceTabWithBlank;
708
732
  /** Find a session's tab by its backing view id (toolbar actions carry view ids). */
709
733
  private tabByViewId;
710
734
  /**