dsh-builtin-browser 0.4.6 → 0.4.7

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.
@@ -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)}` });
@@ -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
  *
@@ -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 `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
  /**
@@ -352,7 +352,7 @@ export class ElectronBrowserProvider {
352
352
  // session), carrying the label for the window title. Hosts without
353
353
  // groupView keep the shared-window behavior.
354
354
  this.host.groupView?.(handle, id, label);
355
- this.sessions.set(id, { id, label, tabs: [{ id: `tab:${randomUUID()}`, handle }], activeIndex: 0, history: [], nextSeq: 1 });
355
+ this.sessions.set(id, { id, label, tabs: [{ id: `tab:${randomUUID()}`, handle, url: '' }], activeIndex: 0, history: [], nextSeq: 1 });
356
356
  return Promise.resolve(id);
357
357
  }
358
358
  /** Open a URL in the active tab (default) or a new tab. */
@@ -371,9 +371,16 @@ export class ElectronBrowserProvider {
371
371
  const tab = s.tabs[i];
372
372
  if (tab === undefined)
373
373
  continue; // defensive: array can shift under concurrency
374
+ // A tab we know has never navigated is NOT probed: its view has committed no document, so
375
+ // the round trip can only answer nothing — and it is the round trip that costs. A tab that
376
+ // HAS navigated is re-read, because the page may have moved itself since; a read that fails
377
+ // keeps the last accepted URL rather than reporting the tab as blank.
378
+ const liveUrl = tab.url === '' ? '' : await this.currentUrl(tab.handle).catch(() => tab.url);
379
+ if (liveUrl !== '')
380
+ tab.url = liveUrl;
374
381
  result.push({
375
382
  id: tab.id,
376
- url: await this.currentUrl(tab.handle).catch(() => ''),
383
+ url: liveUrl,
377
384
  active: i === s.activeIndex,
378
385
  });
379
386
  }
