dsh-builtin-browser 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -120,9 +120,15 @@ The browser's **visible view**, the **browser column layout**, and the **column-
120
120
  ## Known limitations
121
121
 
122
122
  - Screenshots are PNG-only (CDP JPEG hangs on Electron 43); JPEG awaits a non-CDP conversion path.
123
+ - Self-hosted captures prefer Electron's native `capturePage` (CDP `captureScreenshot` can hang with multiple views in the window); the target tab is raised before capturing.
124
+ - **Electron >= 40 is recommended**: 33.x has a compositor defect that intermittently breaks capture ("display surface not available"). The plugin automatically picks the newest Electron in the environment (peer dependency > `ELECTRON_PATH` > newest among anchors/pnpm store).
123
125
  - `fullPage` capture is flaky under software compositing on some hosts.
124
- - Session lifecycle is plugin-level (one session shared by the model), not per-agent yet.
126
+ - Sessions are isolated per task (each DSH session gets its own browser session with own tabs/history; concurrent tasks never interfere; calls within one task reuse the same session). Logins (cookies) are shared and can be exported/restored with `browser_auth`.
127
+ - CAPTCHA cannot be solved automatically: snapshots flag detected challenges (`browser_challenge` checks explicitly); ask the human to complete it in the shared window instead of retrying.
125
128
  - Private mode (`privateMode`) is not implemented: it needs Electron session partitioning, which is host-layer territory; this plugin does not promise it.
129
+ - `browser_download` fetches in the page context (keeps logins) and is subject to same-origin/CORS constraints; single files are capped at 256 MB.
130
+ - The `browser_auth` cookie round-trip does not preserve `hostOnly`/`sameSite` (host-only cookies come back as domain cookies); it is available on the self-hosted browser only.
131
+ - After a self-hosted child crash the browser host restarts automatically, but sessions opened before the crash are gone — call `browser_reset_session` to rebuild.
126
132
  - This plugin contains no browser-column UI — that is host-shell territory; do not treat "browser column" as a plugin feature.
127
133
 
128
134
  ## License
package/README.md CHANGED
@@ -127,10 +127,15 @@ provider 按构造与 Electron 解耦:它通过 `ElectronBrowserViewHost` 接缝
127
127
  ## 已知限制
128
128
 
129
129
  - 截图仅 PNG(CDP JPEG 在 Electron 43 上挂起);JPEG 等待非 CDP 转换路径。
130
+ - 自托管截图优先走 Electron 原生 `capturePage`(CDP `captureScreenshot` 在多视图下会挂起);截图前自动把目标标签置顶。
131
+ - **Electron 版本建议 ≥ 40**:33.x 存在合成器缺陷,会间歇性导致截图失败("display surface not available")。插件会**自动选择环境中最新版本的 Electron**(peer 依赖 > `ELECTRON_PATH` > 锚点/pnpm store 中最新版)。
130
132
  - 部分主机在软件合成下 `fullPage` 截图不稳定。
131
133
  - 会话按任务隔离:每个调用方任务(DSH 会话)拥有独立的浏览器会话(独立标签页与历史),并发任务互不干扰;同一任务的多次调用复用同一会话。登录态(cookie)为共享,可用 `browser_auth` 导出/恢复。
132
134
  - 人机验证(CAPTCHA)无法自动解决:快照会标注检测到的挑战(`browser_challenge` 可显式检查),此时应请用户在共享窗口中人工完成,而不是反复重试。
133
135
  - 无痕模式(`privateMode`)未实现:它需要 Electron 的 session 分区能力,属于宿主层,本插件不承诺。
136
+ - `browser_download` 在页面上下文内 `fetch`(带登录态),受同源/CORS 约束;单文件上限 256MB。
137
+ - `browser_auth` 的 cookie 往返不保留 `hostOnly`/`sameSite` 字段(host-only cookie 恢复后变成 domain cookie);仅自托管浏览器可用。
138
+ - 自托管浏览器子进程崩溃后会自动重启,但崩溃前已打开的会话视图已失效,调用 `browser_reset_session` 重建即可。
134
139
  - 本插件不含浏览器列 UI——那是宿主外壳的配套,别把"浏览器列"当成插件能力。
135
140
 
136
141
  ## 许可证
@@ -16,8 +16,23 @@
16
16
  import { app, BrowserWindow, WebContentsView } from 'electron';
17
17
  import { createInterface } from 'node:readline';
18
18
  import { createConnection } from 'node:net';
19
+ import { join } from 'node:path';
20
+ // Isolate this host's profile from the DSH app's default Electron userData:
21
+ // several Electron instances sharing Roaming\Electron fight over the GPU
22
+ // cache/session locks, which can leave the window without a display surface
23
+ // (capturePage then fails). A dedicated userData also persists cookies across
24
+ // host restarts (on top of browser_auth). Must run before app is ready.
25
+ try {
26
+ const base = process.env.DSH_HOME ?? app.getPath('appData');
27
+ app.setPath('userData', join(base, 'dsh-builtin-browser-host'));
28
+ }
29
+ catch (error) {
30
+ process.stderr.write(`[dsh-browser host] userData setup failed: ${String(error)}\n`);
31
+ }
19
32
  /** CDP protocol version attached to every view's debugger. */
20
33
  const CDP_VERSION = '1.3';
34
+ /** Download cap: the body is shipped base64 as one JSON line; bound the memory. */
35
+ const MAX_DOWNLOAD_BYTES = 256 * 1024 * 1024;
21
36
  /** Views by the id the parent assigned at createView time. */
22
37
  const views = new Map();
23
38
  /** The single browser window; created lazily on first createView. */
@@ -48,6 +63,9 @@ async function handle(op, msg) {
48
63
  window.on('closed', () => { window = undefined; });
49
64
  }
50
65
  const view = new WebContentsView();
66
+ // Attach the debugger BEFORE the view can be seen: an attach failure
67
+ // then leaves nothing in the window (no visible ghost view).
68
+ view.webContents.debugger.attach(CDP_VERSION);
51
69
  // New views start hidden: with several sessions/tabs only the shown
52
70
  // one may be visible (they stack in contentView child order).
53
71
  view.setVisible(false);
