dsh-builtin-browser 0.1.10 → 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,6 +16,19 @@
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';
21
34
  /** Download cap: the body is shipped base64 as one JSON line; bound the memory. */
@@ -121,6 +134,94 @@ async function handle(op, msg) {
121
134
  reply(msg.id, { ok: true, result });
122
135
  return;
123
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
+ }
124
225
  case 'download': {
125
226
  const viewId = msg.viewId;
126
227
  const url = msg.url;
@@ -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). */
@@ -569,36 +569,53 @@ export class ElectronBrowserProvider {
569
569
  }
570
570
  /** Capture the current page, optionally full-page. PNG only (CDP JPEG hangs on Electron 43). */
571
571
  async screenshot(session, request, signal) {
572
- const { handle } = this.activeTab(this.session(session));
572
+ const s = this.session(session);
573
+ const { handle } = this.activeTab(s);
573
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.
574
592
  const params = {};
575
593
  if (request?.fullPage === true) {
576
594
  // `captureBeyondViewport` captures the full scrollable content; without
577
595
  // a clip this yields the full-page image (CDP default is the viewport).
578
596
  params.captureBeyondViewport = true;
579
597
  }
580
- // Bound the capture: a view that has not painted can wedge
581
- // Page.captureScreenshot indefinitely (observed after view re-showing).
582
598
  const timeoutMs = 30_000;
583
599
  const result = await withTimeout(handle.sendCommand(CDP_PAGE_CAPTURE_SCREENSHOT, params), timeoutMs, signal, `browser: screenshot timed out after ${timeoutMs}ms`);
584
600
  const data = result.data;
585
601
  if (typeof data !== 'string') {
586
602
  throw new BrowserError('browser: screenshot returned no image data', 'BROWSER_SCREENSHOT_FAILED');
587
603
  }
588
- // Optional disk write so a vision tool (e.g. modlens read_image) can read
589
- // the screenshot from a file path. Fire-and-forget: a write failure must
590
- // not fail the capture.
591
- 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) {
592
609
  try {
593
- writeFileSync(request.savePath, Buffer.from(data, 'base64'));
594
- 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 };
595
612
  }
596
613
  catch (error) {
597
614
  // Report the write problem but keep the capture usable.
598
- 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 });
599
616
  }
600
617
  }
601
- return { dataUrl: `data:image/png;base64,${data}` };
618
+ return { dataUrl: `data:image/png;base64,${base64}` };
602
619
  }
603
620
  /** Append one operation to the session's history. */