@@ -455,7 +462,8 @@ export class ElectronBrowserProvider {
455
462
  /** Navigate the active tab's view to a URL, honoring HTTP(S)-only admission. */
456
463
  async navigate(session, request, signal) {
457
464
  const s = this.session(session);
458
- const { handle } = this.activeTab(s);
465
+ const tab = this.activeTab(s);
466
+ const { handle } = tab;
459
467
  const url = request.url;
460
468
  try {
461
469
  if (this.httpOnly) {
@@ -496,6 +504,11 @@ export class ElectronBrowserProvider {
496
504
  // the visit written to the persistent history for a navigation nobody awaits any more.
497
505
  signal?.throwIfAborted();
498
506
  this.record(s, 'navigate', { url }, true);
507
+ // The tab now holds a committed document, which is what the blank short-circuit in
508
+ // `snapshot`/`a11y` keys on. Recorded here rather than after the settle below: the
509
+ // navigation HAS committed, and a tab wrongly treated as blank would return empty results
510
+ // for a page that is really there.
511
+ tab.url = url;
499
512
  await this.settleDocument(handle, before, signal);
500
513
  this.recordVisit(s, handle);
501
514
  this.showActive(s);
@@ -808,6 +821,11 @@ export class ElectronBrowserProvider {
808
821
  const s = this.session(session);
809
822
  const tab = this.activeTab(s);
810
823
  signal?.throwIfAborted();
824
+ // A tab that has never navigated is a blank, uncommitted view. The collector cannot answer in
825
+ // it — there is no document to commit — so the call would sit out its whole budget before
826
+ // saying anything. Answering the empty page immediately is both faster and more truthful.
827
+ if (tab.url === '')
828
+ return { url: '', elements: [], truncated: false };
811
829
  const script = `(() => {
812
830
  const cap = ${String(this.snapshotMaxElements)}
813
831
  const url = location.href
@@ -880,7 +898,22 @@ export class ElectronBrowserProvider {
880
898
  // Same hang guard as execute: a renderer that has not committed after
881
899
  // navigate would otherwise block snapshot forever.
882
900
  const timeoutMs = 30_000;
883
- const result = await withTimeout(handleSendEvaluate(tab.handle, script), timeoutMs, signal, `browser: snapshot timed out after ${timeoutMs}ms`, () => terminatePage(tab.handle));
901
+ let result;
902
+ try {
903
+ result = await withTimeout(handleSendEvaluate(tab.handle, script), timeoutMs, signal, `browser: snapshot timed out after ${timeoutMs}ms`, () => terminatePage(tab.handle));
904
+ }
905
+ catch (error) {
906
+ // A timeout leaves the wedged view in the session, so every later read would pay the same
907
+ // 30 seconds again — and `terminatePage` only interrupts the running script, it does not
908
+ // make the view readable. Replacing it is what turns a permanent loss into one bad call:
909
+ // the tab keeps the id the caller holds, and the next browser_open with the real URL
910
+ // repopulates it.
911
+ if (error instanceof Error && error.name === 'TimeoutError') {
912
+ this.replaceTabWithBlank(s, tab);
913
+ throw new BrowserError('browser: snapshot timed out; the stuck tab was reset to blank. Call browser_open with the real target URL before reading it again.', 'BROWSER_SNAPSHOT_TIMEOUT', { cause: error });
914
+ }
915
+ throw error;
916
+ }
884
917
  if (!result.ok)
885
918
  throw new BrowserError(`browser: snapshot evaluation failed: ${result.exception}`, 'BROWSER_SNAPSHOT_FAILED');
886
919
  const value = result.value;
@@ -898,6 +931,10 @@ export class ElectronBrowserProvider {
898
931
  const s = this.session(session);
899
932
  const tab = this.activeTab(s);
900
933
  signal?.throwIfAborted();
934
+ // Same short-circuit as `snapshot`: a tab that never navigated has no committed document, so
935
+ // the tree cannot be read and the call would spend its whole budget discovering that.
936
+ if (tab.url === '')
937
+ return { url: '', title: undefined, count: 0, nodes: [], truncated: false };
901
938
  const includeHidden = request.includeHidden === true;
902
939
  // 150 rather than 500. The cap fills on any real page, and 500 nodes measured 38,953
903
940
  // characters — 10k-13k tokens for a single call. A caller that needs more asks for it.
@@ -1059,7 +1096,19 @@ export class ElectronBrowserProvider {
1059
1096
  return { url: location.href, title: document.title || undefined, count: out.length, nodes: out, truncated: out.length >= maxNodes }
1060
1097
  })()`;
1061
1098
  const timeoutMs = 30_000;
1062
- const result = await withTimeout(handleSendEvaluate(tab.handle, script), timeoutMs, signal, `browser: a11y timed out after ${timeoutMs}ms`, () => terminatePage(tab.handle));
1099
+ let result;
1100
+ try {
1101
+ result = await withTimeout(handleSendEvaluate(tab.handle, script), timeoutMs, signal, `browser: a11y timed out after ${timeoutMs}ms`, () => terminatePage(tab.handle));
1102
+ }
1103
+ catch (error) {
1104
+ // As in `snapshot`: a timeout means the view is wedged, and leaving it in the session would
1105
+ // charge every later read the same 30 seconds. Replaced in place, so the tab id survives.
1106
+ if (error instanceof Error && error.name === 'TimeoutError') {
1107
+ this.replaceTabWithBlank(s, tab);
1108
+ throw new BrowserError('browser: a11y timed out; the stuck tab was reset to blank. Call browser_open with the real target URL before reading it again.', 'BROWSER_A11Y_TIMEOUT', { cause: error });
1109
+ }
1110
+ throw error;
1111
+ }
1063
1112
  if (!result.ok)
1064
1113
  throw new BrowserError(`browser: a11y evaluation failed: ${result.exception}`, 'BROWSER_A11Y_FAILED');
1065
1114
  const value = result.value;
@@ -2579,10 +2628,42 @@ export class ElectronBrowserProvider {
2579
2628
  const handle = this.host.createView(s.label);
2580
2629
  // Same window as the session's other tabs.
2581
2630
  this.host.groupView?.(handle, s.id, s.label);
2582
- s.tabs.push({ id: `tab:${randomUUID()}`, handle });
2631
+ s.tabs.push({ id: `tab:${randomUUID()}`, handle, url: '' });
2583
2632
  s.activeIndex = s.tabs.length - 1;
2584
2633
  this.showActive(s);
2585
2634
  }
2635
+ /**
2636
+ * Swap a wedged view for a fresh blank one, keeping the tab's public id.
2637
+ *
2638
+ * A view whose renderer never commits cannot be read again: every evaluate into it runs out its
2639
+ * whole budget. Replacing it is what turns that from permanent into one bad call — the tab id
2640
+ * callers hold stays valid, and the next `browser_open` with the real URL re-populates it.
2641
+ *
2642
+ * The old view is destroyed AFTER the new one is in place, so a failure here cannot leave the
2643
+ * session holding a handle that is already gone.
2644
+ * @param s - the session owning the tab.
2645
+ * @param tab - the tab to reset.
2646
+ */
2647
+ replaceTabWithBlank(s, tab) {
2648
+ const index = s.tabs.indexOf(tab);
2649
+ if (index < 0)
2650
+ return;
2651
+ let handle;
2652
+ try {
2653
+ handle = this.host.createView(s.label);
2654
+ this.host.groupView?.(handle, s.id, s.label);
2655
+ }
2656
+ catch {
2657
+ // Creating the replacement failed: leaving the wedged tab in place is worse than nothing
2658
+ // only for the caller, who is about to be told the tab timed out either way.
2659
+ return;
2660
+ }
2661
+ const wedged = tab.handle;
2662
+ s.tabs[index] = { id: tab.id, handle, url: '' };
2663
+ if (index === s.activeIndex)
2664
+ this.showActive(s);
2665
+ this.host.destroyView(wedged);
2666
+ }
2586
2667
  /** Find a session's tab by its backing view id (toolbar actions carry view ids). */
2587
2668
  tabByViewId(s, viewId) {
2588
2669
  return s.tabs.find(tab => tab.handle.id === viewId);
@@ -69,6 +69,13 @@ export declare const DEFAULT_SETTINGS: BrowserSettings;
69
69
  * @returns the settings file path.
70
70
  */
71
71
  export declare function settingsPath(): string;
72
+ /**
73
+ * The channels a settings document may name.
74
+ *
75
+ * Exported so the log line and the error message can quote the same set the parser accepts: a
76
+ * message that lists a value the code rejects (or omits one it takes) is worse than no message.
77
+ */
78
+ export declare const BROWSER_CHANNELS: readonly ["chrome", "edge", "auto", "bundled", "sidebar"];
72
79
  /**
73
80
  * Resolve a raw settings document (already parsed, shape unknown) into the full
74
81
  * document, field by field. Unknown keys are dropped: the file is not a place to
@@ -76,7 +83,7 @@ export declare function settingsPath(): string;
76
83
  * @param raw - the parsed file contents.
77
84
  * @returns the resolved settings.
78
85
  */
79
- export declare function resolveSettings(raw: unknown): BrowserSettings;
86
+ export declare function resolveSettings(raw: unknown, warn?: (message: string) => void): BrowserSettings;
80
87
  /**
81
88
  * File-backed settings with an in-memory snapshot. Reads re-stat the file so an
82
89
  * edit from another process (or the settings panel writing through a second
@@ -85,13 +92,14 @@ export declare function resolveSettings(raw: unknown): BrowserSettings;
85
92
  */
86
93
  export declare class SettingsStore {
87
94
  private readonly file;
95
+ private readonly log?;
88
96
  private cached;
89
97
  /** Last observed mtime, used to invalidate the cache on external edits. */
90
98
  private cachedMtimeMs;
91
99
  /**
92
100
  * @param file - settings file path (defaults beside the browser profile).
93
101
  */
94
- constructor(file?: string);
102
+ constructor(file?: string, log?: ((message: string) => void) | undefined);
95
103
  /** Path this store reads and writes. */
96
104
  path(): string;
97
105
  /**