@@ -56,7 +74,6 @@ async function handle(op, msg) {
56
74
  view.setBounds({ x: 0, y: 0, width: width ?? 0, height: height ?? 0 });
57
75
  if (views.size === 0)
58
76
  view.setVisible(true);
59
- view.webContents.debugger.attach(CDP_VERSION);
60
77
  views.set(viewId, { webContentsView: view });
61
78
  reply(msg.id, { ok: true });
62
79
  return;
@@ -117,6 +134,94 @@ async function handle(op, msg) {
117
134
  reply(msg.id, { ok: true, result });
118
135
  return;
119
136
  }
137
+ case 'capture': {
138
+ const viewId = msg.viewId;
139
+ if (viewId === undefined)
140
+ throw new Error('capture missing viewId');
141
+ const entry = views.get(viewId);
142
+ if (entry === undefined)
143
+ throw new Error(`capture: unknown view ${viewId}`);
144
+ // Two complementary paths, because each has a failure mode:
145
+ // - capturePage: fast and reliable with several WebContentsViews in
146
+ // the window, but needs a live display surface (fails when the
147
+ // window is minimized/occluded/unpainted).
148
+ // - CDP Page.captureScreenshot: works without a display surface, but
149
+ // can hang when another hidden WebContentsView exists in the window.
150
+ // Try capturePage first (show/focus/restore + one retry), then CDP.
151
+ if (window !== undefined) {
152
+ try {
153
+ if (!window.isVisible())
154
+ window.show();
155
+ }
156
+ catch { /* closing */ }
157
+ try {
158
+ window.restore();
159
+ }
160
+ catch { /* not minimized */ }
161
+ window.focus();
162
+ }
163
+ let base64 = '';
164
+ try {
165
+ let image;
166
+ try {
167
+ image = await entry.webContentsView.webContents.capturePage();
168
+ }
169
+ catch (error) {
170
+ process.stderr.write(`[dsh-browser host] capturePage failed: ${String(error)}\n`);
171
+ await new Promise(resolve => setTimeout(resolve, 400));
172
+ image = await entry.webContentsView.webContents.capturePage();
173
+ }
174
+ const png = image.toPNG();
175
+ if (png.length > 0)
176
+ base64 = png.toString('base64');
177
+ }
178
+ catch (error) {
179
+ const state = window !== undefined
180
+ ? JSON.stringify({
181
+ win: { visible: window.isVisible(), minimized: window.isMinimized(), focused: window.isFocused() },
182
+ })
183
+ : 'no-window';
184
+ process.stderr.write(`[dsh-browser host] capturePage retry failed: ${String(error)} state=${state}\n`);
185
+ base64 = '';
186
+ }
187
+ if (base64 === '') {
188
+ // CDP fallback. Page.captureScreenshot can hang when OTHER views
189
+ // (especially hidden attach-first ones) are in the window, so
190
+ // temporarily detach the siblings, capture in single-view state,
191
+ // then restore them (target stays on top).
192
+ const siblings = [...views.values()].filter(v => v !== entry);
193
+ for (const v of siblings) {
194
+ try {
195
+ window?.contentView.removeChildView(v.webContentsView);
196
+ }
197
+ catch { /* already gone */ }
198
+ }
199
+ try {
200
+ const shot = await entry.webContentsView.webContents.debugger.sendCommand('Page.captureScreenshot', {});
201
+ const data = shot.data;
202
+ if (typeof data === 'string' && data.length > 0)
203
+ base64 = data;
204
+ }
205
+ finally {
206
+ for (const v of siblings) {
207
+ try {
208
+ window?.contentView.addChildView(v.webContentsView);
209
+ }
210
+ catch { /* destroyed */ }
211
+ }
212
+ try {
213
+ window?.contentView.removeChildView(entry.webContentsView);
214
+ window?.contentView.addChildView(entry.webContentsView);
215
+ }
216
+ catch { /* closing */ }
217
+ }
218
+ }
219
+ if (base64 === '') {
220
+ throw new Error('capture produced no image (view not painted)');
221
+ }
222
+ reply(msg.id, { ok: true, result: { base64, width: 0, height: 0 } });
223
+ return;
224
+ }
120
225
  case 'download': {
121
226
  const viewId = msg.viewId;
122
227
  const url = msg.url;
@@ -137,6 +242,7 @@ async function handle(op, msg) {
137
242
  if (!r.ok) throw new Error('HTTP ' + r.status)
138
243
  const b = await r.arrayBuffer()
139
244
  const bytes = new Uint8Array(b)
245
+ if (bytes.length > ${String(MAX_DOWNLOAD_BYTES)}) throw new Error('download too large (limit ' + ${String(MAX_DOWNLOAD_BYTES)} + ' bytes, got ' + bytes.length + ')')
140
246
  let bin = ''
141
247
  for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000))
142
248
  return btoa(bin)
@@ -162,16 +268,21 @@ async function handle(op, msg) {
162
268
  // Export the session's cookies so login state can be saved/restored
163
269
  // across browser hosts (or shared with another machine).
164
270
  const cookies = await entry.webContentsView.webContents.session.cookies.get({});
165
- const exported = cookies.map(c => ({
166
- url: `http${c.secure ? 's' : ''}://${c.domain.startsWith('.') ? c.domain.slice(1) : c.domain}${c.path}`,
167
- name: c.name,
168
- value: c.value,
169
- domain: c.domain,
170
- path: c.path,
171
- secure: c.secure,
172
- httpOnly: c.httpOnly,
173
- expirationDate: c.expirationDate,
174
- }));
271
+ const exported = cookies.map(c => {
272
+ const host = c.domain.startsWith('.') ? c.domain.slice(1) : c.domain;
273
+ // IPv6 literal domains need brackets in a URL (e.g. http://[::1]/).
274
+ const hostPart = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
275
+ return {
276
+ url: `http${c.secure ? 's' : ''}://${hostPart}${c.path}`,
277
+ name: c.name,
278
+ value: c.value,
279
+ domain: c.domain,
280
+ path: c.path,
281
+ secure: c.secure,
282
+ httpOnly: c.httpOnly,
283
+ expirationDate: c.expirationDate,
284
+ };
285
+ });
175
286
  reply(msg.id, { ok: true, result: { cookies: exported } });
176
287
  return;
177
288
  }
@@ -185,6 +296,7 @@ async function handle(op, msg) {
185
296
  throw new Error(`restoreAuth: unknown view ${viewId}`);
186
297
  if (!Array.isArray(cookies))
187
298
  throw new Error('restoreAuth missing cookies array');
299
+ let restored = 0;
188
300
  for (const c of cookies) {
189
301
  if (typeof c.url !== 'string' || typeof c.name !== 'string' || typeof c.value !== 'string')
190
302
  continue;
@@ -198,8 +310,9 @@ async function handle(op, msg) {
198
310
  ...typeof c.httpOnly === 'boolean' ? { httpOnly: c.httpOnly } : {},
199
311
  ...typeof c.expirationDate === 'number' ? { expirationDate: c.expirationDate } : {},
200
312
  });
313
+ restored++;
201
314
  }
202
- reply(msg.id, { ok: true, result: { restored: cookies.length } });
315
+ reply(msg.id, { ok: true, result: { restored } });
203
316
  return;
204
317
  }
205
318
  default:
@@ -183,6 +183,8 @@ export declare class ElectronBrowserProvider implements BrowserProvider {
183
183
  readonly dataUrl: string;
184
184
  readonly path?: string;
185
185
  }>;
186
+ /** Build the data URL and optionally write the PNG to disk. */
187
+ private saveScreenshot;
186
188
  /** Append one operation to the session's history. */
187
189
  private record;
188
190
  /** Return the session's chronological operation log (newest last). */
@@ -531,7 +531,10 @@ export class ElectronBrowserProvider {
531
531
  if (typeof downloadable.download !== 'function') {
532
532
  throw new BrowserError('browser: download is only available on the self-hosted browser', 'BROWSER_DOWNLOAD_UNSUPPORTED');
533
533
  }
534
- await downloadable.download(request.url, request.savePath);
534
+ // The child fetches in-page with awaitPromise; a slow/hung network can
535
+ // block it well past the tool budget, so bound it like every other call.
536
+ const timeoutMs = 60_000;
537
+ await withTimeout(downloadable.download(request.url, request.savePath), timeoutMs, signal, `browser: download timed out after ${timeoutMs}ms`);
535
538
  this.record(s, 'download', { url: request.url, savePath: request.savePath }, true, { result: request.savePath });
536
539
  return { path: request.savePath };
537
540
  }
@@ -546,7 +549,8 @@ export class ElectronBrowserProvider {
546
549
  if (typeof host.flushAuth !== 'function') {
547
550
  throw new BrowserError('browser: auth export is only available on the self-hosted browser', 'BROWSER_AUTH_UNSUPPORTED');
548
551
  }
549
- const cookies = await host.flushAuth();
552
+ const timeoutMs = 30_000;
553
+ const cookies = await withTimeout(host.flushAuth(), timeoutMs, undefined, `browser: auth export timed out after ${timeoutMs}ms`);
550
554
  this.record(s, 'flushAuth', {}, true, { result: `${cookies.length} cookies` });
551
555
  return cookies;
552
556
  }
@@ -558,42 +562,60 @@ export class ElectronBrowserProvider {
558
562
  if (typeof host.restoreAuth !== 'function') {
559
563
  throw new BrowserError('browser: auth restore is only available on the self-hosted browser', 'BROWSER_AUTH_UNSUPPORTED');
560
564
  }
561
- const restored = await host.restoreAuth(cookies);
565
+ const timeoutMs = 30_000;
566
+ const restored = await withTimeout(host.restoreAuth(cookies), timeoutMs, undefined, `browser: auth restore timed out after ${timeoutMs}ms`);
562
567
  this.record(s, 'restoreAuth', { count: cookies.length }, true, { result: `${restored} cookies` });
563
568
  return restored;
564
569
  }
565
570
  /** Capture the current page, optionally full-page. PNG only (CDP JPEG hangs on Electron 43). */
