dsh-builtin-browser 0.4.1 → 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +1699 -0
  2. package/README.en.md +114 -33
  3. package/README.md +108 -32
  4. package/client.js +478 -29
  5. package/cordis.patch.yml +21 -0
  6. package/desktop-bridge/install.mjs +66 -6
  7. package/desktop-bridge/plugin-browser-bridge.js +426 -167
  8. package/docs/architecture.md +14 -4
  9. package/docs/tool-reference.md +13 -13
  10. package/docs/user-guide.md +58 -3
  11. package/docs/why-browser.md +1 -1
  12. package/lib/browser/types.d.ts +34 -3
  13. package/lib/browser-electron/bridge-connection.d.ts +49 -26
  14. package/lib/browser-electron/bridge-connection.js +185 -22
  15. package/lib/browser-electron/desktop-bridge-host.d.ts +69 -36
  16. package/lib/browser-electron/desktop-bridge-host.js +151 -86
  17. package/lib/browser-electron/entry.d.ts +1 -1
  18. package/lib/browser-electron/entry.js +133 -40
  19. package/lib/browser-electron/ephemeral-profile.d.ts +37 -0
  20. package/lib/browser-electron/ephemeral-profile.js +146 -0
  21. package/lib/browser-electron/history-store.d.ts +36 -2
  22. package/lib/browser-electron/history-store.js +76 -7
  23. package/lib/browser-electron/host-main.js +86 -28
  24. package/lib/browser-electron/provider.d.ts +106 -3
  25. package/lib/browser-electron/provider.js +407 -60
  26. package/lib/browser-electron/remote-host.d.ts +52 -3
  27. package/lib/browser-electron/remote-host.js +259 -25
  28. package/lib/browser-electron/rpc-ops.d.ts +18 -0
  29. package/lib/browser-electron/rpc-ops.js +36 -0
  30. package/lib/browser-electron/settings-route.d.ts +28 -0
  31. package/lib/browser-electron/settings-route.js +68 -0
  32. package/lib/browser-electron/settings-store.d.ts +40 -0
  33. package/lib/browser-electron/settings-store.js +160 -23
  34. package/lib/browser-electron/system-browser.d.ts +102 -12
  35. package/lib/browser-electron/system-browser.js +411 -51
  36. package/lib/index.js +0 -1
  37. package/lib/tool-browser/element-fields.d.ts +145 -0
  38. package/lib/tool-browser/element-fields.js +152 -0
  39. package/lib/tool-browser/index.js +149 -74
  40. package/package.json +10 -4
  41. package/scripts/verify-host-compat.mjs +195 -0
  42. package/src/browser/types.ts +34 -3
  43. package/src/browser-electron/bridge-connection.ts +179 -22
  44. package/src/browser-electron/desktop-bridge-host.ts +146 -84
  45. package/src/browser-electron/entry.ts +132 -39
  46. package/src/browser-electron/ephemeral-profile.ts +152 -0
  47. package/src/browser-electron/history-store.ts +81 -9
  48. package/src/browser-electron/host-main.ts +115 -36
  49. package/src/browser-electron/provider.ts +492 -69
  50. package/src/browser-electron/remote-host.ts +276 -38
  51. package/src/browser-electron/rpc-ops.ts +41 -0
  52. package/src/browser-electron/settings-route.ts +87 -0
  53. package/src/browser-electron/settings-store.ts +173 -24
  54. package/src/browser-electron/system-browser.ts +408 -39
  55. package/src/index.ts +3 -2
  56. package/src/tool-browser/element-fields.ts +167 -0
  57. package/src/tool-browser/index.ts +155 -76
