dsh-builtin-browser 0.4.8 → 0.4.10

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.
@@ -163,7 +163,27 @@ export class DesktopBridgeViewHost {
163
163
  const probe = new BridgeConnection(endpoint, bridgeEndpointPath());
164
164
  try {
165
165
  const answer = await probe.call({ op: 'list' }, 5_000);
166
- return answer.ok === true ? new DesktopBridgeViewHost(endpoint, sessionId) : undefined;
166
+ if (answer.ok !== true)
167
+ return undefined;
168
+ // Refuse a panel this plugin cannot drive, before adopting it.
169
+ //
170
+ // The sidebar browser renders as a <webview> only where the shell exposes
171
+ // `dshDesktop.browser` and enables `webviewTag`. Where it renders as an <iframe> there is
172
+ // no webContents, no `getWebContentsId()` and no CDP endpoint — and every tool here is
173
+ // built on CDP, so adopting it means failing every call, with no path back to a carrier
174
+ // that works. Reported on DSH Desktop 2.0.17 with both conditions measured.
175
+ //
176
+ // Read here WITHOUT an owner on purpose, because the question is not about a conversation:
177
+ // whether the panel is a webview is decided by the shell's preload and window options, so
178
+ // it is the same answer for every conversation and needs no session context.
179
+ //
180
+ // `none` and `unknown` are NOT refusals: a panel that does not exist yet is created by the
181
+ // first open, and a probe that could not read the attribute has learned nothing.
182
+ if (String(answer.frame ?? 'unknown') === 'iframe') {
183
+ DesktopBridgeViewHost.refuseIframe = true;
184
+ return undefined;
185
+ }
186
+ return new DesktopBridgeViewHost(endpoint, sessionId);
167
187
  }
168
188
  finally {
169
189
  probe.close();
@@ -174,10 +194,95 @@ export class DesktopBridgeViewHost {
174
194
  return undefined;
175
195
  }
176
196
  }
197
+ /**
198
+ * Whether the last discovery refused a bridge because its sidebar panel is an iframe.
199
+ *
200
+ * Recorded so the entry point can say WHY the plugin is self-hosting on a shell that does have a
201
+ * bridge. Otherwise the user sees a second browser window and no explanation, on a machine where
202
+ * they just followed instructions to install the bridge.
203
+ */
204
+ static refuseIframe = false;
205
+ /** Whether discovery refused a bridge for an undrivable sidebar panel (see {@link discover}). */
206
+ static refusedIframePanel() {
207
+ return this.refuseIframe;
208
+ }
177
209
  /** Whether this host can back views: the bridge already answered `list`. */
178
210
  available() {
179
211
  return true;
180
212
  }
213
+ /**
214
+ * Every page this conversation's sidebar panel currently holds.
215
+ *
216
+ * The shell owns page creation inside the panel: a link the user or the agent clicks may open a
217
+ * new page, and nothing tells the provider. Those pages are real — they are what the human sees
218
+ * — but without this the tab model reports only the ones the provider created, so the rest are
219
+ * invisible to `dsh_browser_list_tabs` and unclosable through `dsh_browser_close_tab`.
220
+ *
221
+ * The bridge's `list` already answers with the sidebar guests, which is the same information, so
222
+ * this asks for it rather than reaching into the shell again.
223
+ * @returns the panel's pages, or an empty list when the bridge cannot answer.
224
+ */
225
+ /**
226
+ * Every sidebar page this conversation holds that THIS HOST did not open.
227
+ *
228
+ * The shell owns page creation inside the panel: a link the user or the agent clicks may open a
229
+ * new page, and nothing tells the provider. Those pages are real — they are what the human sees
230
+ * — but without this the tab model reports only the ones the provider created, so the rest are
231
+ * invisible to `dsh_browser_list_tabs` and unclosable through `dsh_browser_close_tab`.
232
+ *
233
+ * Pages this host opened are EXCLUDED here rather than deduplicated by the caller, because the
234
+ * caller cannot do it: the provider knows a tab by its own id and the carrier knows a page by the
235
+ * guest id, and the two never match. The first version returned everything and relied on the
236
+ * caller to filter, which reported the same page twice — the mapping that makes the match
237
+ * possible exists only here.
238
+ * @returns the panel's pages that are not ours, or an empty list when the bridge cannot answer.
239
+ */
240
+ async listPages() {
241
+ try {
242
+ const answer = await this.connection.call({ op: 'list', owner: this.owner }, 5_000);
243
+ const guests = Array.isArray(answer.sidebar) ? answer.sidebar : [];
244
+ const ours = new Set();
245
+ for (const view of this.views.values()) {
246
+ if (typeof view.guest === 'number')
247
+ ours.add(view.guest);
248
+ }
249
+ return guests
250
+ .filter((guest) => typeof guest?.id === 'number')
251
+ .filter(guest => !ours.has(guest.id))
252
+ .map(guest => ({
253
+ id: String(guest.id),
254
+ url: typeof guest.url === 'string' ? guest.url : '',
255
+ }));
256
+ }
257
+ catch {
258
+ // A bridge that cannot answer has a real problem, and the next command reports it. Failing
259
+ // the tab LIST here would make a listing worse than the driving it exists to describe.
260
+ return [];
261
+ }
262
+ }
263
+ /**
264
+ * Close one sidebar page by the id {@link listPages} reported.
265
+ *
266
+ * The bridge closes sidebar pages by TITLE, and only the ones the caller owns — that is what
267
+ * stopped one conversation's teardown from destroying another's page. So the title is looked up
268
+ * through the same listing, and a page that no longer appears is treated as already gone rather
269
+ * than as an error: the caller asked for it to be closed, and it is.
270
+ * @param id - the sidebar guest id, as a string.
271
+ */
272
+ async closePage(id) {
273
+ const numeric = Number(id);
274
+ if (!Number.isFinite(numeric))
275
+ throw new Error(`not a sidebar page id: ${id}`);
276
+ const answer = await this.connection.call({ op: 'list', owner: this.owner }, 5_000);
277
+ const guests = Array.isArray(answer.sidebar) ? answer.sidebar : [];
278
+ const title = guests
279
+ .filter((guest) => Number(guest?.id) === numeric)
280
+ .map(guest => String(guest.title ?? ''))
281
+ .find(candidate => candidate !== '');
282
+ if (title === undefined)
283
+ return;
284
+ await this.connection.call({ op: 'closeSidebarBrowser', titles: [title], owner: this.owner }, 10_000);
285
+ }
181
286
  /**
182
287
  * The guest backing a view, materialized on first use.
183
288
  *
@@ -214,8 +319,17 @@ export class DesktopBridgeViewHost {
214
319
  owner,
215
320
  ...url !== undefined && url !== '' ? { url } : {},
216
321
  }, 30_000);
217
- if (answer.ok !== true)
322
+ if (answer.ok !== true) {
323
+ // A bridge that answers but cannot produce a page is as unusable as one that has exited, and
324
+ // the difference matters here: `onLost` is what returns the surface to the self-hosted
325
+ // browser. Without this the same failure repeats on every call until DSH is restarted — on a
326
+ // shell whose sidebar panel is an iframe, that is every call forever. Reported as "installing
327
+ // the bridge made it worse than not installing it": the carrier was switched to one that
328
+ // cannot work, with no path back to the one that does.
329
+ if (String(answer.error).includes('produced no page'))
330
+ this.reportLost();
218
331
  throw new Error(`dsh-builtin-browser: sidebar unavailable (${String(answer.error)})`);
332
+ }
219
333
  const id = Number(answer.id);
220
334
  if (!Number.isFinite(id) || id <= 0) {
221
335
  throw new Error('dsh-builtin-browser: the sidebar reported no browser page for this conversation');
@@ -303,6 +303,19 @@ export function apply(ctx, config) {
303
303
  adopt(sidebar, 'driving the desktop sidebar browser');
304
304
  return;
305
305
  }
306
+ // A bridge exists but its sidebar panel is an iframe. Saying nothing here would leave the user
307
+ // with a second browser window on a machine where they just followed the instructions to
308
+ // install the bridge, and no way to know the two facts are related. The carrier is not
309
+ // unavailable by accident: an iframe has no CDP endpoint, so adopting it would fail every call
310
+ // instead of falling back — see DesktopBridgeViewHost.discover.
311
+ if (DesktopBridgeViewHost.refusedIframePanel()) {
312
+ ctx.logger?.warn?.('dsh-builtin-browser: a desktop bridge is installed, but this shell renders the sidebar '
313
+ + 'browser panel as an iframe rather than a webview, which exposes no CDP endpoint and '
314
+ + 'cannot be driven. Using the browser this plugin hosts itself instead — that is the '
315
+ + 'independent window, not the sidebar. A shell that exposes `dshDesktop.browser` and '
316
+ + 'enables `webviewTag` would provide a drivable panel.');
317
+ return;
318
+ }
306
319
  // The user explicitly asked for the sidebar, so silence would leave them with a setting
307
320
  // that did nothing and no way to tell why. This is reachable on `dsh web`, and on a desktop
308
321
  // whose shell never published a bridge endpoint. An automatic attempt stays quiet (the
@@ -9,6 +9,7 @@
9
9
  import { type VisitedPage } from './history-store.js';
10
10
  import type { BrowserSettings } from './settings-store.js';
11
11
  import type { BrowserA11yRequest, BrowserA11yResult, BrowserChallenge, BrowserCheckRequest, BrowserClearRequest, BrowserClickRequest, BrowserContentRequest, BrowserContentResult, BrowserElementTarget, BrowserExecuteRequest, BrowserExecuteResult, BrowserFillRequest, BrowserFillResult, BrowserGetValueRequest, BrowserGetValueResult, BrowserHistoryEntry, BrowserOpenRequest, BrowserProvider, BrowserScrapeRequest, BrowserScrapeResult, BrowserScreenshotRequest, BrowserSelectRequest, BrowserSelectResult, BrowserSessionId, BrowserSetValueRequest, BrowserSetValueResult, BrowserSnapshotResult, BrowserTab, BrowserTypeRequest, BrowserWaitRequest, BrowserWaitResult, BrowserScrollRequest, BrowserKeyRequest, ExportedCookie } from '../browser/types.js';
12
+ import type { CarrierPage } from '../browser/types.js';
12
13
  /** Stable provider id registered with `ctx.browser`. */
13
14
  export declare const ELECTRON_BROWSER_PROVIDER_ID = "electron";
14
15
  /**
@@ -58,6 +59,35 @@ export interface ElectronBrowserViewHost {
58
59
  * @param label - human-readable session/task label, as in {@link showView}.
59
60
  */
60
61
  presentView?(handle: ElectronViewHandle, label?: string): Promise<void>;
62
+ /**
63
+ * Optional: pages this carrier holds that the provider never created.
64
+ *
65
+ * A sidebar panel and an installed browser both own their own page creation. Clicking a link
66
+ * that opens a new page — or any `window.open` the page performs — produces a page the provider
67
+ * did not ask for and therefore never recorded. Those pages are real: they are what the human
68
+ * sees, they hold memory, and they persist until something closes them. Without this the tab
69
+ * model reports only what the provider itself made, so the extras are invisible to
70
+ * `dsh_browser_list_tabs` and unclosable through `dsh_browser_close_tab`, accumulating for the
71
+ * life of the browser.
72
+ *
73
+ * Views the host itself opened are NOT included. That exclusion belongs to the host, which is the
74
+ * only side that can make it: a provider tab is identified by the provider's own id and a carrier
75
+ * page by the carrier's, so a caller comparing the two matches nothing and reports every page
76
+ * twice. Measured on the sidebar carrier: three clicks produced three webview guests while
77
+ * `dsh_browser_list_tabs` reported one tab; on the installed-browser carrier the window held four
78
+ * pages and the tool reported one.
79
+ * @returns every page the carrier holds that this host did not open.
80
+ */
81
+ listPages?(): Promise<readonly CarrierPage[]>;
82
+ /**
83
+ * Optional: close one page reported by {@link listPages}.
84
+ *
85
+ * The pair exists because listing a page the caller cannot close is worse than not listing it:
86
+ * `dsh_browser_list_tabs` would report pages that `dsh_browser_close_tab` answers "not open in
87
+ * this session" about, which reads as a contradiction rather than a boundary.
88
+ * @param id - the carrier's own page id, as {@link listPages} reported it.
89
+ */
90
+ closePage?(id: string): Promise<void>;
61
91
  /**
62
92
  * Optional cheap usability probe (no network): whether the host can back
63
93
  * views at all right now. The self-hosted host checks for a usable Electron
@@ -303,6 +333,15 @@ export interface CdpNavigateParams {
303
333
  * CDP method/params for `Input.dispatchMouseEvent`: a pointer move, or one half
304
334
  * of a click's press+release pair. `button`/`clickCount` belong to the press and
305
335
  * release halves; a move carries only the position.
336
+ *
337
+ * `buttons` is a BITMASK of what is held down at the moment of the event, and it is not the same
338
+ * field as `button` — `button` says which one this event is about, `buttons` says which ones are
339
+ * down. Chromium needs both to recognise a synthesized press as a real button press: with
340
+ * `buttons` omitted (0), the event still reaches the page and a `click` listener still fires, but
341
+ * the element's DEFAULT ACTION does not run. Measured on the sidebar carrier: a click on an
342
+ * anchor sent `mousedown`/`mouseup`/`click` to the page with `isTrusted: true` and
343
+ * `defaultPrevented: false`, and the browser did not follow the link — including for an anchor
344
+ * injected by the test itself, so the page was not responsible.
306
345
  */
307
346
  export interface CdpMouseParams {
308
347
  readonly type: 'mouseMoved' | 'mousePressed' | 'mouseReleased';
@@ -310,6 +349,8 @@ export interface CdpMouseParams {
310
349
  readonly y: number;
311
350
  readonly button?: 'left';
312
351
  readonly clickCount?: number;
352
+ /** Buttons held during the event: 1 = primary down. A move carries 0. */
353
+ readonly buttons?: number;
313
354
  }
314
355
  /** CDP method/params for `Input.insertText`. */
315
356
  export interface CdpInsertTextParams {
@@ -384,6 +384,28 @@ export class ElectronBrowserProvider {
384
384
  active: i === s.activeIndex,
385
385
  });
386
386
  }
387
+ // Pages the CARRIER holds that this provider never created.
388
+ //
389
+ // A sidebar panel and an installed browser both open pages on their own: a click on a link
390
+ // that targets a new page, or any `window.open`, produces a page nothing tells us about.
391
+ // Reporting only our own tabs made those invisible to this tool and unclosable through
392
+ // `dsh_browser_close_tab`, so they accumulated for the life of the browser. Measured on the
393
+ // sidebar carrier: three clicks produced three webview guests while this listed one tab.
394
+ //
395
+ // `listPages` already excludes the pages it opened the view for — that filtering cannot happen
396
+ // here, because a tab is known by this provider's own id and a carrier page by the carrier's,
397
+ // and the two never match. A first version compared them anyway and listed every page twice.
398
+ //
399
+ // They are appended under a `page:` prefix so a caller can tell the two kinds apart — the
400
+ // prefix is also what `closeTab` uses to decide whether to close a view or a carrier page.
401
+ const pages = await this.host.listPages?.().catch(() => undefined) ?? [];
402
+ for (const page of pages) {
403
+ // A page with no URL is a blank one the carrier opened for itself. Listing it would invite a
404
+ // caller to act on nothing, and the blank normalisation already closes those.
405
+ if (page.url === '' || page.url === 'about:blank')
406
+ continue;
407
+ result.push({ id: `page:${page.id}`, url: page.url, active: false });
408
+ }
387
409
  return result;
388
410
  }