566
571
  async screenshot(session, request, signal) {
567
- const { handle } = this.activeTab(this.session(session));
572
+ const s = this.session(session);
573
+ const { handle } = this.activeTab(s);
568
574
  signal?.throwIfAborted();
575
+ // Native capturePage path (self-hosted): CDP Page.captureScreenshot can
576
+ // hang indefinitely on a view once another (hidden) WebContentsView exists
577
+ // in the window; capturePage is fast for visible views and resolves
578
+ // immediately (empty) for hidden ones.
579
+ const capturable = handle;
580
+ if (request?.fullPage !== true && typeof capturable.capture === 'function') {
581
+ // Ensure the target view is the visible one before capturing.
582
+ this.showActive(s);
583
+ const timeoutMs = 30_000;
584
+ const shot = await withTimeout(capturable.capture(), timeoutMs, signal, `browser: screenshot timed out after ${timeoutMs}ms`);
585
+ if (shot.base64 === '') {
586
+ throw new BrowserError('browser: capture returned an empty image (view not painted); retry shortly', 'BROWSER_SCREENSHOT_FAILED');
587
+ }
588
+ return this.saveScreenshot(shot.base64, request?.savePath);
589
+ }
590
+ // Fallback: a desktop-shell handle (no capture()) or a full-page capture
591
+ // uses CDP; full-page needs `captureBeyondViewport` which capturePage lacks.
569
592
  const params = {};
570
593
  if (request?.fullPage === true) {
571
594
  // `captureBeyondViewport` captures the full scrollable content; without
572
595
  // a clip this yields the full-page image (CDP default is the viewport).
573
596
  params.captureBeyondViewport = true;
574
597
  }
575
- // Bound the capture: a view that has not painted can wedge
576
- // Page.captureScreenshot indefinitely (observed after view re-showing).
577
598
  const timeoutMs = 30_000;
578
599
  const result = await withTimeout(handle.sendCommand(CDP_PAGE_CAPTURE_SCREENSHOT, params), timeoutMs, signal, `browser: screenshot timed out after ${timeoutMs}ms`);
579
600
  const data = result.data;
580
601
  if (typeof data !== 'string') {
581
602
  throw new BrowserError('browser: screenshot returned no image data', 'BROWSER_SCREENSHOT_FAILED');
582
603
  }
583
- // Optional disk write so a vision tool (e.g. modlens read_image) can read
584
- // the screenshot from a file path. Fire-and-forget: a write failure must
585
- // not fail the capture.
586
- if (request?.savePath !== undefined) {
604
+ return this.saveScreenshot(data, request?.savePath);
605
+ }
606
+ /** Build the data URL and optionally write the PNG to disk. */
607
+ saveScreenshot(base64, savePath) {
608
+ if (savePath !== undefined) {
587
609
  try {
588
- writeFileSync(request.savePath, Buffer.from(data, 'base64'));
589
- return { dataUrl: `data:image/png;base64,${data}`, path: request.savePath };
610
+ writeFileSync(savePath, Buffer.from(base64, 'base64'));
611
+ return { dataUrl: `data:image/png;base64,${base64}`, path: savePath };
590
612
  }
591
613
  catch (error) {
592
614
  // Report the write problem but keep the capture usable.
593
- throw new BrowserError(`browser: screenshot save to "${request.savePath}" failed: ${String(error)}`, 'BROWSER_SCREENSHOT_SAVE_FAILED', { cause: error });
615
+ throw new BrowserError(`browser: screenshot save to "${savePath}" failed: ${String(error)}`, 'BROWSER_SCREENSHOT_SAVE_FAILED', { cause: error });
594
616
  }
595
617
  }
596
- return { dataUrl: `data:image/png;base64,${data}` };
618
+ return { dataUrl: `data:image/png;base64,${base64}` };
597
619
  }
598
620
  /** Append one operation to the session's history. */
599
621
  record(s, action, params, ok, detail) {
@@ -706,7 +728,9 @@ export class ElectronBrowserProvider {
706
728
  }
707
729
  /** Read the current URL of a view through CDP. */
708
730
  async currentUrl(handle) {
709
- const result = await handleSendEvaluate(handle, 'location.href');
731
+ // Bound the read: a wedged renderer would otherwise hang listTabs.
732
+ const timeoutMs = 10_000;
733
+ const result = await withTimeout(handleSendEvaluate(handle, 'location.href'), timeoutMs, undefined, `browser: url read timed out after ${timeoutMs}ms`);
710
734
  return result.ok && typeof result.value === 'string' ? result.value : '';
711
735
  }
712
736
  }
@@ -23,13 +23,16 @@ import { createServer } from 'node:net';
23
23
  import { fileURLToPath } from 'node:url';
24
24
  /** How long to wait for the child to signal readiness before failing. */
25
25
  const READY_TIMEOUT_MS = 20_000;
26
+ /** Safety cap on a single RPC reply line (base64 downloads are the big ones). */
27
+ const MAX_RPC_BUFFER_BYTES = 512 * 1024 * 1024;
26
28
  /**
27
- * Locate the Electron binary, trying, in order:
29
+ * Locate the Electron binary:
28
30
  * 1. require('electron') from this module (optional peer dependency),
29
- * 2. require.resolve('electron') against DSH install anchors (global dsh
30
- * package, npm global root) — desktop-shell environments satisfy this,
31
- * 3. a pnpm virtual store under common workspace layouts,
32
- * 4. ELECTRON_PATH (explicit override).
31
+ * 2. ELECTRON_PATH (explicit override),
32
+ * 3. every DSH install anchor + pnpm virtual store candidate, choosing the
33
+ * NEWEST version found. Older Electron releases (e.g. 33.x) have a
34
+ * compositor defect that intermittently breaks page capture, so prefer
35
+ * the newest binary available in the environment.
33
36
  * @returns the path to the Electron executable.
34
37
  */
35
38
  function resolveElectronPath() {
@@ -43,8 +46,16 @@ function resolveElectronPath() {
43
46
  catch {
44
47
  // continue probing
45
48
  }
46
- // 2. DSH install anchors: the global dsh package and the npm global root
47
- // both carry electron junctions under pnpm layouts.
49
+ // 2. Explicit override (user intent wins).
50
+ const override = process.env.ELECTRON_PATH;
51
+ if (typeof override === 'string' && override.length > 0 && existsSync(override))
52
+ return override;
53
+ // 3. Collect every candidate (anchors + pnpm stores), then pick the newest.
54
+ const candidates = [];
55
+ const add = (version, path) => {
56
+ if (path !== undefined)
57
+ candidates.push({ version, path });
58
+ };
48
59
  const anchors = [];
49
60
  const globalPrefix = process.env.npm_config_prefix ?? process.env.PREFIX;
50
61
  if (globalPrefix !== undefined) {
@@ -58,21 +69,13 @@ function resolveElectronPath() {
58
69
  try {
59
70
  const resolved = require.resolve('electron', { paths: [anchor] });
60
71
  if (typeof resolved === 'string' && resolved.length > 0) {
61
- // electron's index.js is not the binary; find the dist executable
62
- // next to the package root.
63
- const exe = electronExeBeside(resolved);
64
- if (exe !== undefined)
65
- return exe;
72
+ add(versionOf(resolved), electronExeBeside(resolved));
66
73
  }
67
74
  }
68
75
  catch {
69
76
  // continue probing
70
77
  }
71
78
  }
72
- // 3. pnpm virtual store, walking up from several candidate roots: this
73
- // module (its real path may differ from where the profile resolves it when
74
- // installed via a junction/link), the process CWD, and the Node executable
75
- // dir (DSH CLI typically runs from the workspace root).
76
79
  const roots = new Set([
77
80
  fileURLToPath(new URL('.', import.meta.url)),
78
81
  process.cwd(),
@@ -83,9 +86,12 @@ function resolveElectronPath() {
83
86
  for (let depth = 0; depth < 8; depth++) {
84
87
  const store = join(dir, 'node_modules', '.pnpm');
85
88
  if (existsSync(store)) {
86
- const exe = findElectronExe(store);
87
- if (exe !== undefined)
88
- return exe;
89
+ for (const entry of readdirSync(store)) {
90
+ if (!entry.startsWith('electron@'))
91
+ continue;
92
+ const exe = electronDistExe(join(store, entry, 'node_modules', 'electron'));
93
+ add(entry.slice('electron@'.length), exe);
94
+ }
89
95
  }
90
96
  const parent = join(dir, '..');
91
97
  if (parent === dir)
@@ -93,13 +99,31 @@ function resolveElectronPath() {
93
99
  dir = parent;
94
100
  }
95
101
  }
96
- // 4. Explicit override.
97
- const override = process.env.ELECTRON_PATH;
98
- if (typeof override === 'string' && override.length > 0 && existsSync(override))
99
- return override;
102
+ if (candidates.length > 0) {
103
+ candidates.sort((a, b) => compareVersions(b.version, a.version));
104
+ const best = candidates[0];
105
+ if (best !== undefined)
106
+ return best.path;
107
+ }
100
108
  throw new Error('dsh-builtin-browser: cannot locate the Electron binary. Install electron in the host profile ' +
101
109
  '(e.g. `dsh plugin --profile web add electron`) or set ELECTRON_PATH to the electron executable.');
102
110
  }
111
+ /** Extract an electron version like "43.4.0" from a path containing it. */
112
+ function versionOf(path) {
113
+ const match = /electron@(\d+\.\d+\.\d+)/.exec(path);
114
+ return match?.[1] ?? '0.0.0';
115
+ }
116
+ /** Compare dotted numeric versions; higher wins. */
117
+ function compareVersions(a, b) {
118
+ const pa = a.split('.').map(n => Number(n) || 0);
119
+ const pb = b.split('.').map(n => Number(n) || 0);
120
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
121
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
122
+ if (d !== 0)
123
+ return d;
124
+ }
125
+ return 0;
126
+ }
103
127
  /** From an electron package entry file, find the dist executable beside it. */
104
128
  function electronExeBeside(entry) {
105
129
  const candidates = [
@@ -114,6 +138,14 @@ function electronExeBeside(entry) {
114
138
  }
115
139
  return undefined;
116
140
  }
141
+ /** From an electron package root, find its dist executable. */
142
+ function electronDistExe(pkgRoot) {
143
+ for (const candidate of [join(pkgRoot, 'dist', 'electron.exe'), join(pkgRoot, 'dist', 'electron')]) {
144
+ if (existsSync(candidate))
145
+ return candidate;
146
+ }
147
+ return undefined;
148
+ }
117
149
  /** dirname without importing node:path's dirname separately. */
118
150
  function dirname(p) {
119
151
  const i = p.lastIndexOf('/');
@@ -121,23 +153,6 @@ function dirname(p) {
121
153
  const k = Math.max(i, j);
122
154
  return k < 0 ? p : p.slice(0, k);
123
155
  }
124
- /** Search a pnpm virtual store for an Electron dist binary. */
125
- function findElectronExe(store) {
126
- const entries = existsSync(store) ? readdirSync(store) : [];
127
- for (const entry of entries) {
128
- if (!entry.startsWith('electron@'))
129
- continue;
130
- const candidates = [
131
- join(store, entry, 'node_modules', 'electron', 'dist', 'electron.exe'),
132
- join(store, entry, 'node_modules', 'electron', 'dist', 'electron'),
133
- ];
134
- for (const candidate of candidates) {
135
- if (existsSync(candidate))
136
- return candidate;
137
- }
138
- }
139
- return undefined;
140
- }
141
156
  /**
142
157
  * Line-delimited JSON-RPC client over a local TCP socket. Electron's main
143
158
  * process on Windows does not receive piped stdin, so the parent listens on a
@@ -162,6 +177,7 @@ class ElectronChildClient {
162
177
  this.port = port;
163
178
  this.onExit = onExit;
164
179
  const electron = resolveElectronPath();
180
+ process.stderr.write(`[dsh-browser host] spawning electron: ${electron}\n`);
165
181
  // ELECTRON_RUN_AS_NODE (even an empty string) makes Electron run as plain
166
182
  // Node, breaking require('electron'); NODE_OPTIONS can inject flags that
167
183
  // break the child. Rebuild the env without either.
@@ -227,6 +243,14 @@ class ElectronChildClient {
227
243
  }
228
244
  onData(chunk) {
229
245
  this.buffer += typeof chunk === 'string' ? chunk : chunk.toString('utf8');
246
+ // Safety net: a pathological child (or a reply larger than expected)
247
+ // must not grow the parent's memory without bound. The child caps
248
+ // downloads at 256 MiB, so a healthy stream never approaches this.
249
+ if (this.buffer.length > MAX_RPC_BUFFER_BYTES) {
250
+ this.buffer = '';
251
+ this.fail(new Error(`dsh-builtin-browser: RPC reply exceeded ${MAX_RPC_BUFFER_BYTES} bytes`));
252
+ return;
253
+ }
230
254
  let nl;
231
255
  while ((nl = this.buffer.indexOf('\n')) >= 0) {
232
256
  const line = this.buffer.slice(0, nl).trim();
@@ -300,6 +324,10 @@ class RemoteView {
300
324
  const result = await this.client.call('download', { viewId: this.id, url, savePath });
301
325
  writeFileSync(savePath, Buffer.from(result.base64, 'base64'));
302
326
  }
327
+ /** Native capturePage snapshot of the view (PNG base64 + size). */
328
+ capture() {
329
+ return this.client.call('capture', { viewId: this.id });
330
+ }
303
331
  /** Export the session's cookies (login state). */
304
332
  flushAuth() {
305
333
  return this.client.call('flushAuth', { viewId: this.id }).then(r => r.cookies);
@@ -329,8 +357,22 @@ export class RemoteElectronViewHost {
329
357
  ready() {
330
358
  if (this.readyPromise !== undefined)
331
359
  return this.readyPromise;
332
- this.readyPromise = this.start();
333
- return this.readyPromise;
360
+ const started = this.start();
361
+ const wrapped = started.catch(error => {
362
+ // A failed startup must not poison the host forever: tear down whatever
363
+ // was half-created and let the next call retry from scratch.
364
+ if (this.readyPromise === wrapped) {
365
+ this.readyPromise = undefined;
366
+ this.client?.kill();
367
+ this.client = undefined;
368
+ this.server?.close();
369
+ this.server = undefined;
370
+ this.pendingSocket = undefined;
371
+ }
372
+ throw error;
373
+ });
374
+ this.readyPromise = wrapped;
375
+ return wrapped;
334
376
  }
335
377
  async start() {
336
378
  // Listen on an ephemeral loopback port; the child connects back.
@@ -386,6 +428,11 @@ export class RemoteElectronViewHost {
386
428
  if (client === undefined)
387
429
  throw new Error('browser host unavailable');
388
430
  await client.call('createView', { viewId: id });
431
+ // If the view was destroyed while the createView RPC was in flight, do
432
+ // not re-insert a stale entry that would resurrect a dead child view.
433
+ if (this.views.get(id) === undefined) {
434
+ throw new Error('browser: view destroyed while starting');
435
+ }
389
436
  const view = new RemoteView(id, client);
390
437
  this.views.set(id, view);
391
438
  return view;
@@ -426,27 +473,41 @@ class DeferredRemoteView {
426
473
  this.id = id;
427
474
  this.materialize = materialize;
428
475
  }
476
+ /**
477
+ * Materialize once and cache: every sendCommand on the same handle must
478
+ * target the SAME child view (re-materializing would re-run createView and
479
+ * duplicate the window). A FAILED materialization is reset so a later call
480
+ * (e.g. after the host restarted) can retry instead of being poisoned.
481
+ */
482
+ materializeOnce() {
483
+ if (this.materialized === undefined) {
484
+ const pending = this.materialize();
485
+ this.materialized = pending.catch(error => {
486
+ if (this.materialized === pending)
487
+ this.materialized = undefined;
488
+ throw error;
489
+ });
490
+ }
491
+ return this.materialized;
492
+ }
429
493
  async sendCommand(method, params) {
430
- // Materialize once and cache: every sendCommand on the same handle must
431
- // target the SAME child view. Re-materializing would re-run createView
432
- // (duplicate window) and drop the established view.
433
- this.materialized ??= this.materialize();
434
- const view = await this.materialized;
494
+ const view = await this.materializeOnce();
435
495
  return view.sendCommand(method, params);
436
496
  }
437
497
  async download(url, savePath) {
438
- this.materialized ??= this.materialize();
439
- const view = await this.materialized;
498
+ const view = await this.materializeOnce();
440
499
  return view.download(url, savePath);
441
500
  }
501
+ async capture() {
502
+ const view = await this.materializeOnce();
503
+ return view.capture();
504
+ }
442
505
  async flushAuth() {
443
- this.materialized ??= this.materialize();
444
- const view = await this.materialized;
506
+ const view = await this.materializeOnce();
445
507
  return view.flushAuth();
446
508
  }
447
509
  async restoreAuth(cookies) {
448
- this.materialized ??= this.materialize();
449
- const view = await this.materialized;
510
+ const view = await this.materializeOnce();
450
511
  return view.restoreAuth(cookies);
451
512
  }
452
513
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-builtin-browser",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "Shared real browser plugin for DeepSeek Harness: install-and-use — a visible native browser the human can take over, driven by the agent over CDP. DOM-level interaction (React/Vue safe), per-task session isolation, cookie persistence (browser_auth), CAPTCHA detection, batch form fill (browser_fill), authenticated download, history/replay, action restriction",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -17,10 +17,26 @@
17
17
  import { app, BrowserWindow, WebContentsView } from 'electron'
18
18
  import { createInterface } from 'node:readline'
19
19
  import { createConnection } from 'node:net'
20
+ import { join } from 'node:path'
21
+
22
+ // Isolate this host's profile from the DSH app's default Electron userData:
23
+ // several Electron instances sharing Roaming\Electron fight over the GPU
24
+ // cache/session locks, which can leave the window without a display surface
25
+ // (capturePage then fails). A dedicated userData also persists cookies across
26
+ // host restarts (on top of browser_auth). Must run before app is ready.
27
+ try {
28
+ const base = process.env.DSH_HOME ?? app.getPath('appData')
29
+ app.setPath('userData', join(base, 'dsh-builtin-browser-host'))
30
+ } catch (error) {
31
+ process.stderr.write(`[dsh-browser host] userData setup failed: ${String(error)}\n`)
32
+ }
20
33
 
21
34
  /** CDP protocol version attached to every view's debugger. */
22
35
  const CDP_VERSION = '1.3'
23
36
 
37
+ /** Download cap: the body is shipped base64 as one JSON line; bound the memory. */
38
+ const MAX_DOWNLOAD_BYTES = 256 * 1024 * 1024
39
+
24
40
  /** One view: the Electron object plus its CDP-backed surface. */
25
41
  interface HostView {
26
42
  readonly webContentsView: WebContentsView
@@ -59,6 +75,9 @@ async function handle(op: string, msg: { id: number; viewId?: string; method?: s
59
75
  window.on('closed', () => { window = undefined })
60
76
  }
61
77
  const view = new WebContentsView()
78
+ // Attach the debugger BEFORE the view can be seen: an attach failure
79
+ // then leaves nothing in the window (no visible ghost view).
80
+ view.webContents.debugger.attach(CDP_VERSION)
62
81
  // New views start hidden: with several sessions/tabs only the shown
63
82
  // one may be visible (they stack in contentView child order).
64
83
  view.setVisible(false)
@@ -66,7 +85,6 @@ async function handle(op: string, msg: { id: number; viewId?: string; method?: s
66
85
  const [width, height] = window.getContentSize()
67
86
  view.setBounds({ x: 0, y: 0, width: width ?? 0, height: height ?? 0 })
68
87
  if (views.size === 0) view.setVisible(true)
69
- view.webContents.debugger.attach(CDP_VERSION)
70
88
  views.set(viewId, { webContentsView: view })
71
89
  reply(msg.id, { ok: true })
72
90
  return
@@ -115,6 +133,73 @@ async function handle(op: string, msg: { id: number; viewId?: string; method?: s
115
133
  reply(msg.id, { ok: true, result })
116
134
  return
117
135
  }
136
+ case 'capture': {
137
+ const viewId = msg.viewId
138
+ if (viewId === undefined) throw new Error('capture missing viewId')
139
+ const entry = views.get(viewId)
140
+ if (entry === undefined) throw new Error(`capture: unknown view ${viewId}`)
141
+ // Two complementary paths, because each has a failure mode:
142
+ // - capturePage: fast and reliable with several WebContentsViews in
143
+ // the window, but needs a live display surface (fails when the
144
+ // window is minimized/occluded/unpainted).
145
+ // - CDP Page.captureScreenshot: works without a display surface, but
146
+ // can hang when another hidden WebContentsView exists in the window.
147
+ // Try capturePage first (show/focus/restore + one retry), then CDP.
148
+ if (window !== undefined) {
149
+ try { if (!window.isVisible()) window.show() } catch { /* closing */ }
150
+ try { window.restore() } catch { /* not minimized */ }
151
+ window.focus()
152
+ }
153
+ let base64 = ''
154
+ try {
155
+ let image
156
+ try {
157
+ image = await entry.webContentsView.webContents.capturePage()
158
+ } catch (error) {
159
+ process.stderr.write(`[dsh-browser host] capturePage failed: ${String(error)}\n`)
160
+ await new Promise(resolve => setTimeout(resolve, 400))
161
+ image = await entry.webContentsView.webContents.capturePage()
162
+ }
163
+ const png = image.toPNG()
164
+ if (png.length > 0) base64 = png.toString('base64')
165
+ } catch (error) {
166
+ const state = window !== undefined
167
+ ? JSON.stringify({
168
+ win: { visible: window.isVisible(), minimized: window.isMinimized(), focused: window.isFocused() },
169
+ })
170
+ : 'no-window'
171
+ process.stderr.write(`[dsh-browser host] capturePage retry failed: ${String(error)} state=${state}\n`)
172
+ base64 = ''
173
+ }
174
+ if (base64 === '') {
175
+ // CDP fallback. Page.captureScreenshot can hang when OTHER views
176
+ // (especially hidden attach-first ones) are in the window, so
177
+ // temporarily detach the siblings, capture in single-view state,
178
+ // then restore them (target stays on top).
179
+ const siblings = [...views.values()].filter(v => v !== entry)
180
+ for (const v of siblings) {
181
+ try { window?.contentView.removeChildView(v.webContentsView) } catch { /* already gone */ }
182
+ }
183
+ try {
184
+ const shot = await entry.webContentsView.webContents.debugger.sendCommand('Page.captureScreenshot', {})
185
+ const data = (shot as { data?: unknown }).data
186
+ if (typeof data === 'string' && data.length > 0) base64 = data
187
+ } finally {
188
+ for (const v of siblings) {
189
+ try { window?.contentView.addChildView(v.webContentsView) } catch { /* destroyed */ }
190
+ }
191
+ try {
192
+ window?.contentView.removeChildView(entry.webContentsView)
193
+ window?.contentView.addChildView(entry.webContentsView)
194
+ } catch { /* closing */ }
195
+ }
196
+ }
197
+ if (base64 === '') {
198
+ throw new Error('capture produced no image (view not painted)')
199
+ }
200
+ reply(msg.id, { ok: true, result: { base64, width: 0, height: 0 } })
201
+ return
202
+ }
118
203
  case 'download': {
119
204
  const viewId = msg.viewId
120
205
  const url = msg.url
@@ -134,6 +219,7 @@ async function handle(op: string, msg: { id: number; viewId?: string; method?: s
134
219
  if (!r.ok) throw new Error('HTTP ' + r.status)
135
220
  const b = await r.arrayBuffer()
136
221
  const bytes = new Uint8Array(b)
222
+ if (bytes.length > ${String(MAX_DOWNLOAD_BYTES)}) throw new Error('download too large (limit ' + ${String(MAX_DOWNLOAD_BYTES)} + ' bytes, got ' + bytes.length + ')')
137
223
  let bin = ''
138
224
  for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000))
139
225
  return btoa(bin)
@@ -157,16 +243,21 @@ async function handle(op: string, msg: { id: number; viewId?: string; method?: s
157
243
  // Export the session's cookies so login state can be saved/restored
158
244
  // across browser hosts (or shared with another machine).
159
245
  const cookies = await entry.webContentsView.webContents.session.cookies.get({})
160
- const exported = cookies.map(c => ({
161
- url: `http${c.secure ? 's' : ''}://${c.domain.startsWith('.') ? c.domain.slice(1) : c.domain}${c.path}`,
162
- name: c.name,
163
- value: c.value,
164
- domain: c.domain,
165
- path: c.path,
166
- secure: c.secure,
167
- httpOnly: c.httpOnly,
168
- expirationDate: c.expirationDate,
169
- }))
246
+ const exported = cookies.map(c => {
247
+ const host = c.domain.startsWith('.') ? c.domain.slice(1) : c.domain
248
+ // IPv6 literal domains need brackets in a URL (e.g. http://[::1]/).
249
+ const hostPart = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host
250
+ return {
251
+ url: `http${c.secure ? 's' : ''}://${hostPart}${c.path}`,
252
+ name: c.name,
253
+ value: c.value,
254
+ domain: c.domain,
255
+ path: c.path,
256
+ secure: c.secure,
257
+ httpOnly: c.httpOnly,
258
+ expirationDate: c.expirationDate,
259
+ }
260
+ })
170
261
  reply(msg.id, { ok: true, result: { cookies: exported } })
171
262
  return
172
263
  }
@@ -177,6 +268,7 @@ async function handle(op: string, msg: { id: number; viewId?: string; method?: s
177
268
  const entry = views.get(viewId)
178
269
  if (entry === undefined) throw new Error(`restoreAuth: unknown view ${viewId}`)
179
270
  if (!Array.isArray(cookies)) throw new Error('restoreAuth missing cookies array')
271
+ let restored = 0
180
272
  for (const c of cookies as Array<{ url?: string; name?: string; value?: string; domain?: string; path?: string; secure?: boolean; httpOnly?: boolean; expirationDate?: number }>) {
181
273
  if (typeof c.url !== 'string' || typeof c.name !== 'string' || typeof c.value !== 'string') continue
182
274
  await entry.webContentsView.webContents.session.cookies.set({
@@ -189,8 +281,9 @@ async function handle(op: string, msg: { id: number; viewId?: string; method?: s
189
281
  ...typeof c.httpOnly === 'boolean' ? { httpOnly: c.httpOnly } : {},
190
282
  ...typeof c.expirationDate === 'number' ? { expirationDate: c.expirationDate } : {},
191
283
  })
284
+ restored++
192
285
  }
193
- reply(msg.id, { ok: true, result: { restored: (cookies as unknown[]).length } })
286
+ reply(msg.id, { ok: true, result: { restored } })
194
287
  return
195
288
  }
196
289
  default:
@@ -693,7 +693,15 @@ export class ElectronBrowserProvider implements BrowserProvider {
693
693
  if (typeof downloadable.download !== 'function') {
694
694
  throw new BrowserError('browser: download is only available on the self-hosted browser', 'BROWSER_DOWNLOAD_UNSUPPORTED')
695
695
  }
696
- await downloadable.download(request.url, request.savePath)
696
+ // The child fetches in-page with awaitPromise; a slow/hung network can
697
+ // block it well past the tool budget, so bound it like every other call.
698
+ const timeoutMs = 60_000
699
+ await withTimeout(
700
+ downloadable.download(request.url, request.savePath),
701
+ timeoutMs,
702
+ signal,
703
+ `browser: download timed out after ${timeoutMs}ms`,
704
+ )
697
705
  this.record(s, 'download', { url: request.url, savePath: request.savePath }, true, { result: request.savePath })
698
706
  return { path: request.savePath }
699
707
  }
@@ -709,7 +717,8 @@ export class ElectronBrowserProvider implements BrowserProvider {
709
717
  if (typeof host.flushAuth !== 'function') {
710
718
  throw new BrowserError('browser: auth export is only available on the self-hosted browser', 'BROWSER_AUTH_UNSUPPORTED')
711
719
  }
712
- const cookies = await host.flushAuth()
720
+ const timeoutMs = 30_000
721
+ const cookies = await withTimeout(host.flushAuth(), timeoutMs, undefined, `browser: auth export timed out after ${timeoutMs}ms`)
713
722
  this.record(s, 'flushAuth', {}, true, { result: `${cookies.length} cookies` })
714
723
  return cookies
715
724
  }
@@ -722,7 +731,8 @@ export class ElectronBrowserProvider implements BrowserProvider {
722
731
  if (typeof host.restoreAuth !== 'function') {
723
732
  throw new BrowserError('browser: auth restore is only available on the self-hosted browser', 'BROWSER_AUTH_UNSUPPORTED')
724
733
  }
725
- const restored = await host.restoreAuth(cookies)
734
+ const timeoutMs = 30_000
735
+ const restored = await withTimeout(host.restoreAuth(cookies), timeoutMs, undefined, `browser: auth restore timed out after ${timeoutMs}ms`)
726
736
  this.record(s, 'restoreAuth', { count: cookies.length }, true, { result: `${restored} cookies` })
727
737
  return restored
728
738
  }
@@ -733,16 +743,37 @@ export class ElectronBrowserProvider implements BrowserProvider {
733
743
  request?: { readonly fullPage?: boolean; readonly savePath?: string },
734
744
  signal?: AbortSignal,
735
745
  ): Promise<{ readonly dataUrl: string; readonly path?: string }> {
736
- const { handle } = this.activeTab(this.session(session))
746
+ const s = this.session(session)
747
+ const { handle } = this.activeTab(s)
737
748
  signal?.throwIfAborted()
749
+ // Native capturePage path (self-hosted): CDP Page.captureScreenshot can
750
+ // hang indefinitely on a view once another (hidden) WebContentsView exists
751
+ // in the window; capturePage is fast for visible views and resolves
752
+ // immediately (empty) for hidden ones.
753
+ const capturable = handle as { capture?(): Promise<{ base64: string; width: number; height: number }> }
754
+ if (request?.fullPage !== true && typeof capturable.capture === 'function') {
755
+ // Ensure the target view is the visible one before capturing.
756
+ this.showActive(s)
757
+ const timeoutMs = 30_000
758
+ const shot = await withTimeout(
759
+ capturable.capture(),
760
+ timeoutMs,
761
+ signal,
762
+ `browser: screenshot timed out after ${timeoutMs}ms`,
763
+ )
764
+ if (shot.base64 === '') {
765
+ throw new BrowserError('browser: capture returned an empty image (view not painted); retry shortly', 'BROWSER_SCREENSHOT_FAILED')
766
+ }
767
+ return this.saveScreenshot(shot.base64, request?.savePath)
768
+ }
769
+ // Fallback: a desktop-shell handle (no capture()) or a full-page capture
770
+ // uses CDP; full-page needs `captureBeyondViewport` which capturePage lacks.
738
771
  const params: Record<string, unknown> = {}
739
772
  if (request?.fullPage === true) {
740
773
  // `captureBeyondViewport` captures the full scrollable content; without
741
774
  // a clip this yields the full-page image (CDP default is the viewport).
742
775
  params.captureBeyondViewport = true
743
776
  }
744
- // Bound the capture: a view that has not painted can wedge
745
- // Page.captureScreenshot indefinitely (observed after view re-showing).
746
777
  const timeoutMs = 30_000
747
778
  const result = await withTimeout(
748
779
  handle.sendCommand(CDP_PAGE_CAPTURE_SCREENSHOT, params),
@@ -754,19 +785,21 @@ export class ElectronBrowserProvider implements BrowserProvider {
754
785
  if (typeof data !== 'string') {
755
786
  throw new BrowserError('browser: screenshot returned no image data', 'BROWSER_SCREENSHOT_FAILED')
756
787
  }
757
- // Optional disk write so a vision tool (e.g. modlens read_image) can read
758
- // the screenshot from a file path. Fire-and-forget: a write failure must
759
- // not fail the capture.
760
- if (request?.savePath !== undefined) {
788
+ return this.saveScreenshot(data, request?.savePath)
789
+ }
790
+
791
+ /** Build the data URL and optionally write the PNG to disk. */
792
+ private saveScreenshot(base64: string, savePath?: string): { dataUrl: string; path?: string } {
793
+ if (savePath !== undefined) {
761
794
  try {
762
- writeFileSync(request.savePath, Buffer.from(data, 'base64'))
763
- return { dataUrl: `data:image/png;base64,${data}`, path: request.savePath }
795
+ writeFileSync(savePath, Buffer.from(base64, 'base64'))
796
+ return { dataUrl: `data:image/png;base64,${base64}`, path: savePath }
764
797
  } catch (error) {
765
798
  // Report the write problem but keep the capture usable.
766
- throw new BrowserError(`browser: screenshot save to "${request.savePath}" failed: ${String(error)}`, 'BROWSER_SCREENSHOT_SAVE_FAILED', { cause: error })
799
+ throw new BrowserError(`browser: screenshot save to "${savePath}" failed: ${String(error)}`, 'BROWSER_SCREENSHOT_SAVE_FAILED', { cause: error })
767
800
  }
768
801
  }
769
- return { dataUrl: `data:image/png;base64,${data}` }
802
+ return { dataUrl: `data:image/png;base64,${base64}` }
770
803
  }
771
804
 
772
805
  /** Append one operation to the session's history. */
@@ -887,7 +920,14 @@ export class ElectronBrowserProvider implements BrowserProvider {
887
920
 
888
921
  /** Read the current URL of a view through CDP. */
889
922
  private async currentUrl(handle: ElectronViewHandle): Promise<string> {
890
- const result = await handleSendEvaluate(handle, 'location.href')
923
+ // Bound the read: a wedged renderer would otherwise hang listTabs.
924
+ const timeoutMs = 10_000
925
+ const result = await withTimeout(
926
+ handleSendEvaluate(handle, 'location.href'),
927
+ timeoutMs,
928
+ undefined,
929
+ `browser: url read timed out after ${timeoutMs}ms`,
930
+ )
891
931
  return result.ok && typeof result.value === 'string' ? result.value : ''
892
932
  }
893
933
  }
@@ -28,13 +28,17 @@ import type { ExportedCookie } from '../browser/types.ts'
28
28
  /** How long to wait for the child to signal readiness before failing. */
29
29
  const READY_TIMEOUT_MS = 20_000
30
30
 
31
+ /** Safety cap on a single RPC reply line (base64 downloads are the big ones). */
32
+ const MAX_RPC_BUFFER_BYTES = 512 * 1024 * 1024
33
+
31
34
  /**
32
- * Locate the Electron binary, trying, in order:
35
+ * Locate the Electron binary:
33
36
  * 1. require('electron') from this module (optional peer dependency),
34
- * 2. require.resolve('electron') against DSH install anchors (global dsh
35
- * package, npm global root) — desktop-shell environments satisfy this,
36
- * 3. a pnpm virtual store under common workspace layouts,
37
- * 4. ELECTRON_PATH (explicit override).
37
+ * 2. ELECTRON_PATH (explicit override),
38
+ * 3. every DSH install anchor + pnpm virtual store candidate, choosing the
39
+ * NEWEST version found. Older Electron releases (e.g. 33.x) have a
40
+ * compositor defect that intermittently breaks page capture, so prefer
41
+ * the newest binary available in the environment.
38
42
  * @returns the path to the Electron executable.
39
43
  */
40
44
  function resolveElectronPath(): string {
@@ -46,8 +50,14 @@ function resolveElectronPath(): string {
46
50
  } catch {
47
51
  // continue probing
48
52
  }
49
- // 2. DSH install anchors: the global dsh package and the npm global root
50
- // both carry electron junctions under pnpm layouts.
53
+ // 2. Explicit override (user intent wins).
54
+ const override = process.env.ELECTRON_PATH
55
+ if (typeof override === 'string' && override.length > 0 && existsSync(override)) return override
56
+ // 3. Collect every candidate (anchors + pnpm stores), then pick the newest.
57
+ const candidates: Array<{ version: string; path: string }> = []
58
+ const add = (version: string, path: string | undefined): void => {
59
+ if (path !== undefined) candidates.push({ version, path })
60
+ }
51
61
  const anchors: string[] = []
52
62
  const globalPrefix = process.env.npm_config_prefix ?? process.env.PREFIX
53
63
  if (globalPrefix !== undefined) {
@@ -61,19 +71,12 @@ function resolveElectronPath(): string {
61
71
  try {
62
72
  const resolved: unknown = require.resolve('electron', { paths: [anchor] })
63
73
  if (typeof resolved === 'string' && resolved.length > 0) {
64
- // electron's index.js is not the binary; find the dist executable
65
- // next to the package root.
66
- const exe = electronExeBeside(resolved)
67
- if (exe !== undefined) return exe
74
+ add(versionOf(resolved), electronExeBeside(resolved))
68
75
  }
69
76
  } catch {
70
77
  // continue probing
71
78
  }
72
79
  }
73
- // 3. pnpm virtual store, walking up from several candidate roots: this
74
- // module (its real path may differ from where the profile resolves it when
75
- // installed via a junction/link), the process CWD, and the Node executable
76
- // dir (DSH CLI typically runs from the workspace root).
77
80
  const roots = new Set<string>([
78
81
  fileURLToPath(new URL('.', import.meta.url)),
79
82
  process.cwd(),
@@ -84,23 +87,45 @@ function resolveElectronPath(): string {
84
87
  for (let depth = 0; depth < 8; depth++) {
85
88
  const store = join(dir, 'node_modules', '.pnpm')
86
89
  if (existsSync(store)) {
87
- const exe = findElectronExe(store)
88
- if (exe !== undefined) return exe
90
+ for (const entry of readdirSync(store)) {
91
+ if (!entry.startsWith('electron@')) continue
92
+ const exe = electronDistExe(join(store, entry, 'node_modules', 'electron'))
93
+ add(entry.slice('electron@'.length), exe)
94
+ }
89
95
  }
90
96
  const parent = join(dir, '..')
91
97
  if (parent === dir) break
92
98
  dir = parent
93
99
  }
94
100
  }
95
- // 4. Explicit override.
96
- const override = process.env.ELECTRON_PATH
97
- if (typeof override === 'string' && override.length > 0 && existsSync(override)) return override
101
+ if (candidates.length > 0) {
102
+ candidates.sort((a, b) => compareVersions(b.version, a.version))
103
+ const best = candidates[0]
104
+ if (best !== undefined) return best.path
105
+ }
98
106
  throw new Error(
99
107
  'dsh-builtin-browser: cannot locate the Electron binary. Install electron in the host profile ' +
100
108
  '(e.g. `dsh plugin --profile web add electron`) or set ELECTRON_PATH to the electron executable.',
101
109
  )
102
110
  }
103
111
 
112
+ /** Extract an electron version like "43.4.0" from a path containing it. */
113
+ function versionOf(path: string): string {
114
+ const match = /electron@(\d+\.\d+\.\d+)/.exec(path)
115
+ return match?.[1] ?? '0.0.0'
116
+ }
117
+
118
+ /** Compare dotted numeric versions; higher wins. */
119
+ function compareVersions(a: string, b: string): number {
120
+ const pa = a.split('.').map(n => Number(n) || 0)
121
+ const pb = b.split('.').map(n => Number(n) || 0)
122
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
123
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0)
124
+ if (d !== 0) return d
125
+ }
126
+ return 0
127
+ }
128
+
104
129
  /** From an electron package entry file, find the dist executable beside it. */
105
130
  function electronExeBeside(entry: string): string | undefined {
106
131
  const candidates = [
@@ -115,6 +140,14 @@ function electronExeBeside(entry: string): string | undefined {
115
140
  return undefined
116
141
  }
117
142
 
143
+ /** From an electron package root, find its dist executable. */
144
+ function electronDistExe(pkgRoot: string): string | undefined {
145
+ for (const candidate of [join(pkgRoot, 'dist', 'electron.exe'), join(pkgRoot, 'dist', 'electron')]) {
146
+ if (existsSync(candidate)) return candidate
147
+ }
148
+ return undefined
149
+ }
150
+
118
151
  /** dirname without importing node:path's dirname separately. */
119
152
  function dirname(p: string): string {
120
153
  const i = p.lastIndexOf('/')
@@ -123,22 +156,6 @@ function dirname(p: string): string {
123
156
  return k < 0 ? p : p.slice(0, k)
124
157
  }
125
158
 
126
- /** Search a pnpm virtual store for an Electron dist binary. */
127
- function findElectronExe(store: string): string | undefined {
128
- const entries = existsSync(store) ? readdirSync(store) : []
129
- for (const entry of entries) {
130
- if (!entry.startsWith('electron@')) continue
131
- const candidates = [
132
- join(store, entry, 'node_modules', 'electron', 'dist', 'electron.exe'),
133
- join(store, entry, 'node_modules', 'electron', 'dist', 'electron'),
134
- ]
135
- for (const candidate of candidates) {
136
- if (existsSync(candidate)) return candidate
137
- }
138
- }
139
- return undefined
140
- }
141
-
142
159
  /** One RPC round-trip with the child. */
143
160
  interface Pending {
144
161
  resolve(result: unknown): void
@@ -168,6 +185,7 @@ class ElectronChildClient {
168
185
  private readonly onExit?: () => void,
169
186
  ) {
170
187
  const electron = resolveElectronPath()
188
+ process.stderr.write(`[dsh-browser host] spawning electron: ${electron}\n`)
171
189
  // ELECTRON_RUN_AS_NODE (even an empty string) makes Electron run as plain
172
190
  // Node, breaking require('electron'); NODE_OPTIONS can inject flags that
173
191
  // break the child. Rebuild the env without either.
@@ -233,6 +251,14 @@ class ElectronChildClient {
233
251
 
234
252
  private onData(chunk: string | Buffer): void {
235
253
  this.buffer += typeof chunk === 'string' ? chunk : chunk.toString('utf8')
254
+ // Safety net: a pathological child (or a reply larger than expected)
255
+ // must not grow the parent's memory without bound. The child caps
256
+ // downloads at 256 MiB, so a healthy stream never approaches this.
257
+ if (this.buffer.length > MAX_RPC_BUFFER_BYTES) {
258
+ this.buffer = ''
259
+ this.fail(new Error(`dsh-builtin-browser: RPC reply exceeded ${MAX_RPC_BUFFER_BYTES} bytes`))
260
+ return
261
+ }
236
262
  let nl: number
237
263
  while ((nl = this.buffer.indexOf('\n')) >= 0) {
238
264
  const line = this.buffer.slice(0, nl).trim()
@@ -297,6 +323,11 @@ class RemoteView implements ElectronViewHandle {
297
323
  writeFileSync(savePath, Buffer.from(result.base64, 'base64'))
298
324
  }
299
325
 
326
+ /** Native capturePage snapshot of the view (PNG base64 + size). */
327
+ capture(): Promise<{ base64: string; width: number; height: number }> {
328
+ return this.client.call<{ base64: string; width: number; height: number }>('capture', { viewId: this.id })
329
+ }
330
+
300
331
  /** Export the session's cookies (login state). */
301
332
  flushAuth(): Promise<ExportedCookie[]> {
302
333
  return this.client.call<{ cookies: ExportedCookie[] }>('flushAuth', { viewId: this.id }).then(r => r.cookies)
@@ -326,8 +357,22 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
326
357
  /** Ensure the child is up and ready (lazy on first use; restarts after a crash). */
327
358
  private ready(): Promise<void> {
328
359
  if (this.readyPromise !== undefined) return this.readyPromise
329
- this.readyPromise = this.start()
330
- return this.readyPromise
360
+ const started = this.start()
361
+ const wrapped = started.catch(error => {
362
+ // A failed startup must not poison the host forever: tear down whatever
363
+ // was half-created and let the next call retry from scratch.
364
+ if (this.readyPromise === wrapped) {
365
+ this.readyPromise = undefined
366
+ this.client?.kill()
367
+ this.client = undefined
368
+ this.server?.close()
369
+ this.server = undefined
370
+ this.pendingSocket = undefined
371
+ }
372
+ throw error
373
+ })
374
+ this.readyPromise = wrapped
375
+ return wrapped
331
376
  }
332
377
 
333
378
  private async start(): Promise<void> {
@@ -383,6 +428,11 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
383
428
  const client = this.client
384
429
  if (client === undefined) throw new Error('browser host unavailable')
385
430
  await client.call('createView', { viewId: id })
431
+ // If the view was destroyed while the createView RPC was in flight, do
432
+ // not re-insert a stale entry that would resurrect a dead child view.
433
+ if (this.views.get(id) === undefined) {
434
+ throw new Error('browser: view destroyed while starting')
435
+ }
386
436
  const view = new RemoteView(id, client)
387
437
  this.views.set(id, view)
388
438
  return view
@@ -426,30 +476,45 @@ class DeferredRemoteView implements ElectronViewHandle {
426
476
  private readonly materialize: () => Promise<RemoteView>,
427
477
  ) {}
428
478
 
479
+ /**
480
+ * Materialize once and cache: every sendCommand on the same handle must
481
+ * target the SAME child view (re-materializing would re-run createView and
482
+ * duplicate the window). A FAILED materialization is reset so a later call
483
+ * (e.g. after the host restarted) can retry instead of being poisoned.
484
+ */
485
+ private materializeOnce(): Promise<RemoteView> {
486
+ if (this.materialized === undefined) {
487
+ const pending = this.materialize()
488
+ this.materialized = pending.catch(error => {
489
+ if (this.materialized === pending) this.materialized = undefined
490
+ throw error
491
+ })
492
+ }
493
+ return this.materialized
494
+ }
495
+
429
496
  async sendCommand(method: string, params?: Record<string, unknown>): Promise<Record<string, unknown>> {
430
- // Materialize once and cache: every sendCommand on the same handle must
431
- // target the SAME child view. Re-materializing would re-run createView
432
- // (duplicate window) and drop the established view.
433
- this.materialized ??= this.materialize()
434
- const view = await this.materialized
497
+ const view = await this.materializeOnce()
435
498
  return view.sendCommand(method, params)
436
499
  }
437
500
 
438
501
  async download(url: string, savePath: string): Promise<void> {
439
- this.materialized ??= this.materialize()
440
- const view = await this.materialized
502
+ const view = await this.materializeOnce()
441
503
  return view.download(url, savePath)
442
504
  }
443
505
 
506
+ async capture(): Promise<{ base64: string; width: number; height: number }> {
507
+ const view = await this.materializeOnce()
508
+ return view.capture()
509
+ }
510
+
444
511
  async flushAuth(): Promise<ExportedCookie[]> {
445
- this.materialized ??= this.materialize()
446
- const view = await this.materialized
512
+ const view = await this.materializeOnce()
447
513
  return view.flushAuth()
448
514
  }
449
515
 
450
516
  async restoreAuth(cookies: ExportedCookie[]): Promise<number> {
451
- this.materialized ??= this.materialize()
452
- const view = await this.materialized
517
+ const view = await this.materializeOnce()
453
518
  return view.restoreAuth(cookies)
454
519
  }
455
520
  }
@@ -41,12 +41,17 @@ declare module 'electron' {
41
41
  set(details: CookieSetter): Promise<void>
42
42
  }
43
43
  }
44
+ export interface NativeImage {
45
+ toPNG(): Buffer
46
+ getSize(): { width: number; height: number }
47
+ }
44
48
  export interface WebContents {
45
49
  readonly id: number
46
50
  readonly debugger: WebContentsDebugger
47
51
  readonly session: Session
48
52
  close(): void
49
53
  downloadURL(url: string): void
54
+ capturePage(): Promise<NativeImage>
50
55
  }
51
56
  export interface WebContentsView {
52
57
  readonly webContents: WebContents
@@ -62,6 +67,12 @@ declare module 'electron' {
62
67
  readonly children: WebContentsView[]
63
68
  }
64
69
  getContentSize(): [number, number]
70
+ isVisible(): boolean
71
+ isMinimized(): boolean
72
+ isFocused(): boolean
73
+ show(): void
74
+ restore(): void
75
+ focus(): void
65
76
  on(event: 'closed', listener: () => void): this
66
77
  on(event: 'resize', listener: () => void): this
67
78
  off(event: 'closed', listener: () => void): this
@@ -70,6 +81,8 @@ declare module 'electron' {
70
81
  export const app: {
71
82
  whenReady(): Promise<void>
72
83
  exit(code?: number): void
84
+ getPath(name: string): string
85
+ setPath(name: string, path: string): void
73
86
  }
74
87
  export interface BrowserWindowConstructor {
75
88
  new(options?: Record<string, unknown>): BrowserWindow