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 +7 -1
- package/README.md +5 -0
- package/lib/browser-electron/host-main.js +125 -12
- package/lib/browser-electron/provider.d.ts +2 -0
- package/lib/browser-electron/provider.js +39 -15
- package/lib/browser-electron/remote-host.js +114 -53
- package/package.json +1 -1
- package/src/browser-electron/host-main.ts +105 -12
- package/src/browser-electron/provider.ts +55 -15
- package/src/browser-electron/remote-host.ts +114 -49
- package/src/types/electron-shim.d.ts +13 -0
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
|
-
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
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(
|
|
589
|
-
return { dataUrl: `data:image/png;base64,${
|
|
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 "${
|
|
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,${
|
|
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
|
-
|
|
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
|
|
29
|
+
* Locate the Electron binary:
|
|
28
30
|
* 1. require('electron') from this module (optional peer dependency),
|
|
29
|
-
* 2.
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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.
|
|
47
|
-
|
|
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
|
-
|
|
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
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
333
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
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(
|
|
763
|
-
return { dataUrl: `data:image/png;base64,${
|
|
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 "${
|
|
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,${
|
|
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
|
-
|
|
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
|
|
35
|
+
* Locate the Electron binary:
|
|
33
36
|
* 1. require('electron') from this module (optional peer dependency),
|
|
34
|
-
* 2.
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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.
|
|
50
|
-
|
|
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
|
-
|
|
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
|
|
88
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
330
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|