389
411
  /** Switch to a tab by id, making its view visible. */
@@ -395,6 +417,16 @@ export class ElectronBrowserProvider {
395
417
  }
396
418
  /** Close one tab; closing the active tab activates the next. */
397
419
  closeTab(session, tabId) {
420
+ // A page the carrier owns rather than a view we created. It was reported by `listTabs` under
421
+ // this prefix, so closing it here is what keeps the two tools consistent — a page that can be
422
+ // listed must be closable, or the listing reads as a contradiction.
423
+ if (tabId.startsWith('page:')) {
424
+ const id = tabId.slice('page:'.length);
425
+ if (this.host.closePage === undefined) {
426
+ return Promise.reject(new BrowserError('browser: this carrier does not expose the pages it opens on its own, so that page cannot be closed', 'BROWSER_TAB_NOT_FOUND'));
427
+ }
428
+ return this.host.closePage(id);
429
+ }
398
430
  const found = this.locateTab(session, tabId);
399
431
  const s = found.s;
400
432
  const index = found.index;
@@ -1290,7 +1322,7 @@ export class ElectronBrowserProvider {
1290
1322
  // landed because the first had quietly established the target. Move the
1291
1323
  // pointer first so a click is self-contained.
1292
1324
  try {
1293
- await send({ type: 'mouseMoved', x, y }, 'move');
1325
+ await send({ type: 'mouseMoved', x, y, buttons: 0 }, 'move');
1294
1326
  }
1295
1327
  catch (error) {
1296
1328
  throw new BrowserError(`browser: click failed: ${String(error)}`, 'BROWSER_CLICK_FAILED', { cause: error });
@@ -1311,12 +1343,13 @@ export class ElectronBrowserProvider {
1311
1343
  x,
1312
1344
  y,
1313
1345
  button: 'left',
1346
+ buttons: 0,
1314
1347
  clickCount: 1,
1315
1348
  })
1316
1349
  .catch(() => { });
1317
1350
  };
1318
1351
  try {
1319
- await send({ type: 'mousePressed', x, y, button: 'left', clickCount: 1 }, 'press');
1352
+ await send({ type: 'mousePressed', x, y, button: 'left', buttons: 1, clickCount: 1 }, 'press');
1320
1353
  }
1321
1354
  catch (error) {
1322
1355
  // The press may still land late; release best-effort so the button is
@@ -1325,7 +1358,7 @@ export class ElectronBrowserProvider {
1325
1358
  throw new BrowserError(`browser: click failed: ${String(error)}`, 'BROWSER_CLICK_FAILED', { cause: error });
1326
1359
  }
1327
1360
  try {
1328
- await send({ type: 'mouseReleased', x, y, button: 'left', clickCount: 1 }, 'release');
1361
+ await send({ type: 'mouseReleased', x, y, button: 'left', buttons: 0, clickCount: 1 }, 'release');
1329
1362
  }
1330
1363
  catch (error) {
1331
1364
  // The press already landed; retry the release so the button is not
@@ -36,6 +36,7 @@
36
36
  export type BrowserLauncher = (path: string, args: readonly string[]) => ReturnType<typeof spawn>;
37
37
  import { spawn } from 'node:child_process';
38
38
  import type { ElectronBrowserViewHost, ElectronViewHandle } from './provider.js';
39
+ import type { CarrierPage } from '../browser/types.js';
39
40
  /** Which browser the user asked for. */
40
41
  export type BrowserChannel = 'bundled' | 'chrome' | 'edge' | 'auto' | 'sidebar';
41
42
  /** A resolved browser installation. */
@@ -111,6 +112,16 @@ export declare class SystemBrowserViewHost implements ElectronBrowserViewHost {
111
112
  */
112
113
  private clientSuspect;
113
114
  private child;
115
+ /**
116
+ * True when the browser in use was adopted from a previous run rather than launched here.
117
+ *
118
+ * Worth recording because the two differ on teardown: a browser this host spawned is its to
119
+ * kill, while one it merely took over belongs to a process that is already gone — closing it is
120
+ * still right (it holds the profile nobody else can use), but it is a decision, not cleanup.
121
+ */
122
+ private adoptedRunning;
123
+ /** Whether this connection's surplus blank pages have been collapsed already. */
124
+ private blanksNormalized;
114
125
  private starting;
115
126
  /**
116
127
  * Browsers that ignored a kill.
@@ -167,6 +178,25 @@ export declare class SystemBrowserViewHost implements ElectronBrowserViewHost {
167
178
  */
168
179
  private forgetBrowserState;
169
180
  private ensureClient;
181
+ /**
182
+ * Take over a browser already serving this profile, instead of racing it.
183
+ *
184
+ * A previous DSH process leaves its browser running: the child handle died with that process, so
185
+ * nothing here knows about it, and `start()`'s cleanup branch is skipped because it keys on
186
+ * `this.child`. Launching then does something worse than fail — on Windows Chrome hands the
187
+ * request to the instance already holding the profile and exits 0, and that exit is read as a
188
+ * launch failure. The user is told to go kill the leftovers by hand, on every restart, which is
189
+ * exactly what was reported.
190
+ *
191
+ * The launch loop deliberately refuses to trust a `<profile>/DevToolsActivePort` left by a
192
+ * previous launch, and it is right to: a stale port number can be reused by something that is
193
+ * not a browser at all. What makes adoption safe HERE is that this runs BEFORE launching, on a
194
+ * file that lives inside this plugin's own profile directory — only a browser started with this
195
+ * `--user-data-dir` writes it — and the endpoint is then asked to identify itself.
196
+ * @param why - a short phrase for the log, naming which attempt this is.
197
+ * @returns a connected client for the running browser, or undefined when there is none.
198
+ */
199
+ private adoptRunningBrowser;
170
200
  /**
171
201
  * Launch the browser with a private profile and connect over CDP.
172
202
  *
@@ -184,6 +214,45 @@ export declare class SystemBrowserViewHost implements ElectronBrowserViewHost {
184
214
  * make the plugin fall back for no reason.
185
215
  */
186
216
  available(): boolean;
217
+ /**
218
+ * Every page the browser currently holds, including ones this plugin did not create.
219
+ *
220
+ * Chrome and Edge own their own page creation. A click on a link that opens a new page — or any
221
+ * `window.open` — produces a page the provider never asked for, and nothing reports it back.
222
+ * Those pages are real: they hold memory and stay until something closes them. Measured on a real
223
+ * window: four pages present while `dsh_browser_list_tabs` reported one.
224
+ *
225
+ * Discovery is already on at connection time (see the blank-page normalisation), so
226
+ * `Target.getTargets` also lists pages that existed before this client connected.
227
+ * @returns the browser's pages, or an empty list when it cannot answer.
228
+ */
229
+ /**
230
+ * Every page the browser holds that THIS HOST did not open.
231
+ *
232
+ * Chrome and Edge own their own page creation. A click on a link that opens a new page — or any
233
+ * `window.open` — produces a page the provider never asked for, and nothing reports it back.
234
+ * Those pages are real: they hold memory and stay until something closes them. Measured on a real
235
+ * window: four pages present while `dsh_browser_list_tabs` reported one.
236
+ *
237
+ * Pages this host opened are EXCLUDED here rather than deduplicated by the caller, because the
238
+ * caller cannot do it: the provider knows a tab by its own id, the carrier knows a page by its
239
+ * CDP target id, and the two never match. The first version returned everything and relied on the
240
+ * caller to filter, which reported the same page twice.
241
+ *
242
+ * Discovery is already on at connection time (see the blank-page normalisation), so
243
+ * `Target.getTargets` also lists pages that existed before this client connected.
244
+ * @returns the browser's pages that are not ours, or an empty list when it cannot answer.
245
+ */
246
+ listPages(): Promise<readonly CarrierPage[]>;
247
+ /**
248
+ * Close one page by the target id {@link listPages} reported.
249
+ *
250
+ * `Target.closeTarget` is the browser's own close, so it works for pages this plugin never
251
+ * created. The page may already be gone if the human closed it in the meantime — the caller
252
+ * asked for it to be closed and it is, so that is not an error.
253
+ * @param id - the CDP target id.
254
+ */
255
+ closePage(id: string): Promise<void>;
187
256
  createView(): ElectronViewHandle;
188
257
  destroyView(handle: ElectronViewHandle): void;
189
258
  /** Nothing to show: the browser owns its own windows. */
@@ -236,6 +305,37 @@ export declare class SystemBrowserViewHost implements ElectronBrowserViewHost {
236
305
  private ensureSession;
237
306
  /** Create and attach the target for one view. Serialised by {@link ensureSession}. */
238
307
  private buildSession;
308
+ /**
309
+ * Take an unclaimed blank page, if the browser has one to spare.
310
+ *
311
+ * "Unclaimed" is what matters: a blank page the plugin is already driving belongs to a view, and
312
+ * handing the same target to a second view would make two views share one page. The page kept by
313
+ * {@link normalizeBlankPages} is exactly the unclaimed one this is looking for.
314
+ * @param client - the connected browser.
315
+ * @returns the target id to reuse, or undefined when a new page has to be created.
316
+ */
317
+ private adoptBlankTarget;
318
+ /** Create one page, refusing to go on without an id. */
319
+ private createTarget;
320
+ /**
321
+ * Collapse the blank pages a freshly started browser comes with down to one.
322
+ *
323
+ * Chrome opens more than one page when it is given a URL on top of its own start page, and this
324
+ * is the browser's behaviour rather than a session restore: measured on a cleanly exited profile
325
+ * as well as a crashed one, both pages appear within a second of launch. There is nothing to
326
+ * configure away — by the time a client can talk to the browser, both already exist.
327
+ *
328
+ * Left alone the surplus page is invisible to the plugin's tab model and therefore unclosable
329
+ * through the tools, so it survives every navigation and accumulates for as long as the window
330
+ * lives. One is kept deliberately: {@link buildSession} adopts it instead of creating a third.
331
+ *
332
+ * `Target.setDiscoverTargets` has to come first. Without it the browser may omit pages that
333
+ * existed before this client connected — which is precisely the page that needs closing, and the
334
+ * reason the first attempt at this fix failed silently.
335
+ * @param client - the connected browser.
336
+ * @returns how many surplus blank pages were closed.
337
+ */
338
+ private normalizeBlankPages;
239
339
  /** Views whose session is being created right now, so a second caller waits for the first. */
240
340
  private readonly sessionBuilding;
241
341
  /** sessionId -> targetId, so a destroyed view can close the right page. */