@@ -44,6 +44,22 @@ export interface BrowserSettings {
44
44
  readonly credentials: {
45
45
  readonly allowRead: boolean;
46
46
  };
47
+ /**
48
+ * What the agent is allowed to DO, as opposed to what it may look at.
49
+ *
50
+ * These are OPERATOR switches: the settings panel is not reachable from the model, so a
51
+ * value here refuses whatever the model asks for, and no tool can lift it. That is what
52
+ * separates them from `browser_restrict`, which is the model's own soft guardrail. All
53
+ * three default to on, which is the documented out-of-the-box behaviour.
54
+ */
55
+ readonly actions: {
56
+ /** Run arbitrary page scripts in the active tab (`browser_execute`). */
57
+ readonly allowExecute: boolean;
58
+ /** Download a URL to disk (`browser_download`). */
59
+ readonly allowDownload: boolean;
60
+ /** Write cookies — importing login state (`browser_auth` restore). */
61
+ readonly allowCredentialWrite: boolean;
62
+ };
47
63
  }
48
64
  /** Defaults for every switch: the documented out-of-the-box behaviour. */
49
65
  export declare const DEFAULT_SETTINGS: BrowserSettings;
@@ -90,3 +106,27 @@ export declare class SettingsStore {
90
106
  */
91
107
  update(patch: unknown): BrowserSettings;
92
108
  }
109
+ /**
110
+ * Replace a file's contents atomically: write a sibling temporary file, then rename it over
111
+ * the target.
112
+ *
113
+ * `writeFileSync(target, …)` truncates and rewrites in place, so a crash, a full disk or an
114
+ * antivirus lock in the middle leaves HALF a JSON document. That used to be silent: the reader
115
+ * resolved an unparseable file to the defaults, so a torn write flipped `credentials.allowRead`
116
+ * and the action switches back ON, and the next successful update wrote those defaults to disk
117
+ * for good. The read side no longer does that (see {@link SETTINGS_WHEN_UNREADABLE}) — but the
118
+ * write side still has to stop producing torn files at all, because half a document is not
119
+ * something this plugin should ever leave behind.
120
+ *
121
+ * The rename is the commit point: a reader sees either the old document or the new one,
122
+ * never a partial one. It stays in the same directory so the rename is atomic on Windows
123
+ * as well as POSIX (`renameSync` maps to MoveFileEx there: same-volume replaces are
124
+ * atomic, cross-volume ones are not supported at all).
125
+ *
126
+ * The temporary is written with a unique suffix rather than a fixed `.tmp`, so two writers
127
+ * (the settings panel and a second DSH process) cannot rename each other's half-written
128
+ * file into place. Shared with the history store, which had the same hole.
129
+ * @param file - the target path.
130
+ * @param contents - the complete new document.
131
+ */
132
+ export declare function writeFileAtomic(file: string, contents: string): void;
@@ -8,7 +8,7 @@
8
8
  * defaults" rather than an error.
9
9
  * @module dsh-browser/browser-electron/settings-store
10
10
  */