604
621
  record(s, action, params, ok, detail) {
@@ -26,12 +26,13 @@ const READY_TIMEOUT_MS = 20_000;
26
26
  /** Safety cap on a single RPC reply line (base64 downloads are the big ones). */
27
27
  const MAX_RPC_BUFFER_BYTES = 512 * 1024 * 1024;
28
28
  /**
29
- * Locate the Electron binary, trying, in order:
29
+ * Locate the Electron binary:
30
30
  * 1. require('electron') from this module (optional peer dependency),
31
- * 2. require.resolve('electron') against DSH install anchors (global dsh
32
- * package, npm global root) — desktop-shell environments satisfy this,
33
- * 3. a pnpm virtual store under common workspace layouts,
34
- * 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.
35
36
  * @returns the path to the Electron executable.
36
37
  */
37
38
  function resolveElectronPath() {
@@ -45,8 +46,16 @@ function resolveElectronPath() {
45
46
  catch {
46
47
  // continue probing
47
48
  }
48
- // 2. DSH install anchors: the global dsh package and the npm global root
49
- // 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
+ };
50
59
  const anchors = [];
51
60
  const globalPrefix = process.env.npm_config_prefix ?? process.env.PREFIX;
52
61
  if (globalPrefix !== undefined) {
@@ -60,21 +69,13 @@ function resolveElectronPath() {
60
69
  try {
61
70
  const resolved = require.resolve('electron', { paths: [anchor] });
62
71
  if (typeof resolved === 'string' && resolved.length > 0) {
63
- // electron's index.js is not the binary; find the dist executable
64
- // next to the package root.
65
- const exe = electronExeBeside(resolved);
66
- if (exe !== undefined)
67
- return exe;
72
+ add(versionOf(resolved), electronExeBeside(resolved));
68
73
  }
69
74
  }
70
75
  catch {
71
76
  // continue probing
72
77
  }
73
78
  }
74
- // 3. pnpm virtual store, walking up from several candidate roots: this
75
- // module (its real path may differ from where the profile resolves it when
76
- // installed via a junction/link), the process CWD, and the Node executable
77
- // dir (DSH CLI typically runs from the workspace root).
78
79
  const roots = new Set([
79
80
  fileURLToPath(new URL('.', import.meta.url)),
80
81
  process.cwd(),
@@ -85,9 +86,12 @@ function resolveElectronPath() {
85
86
  for (let depth = 0; depth < 8; depth++) {
86
87
  const store = join(dir, 'node_modules', '.pnpm');
87
88
  if (existsSync(store)) {
88
- const exe = findElectronExe(store);
89
- if (exe !== undefined)
90
- 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
+ }
91
95
  }
92
96
  const parent = join(dir, '..');
93
97
  if (parent === dir)
@@ -95,13 +99,31 @@ function resolveElectronPath() {
95
99
  dir = parent;
96
100
  }
97
101
  }
98
- // 4. Explicit override.
99
- const override = process.env.ELECTRON_PATH;
100
- if (typeof override === 'string' && override.length > 0 && existsSync(override))
101
- 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
+ }
102
108
  throw new Error('dsh-builtin-browser: cannot locate the Electron binary. Install electron in the host profile ' +
103
109
  '(e.g. `dsh plugin --profile web add electron`) or set ELECTRON_PATH to the electron executable.');
104
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
+ }
105
127
  /** From an electron package entry file, find the dist executable beside it. */
106
128
  function electronExeBeside(entry) {
107
129
  const candidates = [
@@ -116,6 +138,14 @@ function electronExeBeside(entry) {
116
138
  }
117
139
  return undefined;
118
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
+ }
119
149
  /** dirname without importing node:path's dirname separately. */
120
150
  function dirname(p) {
121
151
  const i = p.lastIndexOf('/');
@@ -123,23 +153,6 @@ function dirname(p) {
123
153
  const k = Math.max(i, j);
124
154
  return k < 0 ? p : p.slice(0, k);
125
155
  }
126
- /** Search a pnpm virtual store for an Electron dist binary. */
127
- function findElectronExe(store) {
128
- const entries = existsSync(store) ? readdirSync(store) : [];
129
- for (const entry of entries) {
130
- if (!entry.startsWith('electron@'))
131
- continue;
132
- const candidates = [
133
- join(store, entry, 'node_modules', 'electron', 'dist', 'electron.exe'),
134
- join(store, entry, 'node_modules', 'electron', 'dist', 'electron'),
135
- ];
136
- for (const candidate of candidates) {
137
- if (existsSync(candidate))
138
- return candidate;
139
- }
140
- }
141
- return undefined;
142
- }
143
156
  /**
144
157
  * Line-delimited JSON-RPC client over a local TCP socket. Electron's main
145
158
  * process on Windows does not receive piped stdin, so the parent listens on a
@@ -164,6 +177,7 @@ class ElectronChildClient {
164
177
  this.port = port;
165
178
  this.onExit = onExit;
166
179
  const electron = resolveElectronPath();
180
+ process.stderr.write(`[dsh-browser host] spawning electron: ${electron}\n`);
167
181
  // ELECTRON_RUN_AS_NODE (even an empty string) makes Electron run as plain
168
182
  // Node, breaking require('electron'); NODE_OPTIONS can inject flags that
169
183
  // break the child. Rebuild the env without either.
@@ -310,6 +324,10 @@ class RemoteView {
310
324
  const result = await this.client.call('download', { viewId: this.id, url, savePath });
311
325
  writeFileSync(savePath, Buffer.from(result.base64, 'base64'));
312
326
  }
327
+ /** Native capturePage snapshot of the view (PNG base64 + size). */
328
+ capture() {
329
+ return this.client.call('capture', { viewId: this.id });
330
+ }
313
331
  /** Export the session's cookies (login state). */
314
332
  flushAuth() {
315
333
  return this.client.call('flushAuth', { viewId: this.id }).then(r => r.cookies);
@@ -480,6 +498,10 @@ class DeferredRemoteView {
480
498
  const view = await this.materializeOnce();
481
499
  return view.download(url, savePath);
482
500
  }
501
+ async capture() {
502
+ const view = await this.materializeOnce();
503
+ return view.capture();
504
+ }
483
505
  async flushAuth() {
484
506
  const view = await this.materializeOnce();
485
507
  return view.flushAuth();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-builtin-browser",
3
- "version": "0.1.10",
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,6 +17,19 @@
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'
@@ -120,6 +133,73 @@ async function handle(op: string, msg: { id: number; viewId?: string; method?: s
120
133
  reply(msg.id, { ok: true, result })
121
134
  return
122
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
+ }
123
203
  case 'download': {
124
204
  const viewId = msg.viewId
125
205
  const url = msg.url
@@ -743,16 +743,37 @@ export class ElectronBrowserProvider implements BrowserProvider {
743
743
  request?: { readonly fullPage?: boolean; readonly savePath?: string },
744
744
  signal?: AbortSignal,
745
745
  ): Promise<{ readonly dataUrl: string; readonly path?: string }> {
746
- const { handle } = this.activeTab(this.session(session))
746
+ const s = this.session(session)
747
+ const { handle } = this.activeTab(s)
747
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.
748
771
  const params: Record<string, unknown> = {}
749
772
  if (request?.fullPage === true) {
750
773
  // `captureBeyondViewport` captures the full scrollable content; without
751
774
  // a clip this yields the full-page image (CDP default is the viewport).
752
775
  params.captureBeyondViewport = true
753
776
  }
754
- // Bound the capture: a view that has not painted can wedge
755
- // Page.captureScreenshot indefinitely (observed after view re-showing).
756
777
  const timeoutMs = 30_000
757
778
  const result = await withTimeout(
758
779
  handle.sendCommand(CDP_PAGE_CAPTURE_SCREENSHOT, params),
@@ -764,19 +785,21 @@ export class ElectronBrowserProvider implements BrowserProvider {
764
785
  if (typeof data !== 'string') {
765
786
  throw new BrowserError('browser: screenshot returned no image data', 'BROWSER_SCREENSHOT_FAILED')
766
787
  }
767
- // Optional disk write so a vision tool (e.g. modlens read_image) can read
768
- // the screenshot from a file path. Fire-and-forget: a write failure must
769
- // not fail the capture.
770
- 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) {
771
794
  try {
772
- writeFileSync(request.savePath, Buffer.from(data, 'base64'))
773
- 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 }
774
797
  } catch (error) {
775
798
  // Report the write problem but keep the capture usable.
776
- 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 })
777
800
  }
778
801
  }
779
- return { dataUrl: `data:image/png;base64,${data}` }
802
+ return { dataUrl: `data:image/png;base64,${base64}` }
780
803
  }
781
804
 
782
805
  /** Append one operation to the session's history. */
@@ -32,12 +32,13 @@ const READY_TIMEOUT_MS = 20_000
32
32
  const MAX_RPC_BUFFER_BYTES = 512 * 1024 * 1024
33
33
 
34
34
  /**
35
- * Locate the Electron binary, trying, in order:
35
+ * Locate the Electron binary:
36
36
  * 1. require('electron') from this module (optional peer dependency),
37
- * 2. require.resolve('electron') against DSH install anchors (global dsh
38
- * package, npm global root) — desktop-shell environments satisfy this,
39
- * 3. a pnpm virtual store under common workspace layouts,
40
- * 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.
41
42
  * @returns the path to the Electron executable.
42
43
  */
43
44
  function resolveElectronPath(): string {
@@ -49,8 +50,14 @@ function resolveElectronPath(): string {
49
50
  } catch {
50
51
  // continue probing
51
52
  }
52
- // 2. DSH install anchors: the global dsh package and the npm global root
53
- // 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
+ }
54
61
  const anchors: string[] = []
55
62
  const globalPrefix = process.env.npm_config_prefix ?? process.env.PREFIX
56
63
  if (globalPrefix !== undefined) {
@@ -64,19 +71,12 @@ function resolveElectronPath(): string {
64
71
  try {
65
72
  const resolved: unknown = require.resolve('electron', { paths: [anchor] })
66
73
  if (typeof resolved === 'string' && resolved.length > 0) {
67
- // electron's index.js is not the binary; find the dist executable
68
- // next to the package root.
69
- const exe = electronExeBeside(resolved)
70
- if (exe !== undefined) return exe
74
+ add(versionOf(resolved), electronExeBeside(resolved))
71
75
  }
72
76
  } catch {
73
77
  // continue probing
74
78
  }
75
79
  }
76
- // 3. pnpm virtual store, walking up from several candidate roots: this
77
- // module (its real path may differ from where the profile resolves it when
78
- // installed via a junction/link), the process CWD, and the Node executable
79
- // dir (DSH CLI typically runs from the workspace root).
80
80
  const roots = new Set<string>([
81
81
  fileURLToPath(new URL('.', import.meta.url)),
82
82
  process.cwd(),
@@ -87,23 +87,45 @@ function resolveElectronPath(): string {
87
87
  for (let depth = 0; depth < 8; depth++) {
88
88
  const store = join(dir, 'node_modules', '.pnpm')
89
89
  if (existsSync(store)) {
90
- const exe = findElectronExe(store)
91
- 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
+ }
92
95
  }
93
96
  const parent = join(dir, '..')
94
97
  if (parent === dir) break
95
98
  dir = parent
96
99
  }
97
100
  }
98
- // 4. Explicit override.
99
- const override = process.env.ELECTRON_PATH
100
- 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
+ }
101
106
  throw new Error(
102
107
  'dsh-builtin-browser: cannot locate the Electron binary. Install electron in the host profile ' +
103
108
  '(e.g. `dsh plugin --profile web add electron`) or set ELECTRON_PATH to the electron executable.',
104
109
  )
105
110
  }
106
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
+
107
129
  /** From an electron package entry file, find the dist executable beside it. */
108
130
  function electronExeBeside(entry: string): string | undefined {
109
131
  const candidates = [
@@ -118,6 +140,14 @@ function electronExeBeside(entry: string): string | undefined {
118
140
  return undefined
119
141
  }
120
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
+
121
151
  /** dirname without importing node:path's dirname separately. */
122
152
  function dirname(p: string): string {
123
153
  const i = p.lastIndexOf('/')
@@ -126,22 +156,6 @@ function dirname(p: string): string {
126
156
  return k < 0 ? p : p.slice(0, k)
127
157
  }
128
158
 
129
- /** Search a pnpm virtual store for an Electron dist binary. */
130
- function findElectronExe(store: string): string | undefined {
131
- const entries = existsSync(store) ? readdirSync(store) : []
132
- for (const entry of entries) {
133
- if (!entry.startsWith('electron@')) continue
134
- const candidates = [
135
- join(store, entry, 'node_modules', 'electron', 'dist', 'electron.exe'),
136
- join(store, entry, 'node_modules', 'electron', 'dist', 'electron'),
137
- ]
138
- for (const candidate of candidates) {
139
- if (existsSync(candidate)) return candidate
140
- }
141
- }
142
- return undefined
143
- }
144
-
145
159
  /** One RPC round-trip with the child. */
146
160
  interface Pending {
147
161
  resolve(result: unknown): void
@@ -171,6 +185,7 @@ class ElectronChildClient {
171
185
  private readonly onExit?: () => void,
172
186
  ) {
173
187
  const electron = resolveElectronPath()
188
+ process.stderr.write(`[dsh-browser host] spawning electron: ${electron}\n`)
174
189
  // ELECTRON_RUN_AS_NODE (even an empty string) makes Electron run as plain
175
190
  // Node, breaking require('electron'); NODE_OPTIONS can inject flags that
176
191
  // break the child. Rebuild the env without either.
@@ -308,6 +323,11 @@ class RemoteView implements ElectronViewHandle {
308
323
  writeFileSync(savePath, Buffer.from(result.base64, 'base64'))
309
324
  }
310
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
+
311
331
  /** Export the session's cookies (login state). */
312
332
  flushAuth(): Promise<ExportedCookie[]> {
313
333
  return this.client.call<{ cookies: ExportedCookie[] }>('flushAuth', { viewId: this.id }).then(r => r.cookies)
@@ -483,6 +503,11 @@ class DeferredRemoteView implements ElectronViewHandle {
483
503
  return view.download(url, savePath)
484
504
  }
485
505
 
506
+ async capture(): Promise<{ base64: string; width: number; height: number }> {
507
+ const view = await this.materializeOnce()
508
+ return view.capture()
509
+ }
510
+
486
511
  async flushAuth(): Promise<ExportedCookie[]> {
487
512
  const view = await this.materializeOnce()
488
513
  return view.flushAuth()
@@ -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