11
- import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
11
+ import { mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
12
12
  import { homedir } from 'node:os';
13
13
  import { dirname, join } from 'node:path';
14
14
  /** Defaults for every switch: the documented out-of-the-box behaviour. */
@@ -22,6 +22,7 @@ export const DEFAULT_SETTINGS = {
22
22
  // it starts a real Chrome/Edge with its own profile.
23
23
  browser: { channel: 'bundled' },
24
24
  credentials: { allowRead: true },
25
+ actions: { allowExecute: true, allowDownload: true, allowCredentialWrite: true },
25
26
  };
26
27
  /**
27
28
  * Absolute path of the settings file, beside the browser profile and the
@@ -36,6 +37,37 @@ export function settingsPath() {
36
37
  function bool(value, fallback) {
37
38
  return typeof value === 'boolean' ? value : fallback;
38
39
  }
40
+ /**
41
+ * Coerce one boolean field that GATES A CAPABILITY.
42
+ *
43
+ * The difference from {@link bool} is what a mistyped value means. Absent still takes the
44
+ * default — a document written before the switch existed is the documented behaviour. But
45
+ * present-and-not-a-boolean is a document this code cannot read, and for a switch that gates
46
+ * something the operator chose to limit, unreadable resolves to OFF. `bool`'s fallback made
47
+ * `"false"` (a string), `null` (a truncated write) and `1` all mean the same as `true`, so a
48
+ * single dropped byte reopened every capability that had been turned off.
49
+ * @param value - the raw value from the settings document.
50
+ * @param fallback - the default, used only when the field is absent.
51
+ * @returns the value when it is a boolean, `false` when it is anything else.
52
+ */
53
+ function gate(value, fallback) {
54
+ if (value === undefined)
55
+ return fallback;
56
+ return value === true;
57
+ }
58
+ /**
59
+ * What an unreadable document resolves to.
60
+ *
61
+ * Only the capability gates are refused; everything else keeps its default. The distinction is
62
+ * deliberate: refusing history, presentation or browser choice would punish the user for a
63
+ * corrupt file without protecting anything, while refusing the gates is the whole point —
64
+ * "the operator's intent cannot be read" must not resolve to "the operator permits".
65
+ */
66
+ const SETTINGS_WHEN_UNREADABLE = {
67
+ ...DEFAULT_SETTINGS,
68
+ credentials: { allowRead: false },
69
+ actions: { allowExecute: false, allowDownload: false, allowCredentialWrite: false },
70
+ };
39
71
  /**
40
72
  * Coerce the browser channel, keeping the default when it is not one of the
41
73
  * known choices. A hand-edited typo must not silently select a different browser.
@@ -43,18 +75,42 @@ function bool(value, fallback) {
43
75
  * @returns a valid channel.
44
76
  */
45
77
  function resolveChannel(value) {
46
- return value === 'chrome' || value === 'edge' || value === 'auto' || value === 'bundled'
78
+ return value === 'chrome' || value === 'edge' || value === 'auto' || value === 'bundled' || value === 'sidebar'
47
79
  ? value
48
80
  : DEFAULT_SETTINGS.browser.channel;
49
81
  }
50
82
  /** Coerce one positive-integer field, keeping the default when invalid. */
51
83
  function count(value, fallback) {
52
- return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
84
+ if (typeof value !== 'number' || !Number.isFinite(value))
85
+ return fallback;
86
+ const floored = Math.floor(value);
87
+ // A fractional value below 1 floors to 0, and 0 is not "very few" in either place these are
88
+ // used: as a history entry cap it means `slice(len - 0)`, i.e. keep nothing, so a hand-typed
89
+ // `0.5` silently wiped the history file on the next rewrite. Anything that survives the
90
+ // floor has to still be a usable limit.
91
+ return floored > 0 ? floored : fallback;
53
92
  }
54
93
  /** Read one nested section of an unknown document, or an empty object. */
94
+ /**
95
+ * Read one section of the document, distinguishing ABSENT from UNREADABLE.
96
+ *
97
+ * Absent (the key is not there at all) is a document written before the section existed, and
98
+ * the defaults apply — that is the documented behaviour. Present-but-not-an-object
99
+ * (`"actions": false`, `null` from a truncated write, a stray string) is a document this code
100
+ * cannot read, and returning `{}` for it made every gate inside look absent, so each one took
101
+ * its permissive default. A whole section mistyped is exactly the shape a careless hand edit
102
+ * produces, and it opened every capability the section was there to bound.
103
+ * @param source - the parsed document.
104
+ * @param key - the section name.
105
+ * @returns the section, or undefined when it is present but not an object.
106
+ */
55
107
  function section(source, key) {
56
108
  const value = source[key];
57
- return typeof value === 'object' && value !== null ? value : {};
109
+ if (value === undefined)
110
+ return {};
111
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
112
+ ? value
113
+ : undefined;
58
114
  }
59
115
  /**
60
116
  * Resolve a raw settings document (already parsed, shape unknown) into the full
@@ -71,21 +127,44 @@ export function resolveSettings(raw) {
71
127
  const vision = section(source, 'vision');
72
128
  const credentials = section(source, 'credentials');
73
129
  const browser = section(source, 'browser');
130
+ const actions = section(source, 'actions');
131
+ // A section that is present but unreadable refuses its gates rather than defaulting them
132
+ // open. The rest of a broken section keeps its defaults: refusing history or presentation
133
+ // would punish the user for a bad file without protecting anything.
134
+ const credentialsUsable = credentials ?? {};
135
+ const actionsUsable = actions ?? {};
74
136
  return {
75
137
  history: {
76
- enabled: bool(history.enabled, DEFAULT_SETTINGS.history.enabled),
77
- maxEntries: count(history.maxEntries, DEFAULT_SETTINGS.history.maxEntries),
78
- maxAgeDays: count(history.maxAgeDays, DEFAULT_SETTINGS.history.maxAgeDays),
138
+ enabled: bool(history?.enabled, DEFAULT_SETTINGS.history.enabled),
139
+ maxEntries: count(history?.maxEntries, DEFAULT_SETTINGS.history.maxEntries),
140
+ maxAgeDays: count(history?.maxAgeDays, DEFAULT_SETTINGS.history.maxAgeDays),
79
141
  },
80
- cookies: { persist: bool(cookies.persist, DEFAULT_SETTINGS.cookies.persist) },
142
+ cookies: { persist: bool(cookies?.persist, DEFAULT_SETTINGS.cookies.persist) },
81
143
  ui: {
82
- autoExpandOnce: bool(ui.autoExpandOnce, DEFAULT_SETTINGS.ui.autoExpandOnce),
83
- closeWithSession: bool(ui.closeWithSession, DEFAULT_SETTINGS.ui.closeWithSession),
84
- virtualCursor: bool(ui.virtualCursor, DEFAULT_SETTINGS.ui.virtualCursor),
144
+ autoExpandOnce: bool(ui?.autoExpandOnce, DEFAULT_SETTINGS.ui.autoExpandOnce),
145
+ closeWithSession: bool(ui?.closeWithSession, DEFAULT_SETTINGS.ui.closeWithSession),
146
+ virtualCursor: bool(ui?.virtualCursor, DEFAULT_SETTINGS.ui.virtualCursor),
147
+ },
148
+ vision: { strategy: vision?.strategy === 'nonVisual' ? 'nonVisual' : DEFAULT_SETTINGS.vision.strategy },
149
+ browser: { channel: resolveChannel(browser?.channel) },
150
+ credentials: {
151
+ // `gate` returns false for anything present-and-not-a-boolean, so an unreadable section
152
+ // is refused here for free: the section itself is the "present but wrong" value.
153
+ allowRead: credentials === undefined
154
+ ? false
155
+ : gate(credentialsUsable.allowRead, DEFAULT_SETTINGS.credentials.allowRead),
156
+ },
157
+ actions: {
158
+ allowExecute: actions === undefined
159
+ ? false
160
+ : gate(actionsUsable.allowExecute, DEFAULT_SETTINGS.actions.allowExecute),
161
+ allowDownload: actions === undefined
162
+ ? false
163
+ : gate(actionsUsable.allowDownload, DEFAULT_SETTINGS.actions.allowDownload),
164
+ allowCredentialWrite: actions === undefined
165
+ ? false
166
+ : gate(actionsUsable.allowCredentialWrite, DEFAULT_SETTINGS.actions.allowCredentialWrite),
85
167
  },
86
- vision: { strategy: vision.strategy === 'nonVisual' ? 'nonVisual' : DEFAULT_SETTINGS.vision.strategy },
87
- browser: { channel: resolveChannel(browser.channel) },
88
- credentials: { allowRead: bool(credentials.allowRead, DEFAULT_SETTINGS.credentials.allowRead) },
89
168
  };
90
169
  }
91
170
  /**
@@ -115,20 +194,35 @@ export class SettingsStore {
115
194
  */
116
195
  get() {
117
196
  let mtimeMs = -1;
118
- let text;
119
197
  try {
120
- if (existsSync(this.file)) {
121
- mtimeMs = statSync(this.file).mtimeMs;
122
- text = readFileSync(this.file, 'utf8');
123
- }
198
+ // stat directly: existsSync costs about thirteen times what the stat does, and the
199
+ // stat already throws when the file is missing — which the catch below handles.
200
+ mtimeMs = statSync(this.file).mtimeMs;
124
201
  }
125
202
  catch {
126
203
  // Unreadable file: fall through to defaults below.
127
204
  mtimeMs = -1;
128
- text = undefined;
129
205
  }
206
+ // Return the cache BEFORE reading. The read used to happen first "just in case", which
207
+ // made the cache pointless: every call paid for a synchronous read of the whole file and
208
+ // then discarded it. This is on the path of every tool call that consults a setting, so
209
+ // it was a stat plus a read per call where a single stat will do.
130
210
  if (this.cached !== undefined && mtimeMs === this.cachedMtimeMs)
131
211
  return this.cached;
212
+ let text;
213
+ if (mtimeMs !== -1) {
214
+ try {
215
+ text = readFileSync(this.file, 'utf8');
216
+ }
217
+ catch {
218
+ // It was there a moment ago (the stat above) and cannot be read now: permissions, a
219
+ // lock, a directory in the way. That is not "no document" — the file is not absent, so
220
+ // what it said is unknown, and unknown resolves to the refusing document.
221
+ this.cached = SETTINGS_WHEN_UNREADABLE;
222
+ this.cachedMtimeMs = mtimeMs;
223
+ return this.cached;
224
+ }
225
+ }
132
226
  try {
133
227
  // A leading BOM is what ordinary Windows editors (Notepad, PowerShell's
134
228
  // `Set-Content -Encoding utf8`) leave behind, and `JSON.parse` rejects it.
@@ -139,8 +233,12 @@ export class SettingsStore {
139
233
  this.cached = body === undefined ? DEFAULT_SETTINGS : resolveSettings(JSON.parse(body));
140
234
  }
141
235
  catch {
142
- // Malformed JSON: behave as defaults rather than fail startup.
143
- this.cached = DEFAULT_SETTINGS;
236
+ // A document that EXISTS but cannot be parsed is not the same as no document at all: the
237
+ // first is a corrupted or half-written file whose contents were the operator's decisions,
238
+ // the second is a first run. Resolving both to the defaults meant a kill during the write,
239
+ // or one stray character, silently reopened the capabilities that had been switched off —
240
+ // so this side refuses them instead (see SETTINGS_WHEN_UNREADABLE).
241
+ this.cached = SETTINGS_WHEN_UNREADABLE;
144
242
  }
145
243
  this.cachedMtimeMs = mtimeMs;
146
244
  return this.cached;
@@ -155,7 +253,7 @@ export class SettingsStore {
155
253
  const merged = resolveSettings(deepMerge(current, patch));
156
254
  try {
157
255
  mkdirSync(dirname(this.file), { recursive: true });
158
- writeFileSync(this.file, `${JSON.stringify(merged, null, 2)}\n`);
256
+ writeFileAtomic(this.file, `${JSON.stringify(merged, null, 2)}\n`);
159
257
  }
160
258
  catch (error) {
161
259
  // Do NOT publish the new value as cached, and do not pretend the file changed: a
@@ -171,6 +269,45 @@ export class SettingsStore {
171
269
  return merged;
172
270
  }
173
271
  }
272
+ /**
273
+ * Replace a file's contents atomically: write a sibling temporary file, then rename it over
274
+ * the target.
275
+ *
276
+ * `writeFileSync(target, …)` truncates and rewrites in place, so a crash, a full disk or an
277
+ * antivirus lock in the middle leaves HALF a JSON document. That used to be silent: the reader
278
+ * resolved an unparseable file to the defaults, so a torn write flipped `credentials.allowRead`
279
+ * and the action switches back ON, and the next successful update wrote those defaults to disk
280
+ * for good. The read side no longer does that (see {@link SETTINGS_WHEN_UNREADABLE}) — but the
281
+ * write side still has to stop producing torn files at all, because half a document is not
282
+ * something this plugin should ever leave behind.
283
+ *
284
+ * The rename is the commit point: a reader sees either the old document or the new one,
285
+ * never a partial one. It stays in the same directory so the rename is atomic on Windows
286
+ * as well as POSIX (`renameSync` maps to MoveFileEx there: same-volume replaces are
287
+ * atomic, cross-volume ones are not supported at all).
288
+ *
289
+ * The temporary is written with a unique suffix rather than a fixed `.tmp`, so two writers
290
+ * (the settings panel and a second DSH process) cannot rename each other's half-written
291
+ * file into place. Shared with the history store, which had the same hole.
292
+ * @param file - the target path.
293
+ * @param contents - the complete new document.
294
+ */
295
+ export function writeFileAtomic(file, contents) {
296
+ const temporary = `${file}.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`;
297
+ try {
298
+ writeFileSync(temporary, contents);
299
+ renameSync(temporary, file);
300
+ }
301
+ catch (error) {
302
+ // The rename is the commit point, so a failure before it leaves the target untouched —
303
+ // only the temporary has to go, and it must not be left behind as garbage.
304
+ try {
305
+ rmSync(temporary, { force: true });
306
+ }
307
+ catch { /* the temp is already the least of it */ }
308
+ throw error;
309
+ }
310
+ }
174
311
  /** One-level-deep merge over the settings sections (patch wins). */
175
312
  function deepMerge(base, patch) {
176
313
  if (typeof patch !== 'object' || patch === null)
@@ -24,9 +24,20 @@
24
24
  * only the carrier differs.
25
25
  * @module dsh-browser/browser-electron/system-browser
26
26
  */
27
+ /**
28
+ * Starts a browser process and returns a handle to it.
29
+ *
30
+ * Injectable so tests can supply something that speaks CDP — the seam a recovery test needs
31
+ * and could not have while the launch was hardcoded to a stub that rejects Chromium's args.
32
+ * @param path - the executable to run.
33
+ * @param args - its arguments.
34
+ * @returns the child process.
35
+ */
36
+ export type BrowserLauncher = (path: string, args: readonly string[]) => ReturnType<typeof spawn>;
37
+ import { spawn } from 'node:child_process';
27
38
  import type { ElectronBrowserViewHost, ElectronViewHandle } from './provider.js';
28
39
  /** Which browser the user asked for. */
29
- export type BrowserChannel = 'bundled' | 'chrome' | 'edge' | 'auto';
40
+ export type BrowserChannel = 'bundled' | 'chrome' | 'edge' | 'auto' | 'sidebar';
30
41
  /** A resolved browser installation. */
31
42
  export interface DetectedBrowser {
32
43
  /** Which product this is. */
@@ -59,13 +70,26 @@ export declare function detectBrowser(channel: BrowserChannel, env?: NodeJS.Proc
59
70
  * @param platform - the platform whose names and locations apply.
60
71
  * @returns the launcher names (as PATH lookups) and the fixed locations checked.
61
72
  */
62
- export declare function searchSummary(kind: DetectedBrowser['kind'], platform?: NodeJS.Platform): string[];
73
+ export declare function searchSummary(kind: DetectedBrowser['kind'], platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv): string[];
63
74
  /** A system browser driven over CDP, presented as a browser view host. */
64
75
  export declare class SystemBrowserViewHost implements ElectronBrowserViewHost {
65
76
  private readonly browser;
66
77
  private readonly profileDir;
67
78
  private readonly extraArgs;
68
79
  private readonly ephemeralDir?;
80
+ private readonly launcher;
81
+ /**
82
+ * viewId -> the session that view rides, and the client that issued it.
83
+ *
84
+ * The client is part of the value on purpose. A session id means nothing to a browser other
85
+ * than the one that handed it out, and this host replaces browsers routinely: the process
86
+ * dies, or its debugging socket closes while the process lives on. Keeping only the id meant
87
+ * the host could start a NEW browser successfully and then send every command to it carrying
88
+ * a session the old one had issued — `Session with given id not found`, on every call, with a
89
+ * browser that answers /json/version perfectly well and nothing the user can do short of
90
+ * restarting DSH. Comparing identity catches it with no round trip; validating each cached
91
+ * session against the browser would put one on every page command.
92
+ */
69
93
  private readonly views;
70
94
  private disposed;
71
95
  /**
@@ -77,16 +101,40 @@ export declare class SystemBrowserViewHost implements ElectronBrowserViewHost {
77
101
  * spawned when a view is actually needed.
78
102
  */
79
103
  private client;
104
+ /**
105
+ * Set when a command went unanswered rather than being refused.
106
+ *
107
+ * A timeout says nothing about the connection: the page may simply be slow. It marks the
108
+ * client for a cheap check before the next command trusts it, which keeps a slow page
109
+ * from being mistaken for a dead browser while still catching one that has truly stopped
110
+ * answering.
111
+ */
112
+ private clientSuspect;
80
113
  private child;
81
114
  private starting;
115
+ /**
116
+ * Browsers that ignored a kill.
117
+ *
118
+ * They are no longer this host's child — a replacement has taken that slot — but they
119
+ * are still running, still holding the profile, and still something release must try to
120
+ * stop. Forgetting them is what allowed a launch this host could not undo.
121
+ */
122
+ private readonly stubborn;
82
123
  /**
83
124
  * @param browser - the detected installation to launch on first use.
84
125
  * @param profileDir - a plugin-owned directory; the user's own profile is never touched.
85
126
  * @param extraArgs - additional Chromium switches.
86
127
  * @param ephemeralDir - a directory to delete on release, when the user has turned
87
128
  * persistence off so no login state outlives the session.
129
+ * @param launcher - how to start the browser, for tests.
130
+ *
131
+ * Recovery after the browser dies was untestable without this: the suite spawns a stub
132
+ * with Chromium's arguments, and a stub that is not a browser rejects them and exits, so
133
+ * no CDP endpoint ever appears and no session is ever created. Verified by mutation —
134
+ * removing every sessions.clear() left the recovery tests green. A launcher lets a test
135
+ * provide something that actually speaks CDP, so the state those tests describe exists.
88
136
  */
89
- constructor(browser: DetectedBrowser, profileDir: string, extraArgs?: readonly string[], ephemeralDir?: string | undefined);
137
+ constructor(browser: DetectedBrowser, profileDir: string, extraArgs?: readonly string[], ephemeralDir?: string | undefined, launcher?: BrowserLauncher);
90
138
  /** Which product this host would drive (for diagnostics). */
91
139
  get kind(): DetectedBrowser['kind'];
92
140
  /** Whether the browser has actually been started yet (diagnostics and tests). */
@@ -95,6 +143,29 @@ export declare class SystemBrowserViewHost implements ElectronBrowserViewHost {
95
143
  * The CDP client, starting the browser on first use.
96
144
  * @returns the connected client.
97
145
  */
146
+ /**
147
+ * Whether a client that reports itself open is actually answering.
148
+ *
149
+ * An open socket proves nothing: a browser can hold the connection and never reply, and
150
+ * that state must not be cached, or every later command waits out its own timeout. Asking
151
+ * the browser something cheap is a fact; reading the failure text is a guess, and the
152
+ * guess was wrong in both directions (it killed healthy browsers on a slow page, and it
153
+ * mistook the browser's own free text for a verdict).
154
+ *
155
+ * This is only reached when the socket is open, so the common path costs nothing.
156
+ * @param client - the client to question.
157
+ * @returns true when it answered.
158
+ */
159
+ private probeClient;
160
+ /**
161
+ * Forget which browser issued what, after that browser is gone.
162
+ *
163
+ * The two maps must move together: `views` maps a view id to a session id and `sessions`
164
+ * maps a session id to a target id, so clearing one alone leaves a mapping the next command
165
+ * will act on. Every path that abandons a browser calls this rather than spelling the pair
166
+ * out — four separate copies is how the third one came to be missing.
167
+ */
168
+ private forgetBrowserState;
98
169
  private ensureClient;
99
170
  /**
100
171
  * Launch the browser with a private profile and connect over CDP.
@@ -117,10 +188,23 @@ export declare class SystemBrowserViewHost implements ElectronBrowserViewHost {
117
188
  destroyView(handle: ElectronViewHandle): void;
118
189
  /** Nothing to show: the browser owns its own windows. */
119
190
  showView(): void;
120
- /** Same as {@link showView}. */
121
- presentView(): Promise<void>;
122
- /** The browser owns window grouping. */
123
- groupView(): void;
191
+ /**
192
+ * Bring the target's page to the front, then report success.
193
+ *
194
+ * The comment on `focus` below used to say this was impossible — that raising the page needs
195
+ * `Page.bringToFront` on a target session, which is a view-level concern the host cannot
196
+ * reach. The handle this method is handed IS that view, and it can send commands, so the
197
+ * capability was here all along; it just was not used. Without it `presentView` was an empty
198
+ * async method, so the provider's whole presentation barrier — the one that stops
199
+ * `Input.*` from being silently dropped for a view with no display surface, and reports
200
+ * `BROWSER_VIEW_NOT_PRESENTED` when it cannot present — never ran on this carrier.
201
+ *
202
+ * A failure is swallowed rather than propagated: a real Chrome window that refuses to come
203
+ * forward is not a reason to refuse the click that follows. The barrier exists to order the
204
+ * two, and the order is honoured either way.
205
+ * @param handle - the view to present.
206
+ */
207
+ presentView(handle: ElectronViewHandle): Promise<void>;
124
208
  /**
125
209
  * Best effort at bringing the browser's window forward.
126
210
  *
@@ -134,10 +218,6 @@ export declare class SystemBrowserViewHost implements ElectronBrowserViewHost {
134
218
  * view-level concern and not available to the host.
135
219
  */
136
220
  focus(): Promise<void>;
137
- /** The browser reports its own window lifecycle. */
138
- onUserAction(): void;
139
- /** The browser reports its own window lifecycle. */
140
- onViewClosed(): void;
141
221
  /**
142
222
  * Close the browser we launched, if we ever launched one.
143
223
  *
@@ -146,8 +226,18 @@ export declare class SystemBrowserViewHost implements ElectronBrowserViewHost {
146
226
  * browser) simply tears down its bookkeeping.
147
227
  */
148
228
  dispose(): void;
149
- /** The flattened session for a view, creating its page on first use. */
229
+ /**
230
+ * The flattened session for a view, creating its page on first use.
231
+ *
232
+ * No cache check here: the only caller has already established that the cached session does
233
+ * not belong to the client it is about to use. Re-checking would mean either a round trip or
234
+ * a second copy of that rule.
235
+ */
150
236
  private ensureSession;
237
+ /** Create and attach the target for one view. Serialised by {@link ensureSession}. */
238
+ private buildSession;
239
+ /** Views whose session is being created right now, so a second caller waits for the first. */
240
+ private readonly sessionBuilding;
151
241
  /** sessionId -> targetId, so a destroyed view can close the right page. */
152
242
  private readonly sessions;
153
243
  }