dsh-builtin-browser 0.1.23 → 0.2.0
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 +45 -2
- package/README.md +46 -3
- package/client.js +298 -0
- package/cordis.patch.yml +24 -5
- package/docs/README.md +1 -1
- package/docs/architecture.md +1 -1
- package/docs/tool-reference.md +2 -1
- package/lib/browser/runtime.d.ts +18 -1
- package/lib/browser/runtime.js +19 -0
- package/lib/browser/types.d.ts +33 -0
- package/lib/browser-electron/entry.js +115 -0
- package/lib/browser-electron/history-store.d.ts +71 -0
- package/lib/browser-electron/history-store.js +151 -0
- package/lib/browser-electron/host-main.js +28 -1
- package/lib/browser-electron/provider.d.ts +80 -0
- package/lib/browser-electron/provider.js +142 -2
- package/lib/browser-electron/remote-host.d.ts +8 -0
- package/lib/browser-electron/remote-host.js +64 -6
- package/lib/browser-electron/settings-store.d.ts +87 -0
- package/lib/browser-electron/settings-store.js +160 -0
- package/lib/browser-electron/virtual-cursor.d.ts +42 -0
- package/lib/browser-electron/virtual-cursor.js +71 -0
- package/lib/index.d.ts +16 -2
- package/lib/index.js +18 -2
- package/lib/tool-browser/index.js +72 -2
- package/package.json +13 -1
- package/src/browser/runtime.ts +22 -0
- package/src/browser/types.ts +31 -0
- package/src/browser-electron/entry.ts +135 -0
- package/src/browser-electron/history-store.ts +162 -0
- package/src/browser-electron/host-main.ts +26 -1
- package/src/browser-electron/provider.ts +166 -2
- package/src/browser-electron/remote-host.ts +67 -5
- package/src/browser-electron/settings-store.ts +198 -0
- package/src/browser-electron/virtual-cursor.ts +82 -0
- package/src/index.ts +20 -2
- package/src/tool-browser/index.ts +71 -1
|
@@ -10,6 +10,8 @@ import { randomUUID } from 'node:crypto';
|
|
|
10
10
|
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
11
11
|
import { homedir } from 'node:os';
|
|
12
12
|
import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
|
|
13
|
+
import { HistoryStore } from './history-store.js';
|
|
14
|
+
import { paintCursor } from './virtual-cursor.js';
|
|
13
15
|
import { BrowserError } from '../browser/types.js';
|
|
14
16
|
/**
|
|
15
17
|
* Page-context human-verification (CAPTCHA / bot-detection) detection. Runs
|
|
@@ -135,6 +137,13 @@ export class ElectronBrowserProvider {
|
|
|
135
137
|
snapshotMaxElements;
|
|
136
138
|
contentMaxChars;
|
|
137
139
|
downloadDir;
|
|
140
|
+
/**
|
|
141
|
+
* Persistent browsing history, or `undefined` when the user turned it off.
|
|
142
|
+
* Sessions come and go; this record outlives all of them.
|
|
143
|
+
*/
|
|
144
|
+
historyStore;
|
|
145
|
+
/** Live settings source; absent when nothing owns a settings document. */
|
|
146
|
+
settingsSource;
|
|
138
147
|
constructor(host, config = {}) {
|
|
139
148
|
this.host = host;
|
|
140
149
|
this.httpOnly = config.httpOnly ?? true;
|
|
@@ -146,9 +155,21 @@ export class ElectronBrowserProvider {
|
|
|
146
155
|
// sandbox does not have to arbitrate. The localized name is probed so a
|
|
147
156
|
// Chinese desktop (`~/下载`) works without configuring `downloadDir`.
|
|
148
157
|
this.downloadDir = config.downloadDir ?? defaultDownloadDir();
|
|
158
|
+
this.settingsSource = config.settings;
|
|
159
|
+
// Browsing history follows the browser profile's persistence rules (close
|
|
160
|
+
// the interface, lose nothing), and can be switched off from settings.
|
|
161
|
+
this.historyStore = config.history?.enabled === false
|
|
162
|
+
? undefined
|
|
163
|
+
: new HistoryStore(config.history?.file, {
|
|
164
|
+
...config.history?.maxEntries !== undefined ? { maxEntries: config.history.maxEntries } : {},
|
|
165
|
+
...config.history?.maxAgeDays !== undefined ? { maxAgeDays: config.history.maxAgeDays } : {},
|
|
166
|
+
});
|
|
149
167
|
// Route toolbar (host UI) actions into the session model: the human and
|
|
150
168
|
// the agent then always drive the same tabs, history, and navigation.
|
|
151
169
|
this.host.onUserAction?.(action => { void this.handleUserAction(action); });
|
|
170
|
+
// A window the human closed ends the session it showed: the next call opens a
|
|
171
|
+
// clean one instead of driving a page nobody can see.
|
|
172
|
+
this.host.onViewClosed?.(windowId => { void this.handleViewClosed(windowId); });
|
|
152
173
|
}
|
|
153
174
|
/**
|
|
154
175
|
* Usable when the host says it can back views (the self-hosted host probes
|
|
@@ -313,6 +334,7 @@ export class ElectronBrowserProvider {
|
|
|
313
334
|
// NEXT observation sees, not whether this action happened).
|
|
314
335
|
this.record(s, 'navigate', { url }, true);
|
|
315
336
|
await this.settleDocument(handle, before, signal);
|
|
337
|
+
this.recordVisit(s, handle);
|
|
316
338
|
this.showActive(s);
|
|
317
339
|
}
|
|
318
340
|
catch (error) {
|
|
@@ -951,6 +973,9 @@ export class ElectronBrowserProvider {
|
|
|
951
973
|
catch (error) {
|
|
952
974
|
throw new BrowserError(`browser: click failed: ${String(error)}`, 'BROWSER_CLICK_FAILED', { cause: error });
|
|
953
975
|
}
|
|
976
|
+
// Paint before the press so the ripple is already on screen when the click
|
|
977
|
+
// lands — the cursor exists to show the human WHERE the agent acts.
|
|
978
|
+
this.showCursor(handle, x, y, 'click');
|
|
954
979
|
const release = () => {
|
|
955
980
|
void handle
|
|
956
981
|
.sendCommand('Input.dispatchMouseEvent', {
|
|
@@ -997,7 +1022,11 @@ export class ElectronBrowserProvider {
|
|
|
997
1022
|
el.focus()
|
|
998
1023
|
return { ok: true }
|
|
999
1024
|
`);
|
|
1000
|
-
|
|
1025
|
+
const locateTab = this.activeTab(s);
|
|
1026
|
+
const out = await this.runTargetScript(locateTab, script, locateMs, signal, 'BROWSER_TYPE_FAILED', 'browser: type');
|
|
1027
|
+
const cursorPoint = scriptPoint(out);
|
|
1028
|
+
if (cursorPoint !== undefined)
|
|
1029
|
+
this.showCursor(locateTab.handle, cursorPoint.x, cursorPoint.y, 'click');
|
|
1001
1030
|
}
|
|
1002
1031
|
const text = 'text' in request ? request.text : '';
|
|
1003
1032
|
const timeoutMs = 30_000;
|
|
@@ -1067,6 +1096,7 @@ export class ElectronBrowserProvider {
|
|
|
1067
1096
|
await withTimeout(handle.sendCommand('Page.navigateToHistoryEntry', { entryId: entry.id }), timeoutMs, signal, `browser: history navigation timed out after ${timeoutMs}ms`, () => { void handle.sendCommand('Page.stopLoading').catch(() => { }); });
|
|
1068
1097
|
this.record(s, direction === -1 ? 'back' : 'forward', {}, true);
|
|
1069
1098
|
await this.settleDocument(handle, before, signal);
|
|
1099
|
+
this.recordVisit(s, handle);
|
|
1070
1100
|
this.showActive(s);
|
|
1071
1101
|
}
|
|
1072
1102
|
/** Go back in the active tab's history. */
|
|
@@ -1091,6 +1121,7 @@ export class ElectronBrowserProvider {
|
|
|
1091
1121
|
() => { void handle.sendCommand('Page.stopLoading').catch(() => { }); });
|
|
1092
1122
|
this.record(s, 'reload', {}, true);
|
|
1093
1123
|
await this.settleDocument(handle, before, signal);
|
|
1124
|
+
this.recordVisit(s, handle);
|
|
1094
1125
|
this.showActive(s);
|
|
1095
1126
|
}
|
|
1096
1127
|
/** Press one named key (Enter/Tab/arrows/…) via CDP key events. */
|
|
@@ -1332,7 +1363,18 @@ export class ElectronBrowserProvider {
|
|
|
1332
1363
|
if (Date.now() >= deadline) return { ok: false, error: 'element not found: ' + JSON.stringify(spec) + ' (looked for ' + timeoutMs + 'ms)' }
|
|
1333
1364
|
await sleep(100)
|
|
1334
1365
|
}
|
|
1366
|
+
const __result = await (async () => {
|
|
1335
1367
|
${body}
|
|
1368
|
+
})()
|
|
1369
|
+
// Report the operated element's viewport center next to the script's own
|
|
1370
|
+
// verdict: the synthetic cursor (and any future feedback) needs the point,
|
|
1371
|
+
// and no individual body should have to compute it a second time. Kept
|
|
1372
|
+
// under a distinct key so a body that already returns x/y keeps them.
|
|
1373
|
+
const __rect = el.getBoundingClientRect()
|
|
1374
|
+
return {
|
|
1375
|
+
...(typeof __result === 'object' && __result !== null ? __result : { ok: true }),
|
|
1376
|
+
__point: { x: Math.round(__rect.x + __rect.width / 2), y: Math.round(__rect.y + __rect.height / 2) },
|
|
1377
|
+
}
|
|
1336
1378
|
})()`;
|
|
1337
1379
|
}
|
|
1338
1380
|
/**
|
|
@@ -1397,6 +1439,9 @@ export class ElectronBrowserProvider {
|
|
|
1397
1439
|
`;
|
|
1398
1440
|
const script = this.buildTargetScript(request.target, timeoutMs, body);
|
|
1399
1441
|
const out = await this.runTargetScript(tab, script, timeoutMs, signal, 'BROWSER_SET_VALUE_FAILED', 'browser: setValue');
|
|
1442
|
+
const cursorPoint = scriptPoint(out);
|
|
1443
|
+
if (cursorPoint !== undefined)
|
|
1444
|
+
this.showCursor(tab.handle, cursorPoint.x, cursorPoint.y, 'click');
|
|
1400
1445
|
this.record(s, 'setValue', { target: request.target, value: String(request.value) }, true, { result: out.method });
|
|
1401
1446
|
return { method: out.method, value: out.value };
|
|
1402
1447
|
}
|
|
@@ -1422,6 +1467,9 @@ export class ElectronBrowserProvider {
|
|
|
1422
1467
|
`;
|
|
1423
1468
|
const script = this.buildTargetScript(request.target, timeoutMs, body);
|
|
1424
1469
|
const out = await this.runTargetScript(tab, script, timeoutMs, signal, 'BROWSER_CHECK_FAILED', 'browser: check');
|
|
1470
|
+
const cursorPoint = scriptPoint(out);
|
|
1471
|
+
if (cursorPoint !== undefined)
|
|
1472
|
+
this.showCursor(tab.handle, cursorPoint.x, cursorPoint.y, 'click');
|
|
1425
1473
|
this.record(s, 'check', { target: request.target, checked: want }, true);
|
|
1426
1474
|
return { checked: out.checked === true };
|
|
1427
1475
|
}
|
|
@@ -1453,6 +1501,9 @@ export class ElectronBrowserProvider {
|
|
|
1453
1501
|
`;
|
|
1454
1502
|
const script = this.buildTargetScript(request.target, timeoutMs, body);
|
|
1455
1503
|
const out = await this.runTargetScript(tab, script, timeoutMs, signal, 'BROWSER_SELECT_FAILED', 'browser: select');
|
|
1504
|
+
const cursorPoint = scriptPoint(out);
|
|
1505
|
+
if (cursorPoint !== undefined)
|
|
1506
|
+
this.showCursor(tab.handle, cursorPoint.x, cursorPoint.y, 'click');
|
|
1456
1507
|
this.record(s, 'select', { target: request.target, optionValue: request.optionValue ?? null, optionText: request.optionText ?? null, optionIndex: request.optionIndex ?? null }, true);
|
|
1457
1508
|
return { value: out.value, text: out.text };
|
|
1458
1509
|
}
|
|
@@ -1485,7 +1536,10 @@ export class ElectronBrowserProvider {
|
|
|
1485
1536
|
return { ok: true }
|
|
1486
1537
|
`;
|
|
1487
1538
|
const script = this.buildTargetScript(request.target, timeoutMs, body);
|
|
1488
|
-
await this.runTargetScript(tab, script, timeoutMs, signal, 'BROWSER_CLEAR_FAILED', 'browser: clear');
|
|
1539
|
+
const out = await this.runTargetScript(tab, script, timeoutMs, signal, 'BROWSER_CLEAR_FAILED', 'browser: clear');
|
|
1540
|
+
const cursorPoint = scriptPoint(out);
|
|
1541
|
+
if (cursorPoint !== undefined)
|
|
1542
|
+
this.showCursor(tab.handle, cursorPoint.x, cursorPoint.y, 'click');
|
|
1489
1543
|
this.record(s, 'clear', { target: request.target }, true);
|
|
1490
1544
|
return { cleared: true };
|
|
1491
1545
|
}
|
|
@@ -1775,6 +1829,62 @@ export class ElectronBrowserProvider {
|
|
|
1775
1829
|
async history(session) {
|
|
1776
1830
|
return this.session(session).history;
|
|
1777
1831
|
}
|
|
1832
|
+
/**
|
|
1833
|
+
* Record one page visit in the persistent browsing history. Fire-and-forget by
|
|
1834
|
+
* design: a visit must never delay or fail the navigation that produced it,
|
|
1835
|
+
* and the title is only worth reading once the document has settled.
|
|
1836
|
+
* @param s - the session that drove the visit.
|
|
1837
|
+
* @param handle - the view whose document just loaded.
|
|
1838
|
+
*/
|
|
1839
|
+
recordVisit(s, handle) {
|
|
1840
|
+
const store = this.historyStore;
|
|
1841
|
+
if (store === undefined)
|
|
1842
|
+
return;
|
|
1843
|
+
// The settings panel can switch recording off at runtime; the static config
|
|
1844
|
+
// only supplies the startup default.
|
|
1845
|
+
if (this.settingsSource !== undefined && !this.settingsSource().history.enabled)
|
|
1846
|
+
return;
|
|
1847
|
+
void handleSendEvaluate(handle, 'location.href + "\\u0000" + (document.title || "")')
|
|
1848
|
+
.then(result => {
|
|
1849
|
+
if (!result.ok || typeof result.value !== 'string')
|
|
1850
|
+
return;
|
|
1851
|
+
const [url = '', title = ''] = result.value.split('\u0000');
|
|
1852
|
+
// Internal documents are not visits: an untouched browser is not history.
|
|
1853
|
+
if (url === '' || url.startsWith('about:') || url.startsWith('devtools:'))
|
|
1854
|
+
return;
|
|
1855
|
+
store.append({
|
|
1856
|
+
at: Date.now(),
|
|
1857
|
+
url,
|
|
1858
|
+
...title.trim() !== '' ? { title: title.trim() } : {},
|
|
1859
|
+
...s.label !== undefined ? { session: s.label } : {},
|
|
1860
|
+
});
|
|
1861
|
+
store.prune();
|
|
1862
|
+
})
|
|
1863
|
+
.catch(() => { });
|
|
1864
|
+
}
|
|
1865
|
+
/**
|
|
1866
|
+
* List persisted visits, newest first — what `browser_visited` reads back.
|
|
1867
|
+
* @param options - result cap and an optional hostname filter.
|
|
1868
|
+
* @returns the matching visits, or an empty list when history is disabled.
|
|
1869
|
+
*/
|
|
1870
|
+
visited(options = {}) {
|
|
1871
|
+
return this.historyStore?.list(options) ?? [];
|
|
1872
|
+
}
|
|
1873
|
+
/**
|
|
1874
|
+
* Show the synthetic pointer on a view, unless the user switched it off. The
|
|
1875
|
+
* cursor is the "the agent has taken over this tab" signal, so it is painted
|
|
1876
|
+
* for every operation that has a landing point — including DOM-level ones that
|
|
1877
|
+
* move no real pointer.
|
|
1878
|
+
* @param handle - the view to paint into.
|
|
1879
|
+
* @param x - viewport x in CSS pixels.
|
|
1880
|
+
* @param y - viewport y in CSS pixels.
|
|
1881
|
+
* @param action - click pulses a ripple; move only relocates.
|
|
1882
|
+
*/
|
|
1883
|
+
showCursor(handle, x, y, action) {
|
|
1884
|
+
if (this.settingsSource !== undefined && !this.settingsSource().ui.virtualCursor)
|
|
1885
|
+
return;
|
|
1886
|
+
paintCursor(handle, x, y, action, (target, expression) => handleSendEvaluate(target, expression));
|
|
1887
|
+
}
|
|
1778
1888
|
/**
|
|
1779
1889
|
* Replay one recorded operation by sequence number. Navigate/click/type are
|
|
1780
1890
|
* re-issued against the current page; execute re-runs its script. The
|
|
@@ -1888,6 +1998,29 @@ export class ElectronBrowserProvider {
|
|
|
1888
1998
|
tabByViewId(s, viewId) {
|
|
1889
1999
|
return s.tabs.find(tab => tab.handle.id === viewId);
|
|
1890
2000
|
}
|
|
2001
|
+
/**
|
|
2002
|
+
* Whether a session is still live. The human closing a window ends its session
|
|
2003
|
+
* (see {@link handleViewClosed}), so callers that cache ids must ask first.
|
|
2004
|
+
* @param session - the session id to test.
|
|
2005
|
+
*/
|
|
2006
|
+
exists(session) {
|
|
2007
|
+
return this.sessions.has(session);
|
|
2008
|
+
}
|
|
2009
|
+
/**
|
|
2010
|
+
* The human closed a browser window: the session that window showed is over.
|
|
2011
|
+
* Ending it here is what makes the next call a clean start rather than a
|
|
2012
|
+
* resurrection of an invisible window. Browsing history and login state live
|
|
2013
|
+
* on disk, so nothing the human cares about is lost with it.
|
|
2014
|
+
* @param windowId - the group key the host reported, which is the session id.
|
|
2015
|
+
*/
|
|
2016
|
+
async handleViewClosed(windowId) {
|
|
2017
|
+
if (typeof windowId !== 'string' || windowId === '')
|
|
2018
|
+
return;
|
|
2019
|
+
const s = this.sessions.get(windowId);
|
|
2020
|
+
if (s === undefined)
|
|
2021
|
+
return;
|
|
2022
|
+
await this.close(s.id).catch(() => { });
|
|
2023
|
+
}
|
|
1891
2024
|
/**
|
|
1892
2025
|
* Route a user-initiated action from the host's UI into the session model.
|
|
1893
2026
|
* Fire-and-forget by design: a user action failing (e.g. an unreachable
|
|
@@ -2079,6 +2212,13 @@ function terminatePage(handle) {
|
|
|
2079
2212
|
* @param expression - the JS expression.
|
|
2080
2213
|
* @param signal - optional abort signal; a fired signal rejects the call.
|
|
2081
2214
|
*/
|
|
2215
|
+
/** Viewport point a target script reported, when it reported one. */
|
|
2216
|
+
function scriptPoint(out) {
|
|
2217
|
+
const point = out?.__point;
|
|
2218
|
+
const x = Number(point?.x);
|
|
2219
|
+
const y = Number(point?.y);
|
|
2220
|
+
return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : undefined;
|
|
2221
|
+
}
|
|
2082
2222
|
async function handleSendEvaluate(handle, expression, signal) {
|
|
2083
2223
|
signal?.throwIfAborted();
|
|
2084
2224
|
const result = await handle.sendCommand(CDP_RUNTIME_EVALUATE, {
|
|
@@ -118,6 +118,12 @@ export declare class RemoteElectronViewHost implements ElectronBrowserViewHost {
|
|
|
118
118
|
private readonly groups;
|
|
119
119
|
/** The provider's user-action handler; routes toolbar actions into sessions. */
|
|
120
120
|
private userActionHandler;
|
|
121
|
+
/**
|
|
122
|
+
* The provider's handler for a window the human closed. Closing the interface
|
|
123
|
+
* ends the session it showed: history and login state stay on disk, so the
|
|
124
|
+
* next open starts clean instead of resuming a window nobody can see.
|
|
125
|
+
*/
|
|
126
|
+
private viewClosedHandler;
|
|
121
127
|
constructor(hostMainPath: string,
|
|
122
128
|
/** Test seam: the executable to spawn instead of the resolved Electron
|
|
123
129
|
* binary. Absent -> resolveElectronPath() (production behavior). */
|
|
@@ -167,6 +173,8 @@ export declare class RemoteElectronViewHost implements ElectronBrowserViewHost {
|
|
|
167
173
|
groupView(handle: ElectronViewHandle, windowId: string, label?: string): void;
|
|
168
174
|
/** Register the provider's handler for user-initiated toolbar actions. */
|
|
169
175
|
onUserAction(handler: (action: BrowserUserAction) => void): void;
|
|
176
|
+
/** Register the provider's handler for windows the human closed. */
|
|
177
|
+
onViewClosed(handler: (windowId: string) => void): void;
|
|
170
178
|
/** Surface a failed user action to the child's toolbar (address bar etc.). */
|
|
171
179
|
notifyUserActionError(windowId: string, message: string): void;
|
|
172
180
|
destroyView(handle: ElectronViewHandle): void;
|
|
@@ -374,6 +374,7 @@ class ElectronChildClient {
|
|
|
374
374
|
token;
|
|
375
375
|
onExit;
|
|
376
376
|
onUserAction;
|
|
377
|
+
onViewClosed;
|
|
377
378
|
executable;
|
|
378
379
|
child;
|
|
379
380
|
pending = new Map();
|
|
@@ -392,6 +393,12 @@ class ElectronChildClient {
|
|
|
392
393
|
awaitingHello = true;
|
|
393
394
|
helloTimer;
|
|
394
395
|
constructor(hostMainPath, port, token, onExit, onUserAction,
|
|
396
|
+
/**
|
|
397
|
+
* Called when the human closes a browser window. The session it showed is
|
|
398
|
+
* over: the provider releases it, while history and login state stay on disk
|
|
399
|
+
* so the next open is a clean session rather than a resurrected one.
|
|
400
|
+
*/
|
|
401
|
+
onViewClosed,
|
|
395
402
|
/** Test seam: the executable to spawn instead of the resolved Electron
|
|
396
403
|
* binary. Absent -> resolveElectronPath() (production behavior). */
|
|
397
404
|
executable) {
|
|
@@ -400,9 +407,18 @@ class ElectronChildClient {
|
|
|
400
407
|
this.token = token;
|
|
401
408
|
this.onExit = onExit;
|
|
402
409
|
this.onUserAction = onUserAction;
|
|
410
|
+
this.onViewClosed = onViewClosed;
|
|
403
411
|
this.executable = executable;
|
|
404
412
|
const electron = this.executable ?? resolveElectronPath();
|
|
413
|
+
// Record what is about to be spawned — with existence checks — BEFORE the
|
|
414
|
+
// attempt. When Electron cannot load the app entry it exits 1 with EMPTY
|
|
415
|
+
// stderr, and that signature is otherwise indistinguishable from a missing
|
|
416
|
+
// binary: writing both paths (and whether they exist) in advance is what
|
|
417
|
+
// turns the next occurrence into a named cause instead of a guess.
|
|
405
418
|
process.stderr.write(`[dsh-browser host] spawning electron: ${electron}\n`);
|
|
419
|
+
appendHostLog(`${hostStamp()} spawning: electron=${electron} (exists=${String(existsSync(electron))})`
|
|
420
|
+
+ ` hostMain=${this.hostMainPath} (exists=${String(existsSync(this.hostMainPath))})`
|
|
421
|
+
+ ` port=${String(this.port)}\n`);
|
|
406
422
|
// ELECTRON_RUN_AS_NODE (even an empty string) makes Electron run as plain
|
|
407
423
|
// Node, breaking require('electron'); NODE_OPTIONS can inject flags that
|
|
408
424
|
// break the child. Rebuild the env without either.
|
|
@@ -439,15 +455,26 @@ class ElectronChildClient {
|
|
|
439
455
|
// A failed spawn (bad/corrupt binary) emits 'error' — without a listener
|
|
440
456
|
// that would crash the whole DSH process.
|
|
441
457
|
this.child.on('error', error => {
|
|
442
|
-
const line =
|
|
458
|
+
const line = `${hostStamp()} spawn error: ${String(error)} electron=${electron} hostMain=${this.hostMainPath}`;
|
|
443
459
|
process.stderr.write(`[dsh-browser host] ${line}\n`);
|
|
444
460
|
appendHostLog(`${line}\n`);
|
|
445
461
|
this.fail(new Error(`dsh-builtin-browser: browser host failed to start: ${String(error)}`));
|
|
446
462
|
});
|
|
447
463
|
this.child.on('exit', (code, signal) => {
|
|
448
464
|
// The exit code/signal is the other half of a crash report; without it a
|
|
449
|
-
// restart loop is indistinguishable from a clean stop.
|
|
450
|
-
|
|
465
|
+
// restart loop is indistinguishable from a clean stop. The timestamp, pid
|
|
466
|
+
// and both paths are what turn a bare `code=1` into a diagnosis: Electron
|
|
467
|
+
// exits 1 with empty stderr precisely when it cannot load the app entry,
|
|
468
|
+
// so the dated `exists=` line written at spawn is the other half.
|
|
469
|
+
const entryExists = existsSync(this.hostMainPath);
|
|
470
|
+
const line = `${hostStamp()} browser host exited (code=${String(code)} signal=${String(signal)})`
|
|
471
|
+
+ ` pid=${String(this.child?.pid)} electron=${electron} hostMain=${this.hostMainPath}`
|
|
472
|
+
+ ` entryExists=${String(entryExists)}`
|
|
473
|
+
// Comparing this with the `exists=` recorded at spawn is the whole
|
|
474
|
+
// diagnosis: present at spawn and absent now means the installation was
|
|
475
|
+
// replaced underneath a running host, which is exactly how Electron ends
|
|
476
|
+
// up unable to load the app entry and exits 1 with empty stderr.
|
|
477
|
+
+ (entryExists ? '' : ' [entry script missing at exit]');
|
|
451
478
|
appendHostLog(`${line}\n`);
|
|
452
479
|
this.fail(new Error(`dsh-builtin-browser: ${line}`));
|
|
453
480
|
});
|
|
@@ -537,6 +564,14 @@ class ElectronChildClient {
|
|
|
537
564
|
this.onUserAction?.(msg.action);
|
|
538
565
|
continue;
|
|
539
566
|
}
|
|
567
|
+
if (msg.op === 'viewClosed') {
|
|
568
|
+
// Fire-and-forget notification from the child's own UI: the human closed
|
|
569
|
+
// the window, so the interface that session showed is gone. The provider
|
|
570
|
+
// decides what that ends — browsing history and login state survive on
|
|
571
|
+
// disk, only the session itself does not.
|
|
572
|
+
this.onViewClosed?.(typeof msg.windowId === 'string' ? msg.windowId : '');
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
540
575
|
if (this.awaitingHello) {
|
|
541
576
|
// The FIRST line must be the child's hello carrying the spawn token.
|
|
542
577
|
// Anything else (or a wrong token) means a spoofed connection: drop
|
|
@@ -681,6 +716,12 @@ export class RemoteElectronViewHost {
|
|
|
681
716
|
groups = new Map();
|
|
682
717
|
/** The provider's user-action handler; routes toolbar actions into sessions. */
|
|
683
718
|
userActionHandler;
|
|
719
|
+
/**
|
|
720
|
+
* The provider's handler for a window the human closed. Closing the interface
|
|
721
|
+
* ends the session it showed: history and login state stay on disk, so the
|
|
722
|
+
* next open starts clean instead of resuming a window nobody can see.
|
|
723
|
+
*/
|
|
724
|
+
viewClosedHandler;
|
|
684
725
|
constructor(hostMainPath,
|
|
685
726
|
/** Test seam: the executable to spawn instead of the resolved Electron
|
|
686
727
|
* binary. Absent -> resolveElectronPath() (production behavior). */
|
|
@@ -787,7 +828,7 @@ export class RemoteElectronViewHost {
|
|
|
787
828
|
// Random per-spawn token: the child must prove knowledge of it (via
|
|
788
829
|
// stdin hello) before any command is written to the socket.
|
|
789
830
|
const token = randomBytes(24).toString('hex');
|
|
790
|
-
this.client = new ElectronChildClient(this.hostMainPath, port, token, () => this.onChildExit(), action => this.userActionHandler?.(action), this.spawnExecutable);
|
|
831
|
+
this.client = new ElectronChildClient(this.hostMainPath, port, token, () => this.onChildExit(), action => this.userActionHandler?.(action), windowId => this.viewClosedHandler?.(windowId), this.spawnExecutable);
|
|
791
832
|
if (this.pendingSocket !== undefined) {
|
|
792
833
|
this.client.attach(this.pendingSocket);
|
|
793
834
|
this.pendingSocket = undefined;
|
|
@@ -916,6 +957,10 @@ export class RemoteElectronViewHost {
|
|
|
916
957
|
onUserAction(handler) {
|
|
917
958
|
this.userActionHandler = handler;
|
|
918
959
|
}
|
|
960
|
+
/** Register the provider's handler for windows the human closed. */
|
|
961
|
+
onViewClosed(handler) {
|
|
962
|
+
this.viewClosedHandler = handler;
|
|
963
|
+
}
|
|
919
964
|
/** Surface a failed user action to the child's toolbar (address bar etc.). */
|
|
920
965
|
notifyUserActionError(windowId, message) {
|
|
921
966
|
void this.ready()
|
|
@@ -1028,6 +1073,15 @@ class DeferredRemoteView {
|
|
|
1028
1073
|
* gets large (a crash loop writes quickly).
|
|
1029
1074
|
*/
|
|
1030
1075
|
const HOST_LOG_MAX_BYTES = 2 * 1024 * 1024;
|
|
1076
|
+
/**
|
|
1077
|
+
* ISO timestamp prefix for host-log lines. This log is the only place a spawn
|
|
1078
|
+
* failure is recorded, and an undated line cannot be lined up with anything else
|
|
1079
|
+
* that happened on the machine — a plugin update replacing the entry script, for
|
|
1080
|
+
* instance — which is precisely the question this log gets asked.
|
|
1081
|
+
*/
|
|
1082
|
+
function hostStamp() {
|
|
1083
|
+
return new Date().toISOString();
|
|
1084
|
+
}
|
|
1031
1085
|
function appendHostLog(text) {
|
|
1032
1086
|
const home = process.env.DSH_HOME;
|
|
1033
1087
|
if (home === undefined || home === '')
|
|
@@ -1036,8 +1090,12 @@ function appendHostLog(text) {
|
|
|
1036
1090
|
const dir = join(home, 'logs');
|
|
1037
1091
|
const file = join(dir, 'dsh-builtin-browser-host.log');
|
|
1038
1092
|
try {
|
|
1039
|
-
if (statSync(file).size > HOST_LOG_MAX_BYTES)
|
|
1040
|
-
|
|
1093
|
+
if (statSync(file).size > HOST_LOG_MAX_BYTES) {
|
|
1094
|
+
// Discard the old bytes but leave a dated marker: truncating to empty
|
|
1095
|
+
// makes a rotation look like "nothing ever happened", which is how the
|
|
1096
|
+
// history of a crash loop gets silently swallowed.
|
|
1097
|
+
writeFileSync(file, `${hostStamp()} log rotated: previous content exceeded ${String(HOST_LOG_MAX_BYTES)} bytes and was discarded\n`);
|
|
1098
|
+
}
|
|
1041
1099
|
}
|
|
1042
1100
|
catch { /* first write, or unreadable: just append */ }
|
|
1043
1101
|
mkdirSync(dir, { recursive: true });
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin-owned settings document — the switches the browser settings panel
|
|
3
|
+
* exposes, stored next to the browsing history so both follow the same rules:
|
|
4
|
+
* they survive the browser process and DSH restarts.
|
|
5
|
+
*
|
|
6
|
+
* Every field is optional on disk and falls back to a default, so a hand-edited
|
|
7
|
+
* or older file never breaks startup; an unreadable file is treated as "all
|
|
8
|
+
* defaults" rather than an error.
|
|
9
|
+
* @module dsh-browser/browser-electron/settings-store
|
|
10
|
+
*/
|
|
11
|
+
/** Vision/operation strategy the provider prefers when the model can see. */
|
|
12
|
+
export type VisionStrategy = 'auto' | 'nonVisual';
|
|
13
|
+
/** The full settings document, with every field resolved. */
|
|
14
|
+
export interface BrowserSettings {
|
|
15
|
+
/** Persistent browsing history (visited pages). */
|
|
16
|
+
readonly history: {
|
|
17
|
+
readonly enabled: boolean;
|
|
18
|
+
readonly maxEntries: number;
|
|
19
|
+
readonly maxAgeDays: number;
|
|
20
|
+
};
|
|
21
|
+
/** Login-state retention: cookies and the browser profile. */
|
|
22
|
+
readonly cookies: {
|
|
23
|
+
readonly persist: boolean;
|
|
24
|
+
};
|
|
25
|
+
/** Presentation behaviour. */
|
|
26
|
+
readonly ui: {
|
|
27
|
+
/** Auto-expand the side panel once per task, then leave the user alone. */
|
|
28
|
+
readonly autoExpandOnce: boolean;
|
|
29
|
+
/** Release the browser when its session ends, instead of waiting for a close. */
|
|
30
|
+
readonly closeWithSession: boolean;
|
|
31
|
+
/** Show the synthetic cursor/click feedback while the agent operates. */
|
|
32
|
+
readonly virtualCursor: boolean;
|
|
33
|
+
};
|
|
34
|
+
/** Visual vs non-visual operation strategy. */
|
|
35
|
+
readonly vision: {
|
|
36
|
+
readonly strategy: VisionStrategy;
|
|
37
|
+
};
|
|
38
|
+
/** Whether the agent may read cookies / export login state. */
|
|
39
|
+
readonly credentials: {
|
|
40
|
+
readonly allowRead: boolean;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/** Defaults for every switch: the documented out-of-the-box behaviour. */
|
|
44
|
+
export declare const DEFAULT_SETTINGS: BrowserSettings;
|
|
45
|
+
/**
|
|
46
|
+
* Absolute path of the settings file, beside the browser profile and the
|
|
47
|
+
* browsing history.
|
|
48
|
+
* @returns the settings file path.
|
|
49
|
+
*/
|
|
50
|
+
export declare function settingsPath(): string;
|
|
51
|
+
/**
|
|
52
|
+
* Resolve a raw settings document (already parsed, shape unknown) into the full
|
|
53
|
+
* document, field by field. Unknown keys are dropped: the file is not a place to
|
|
54
|
+
* smuggle configuration.
|
|
55
|
+
* @param raw - the parsed file contents.
|
|
56
|
+
* @returns the resolved settings.
|
|
57
|
+
*/
|
|
58
|
+
export declare function resolveSettings(raw: unknown): BrowserSettings;
|
|
59
|
+
/**
|
|
60
|
+
* File-backed settings with an in-memory snapshot. Reads re-stat the file so an
|
|
61
|
+
* edit from another process (or the settings panel writing through a second
|
|
62
|
+
* code path) is picked up without a restart; writes are atomic enough for a
|
|
63
|
+
* single-writer plugin and never throw at the caller.
|
|
64
|
+
*/
|
|
65
|
+
export declare class SettingsStore {
|
|
66
|
+
private readonly file;
|
|
67
|
+
private cached;
|
|
68
|
+
/** Last observed mtime, used to invalidate the cache on external edits. */
|
|
69
|
+
private cachedMtimeMs;
|
|
70
|
+
/**
|
|
71
|
+
* @param file - settings file path (defaults beside the browser profile).
|
|
72
|
+
*/
|
|
73
|
+
constructor(file?: string);
|
|
74
|
+
/** Path this store reads and writes. */
|
|
75
|
+
path(): string;
|
|
76
|
+
/**
|
|
77
|
+
* Current settings, re-read when the file changed on disk.
|
|
78
|
+
* @returns the resolved settings document.
|
|
79
|
+
*/
|
|
80
|
+
get(): BrowserSettings;
|
|
81
|
+
/**
|
|
82
|
+
* Merge a partial patch into the stored document and persist it.
|
|
83
|
+
* @param patch - the fields to change (unknown keys are ignored).
|
|
84
|
+
* @returns the settings after the merge.
|
|
85
|
+
*/
|
|
86
|
+
update(patch: unknown): BrowserSettings;
|
|
87
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin-owned settings document — the switches the browser settings panel
|
|
3
|
+
* exposes, stored next to the browsing history so both follow the same rules:
|
|
4
|
+
* they survive the browser process and DSH restarts.
|
|
5
|
+
*
|
|
6
|
+
* Every field is optional on disk and falls back to a default, so a hand-edited
|
|
7
|
+
* or older file never breaks startup; an unreadable file is treated as "all
|
|
8
|
+
* defaults" rather than an error.
|
|
9
|
+
* @module dsh-browser/browser-electron/settings-store
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { homedir } from 'node:os';
|
|
13
|
+
import { dirname, join } from 'node:path';
|
|
14
|
+
/** Defaults for every switch: the documented out-of-the-box behaviour. */
|
|
15
|
+
export const DEFAULT_SETTINGS = {
|
|
16
|
+
history: { enabled: true, maxEntries: 5_000, maxAgeDays: 90 },
|
|
17
|
+
cookies: { persist: true },
|
|
18
|
+
ui: { autoExpandOnce: true, closeWithSession: false, virtualCursor: true },
|
|
19
|
+
vision: { strategy: 'auto' },
|
|
20
|
+
credentials: { allowRead: true },
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Absolute path of the settings file, beside the browser profile and the
|
|
24
|
+
* browsing history.
|
|
25
|
+
* @returns the settings file path.
|
|
26
|
+
*/
|
|
27
|
+
export function settingsPath() {
|
|
28
|
+
const home = process.env.DSH_HOME ?? join(homedir(), '.dsh');
|
|
29
|
+
return join(home, 'dsh-builtin-browser-host', 'settings.json');
|
|
30
|
+
}
|
|
31
|
+
/** Coerce one boolean field, keeping the default when absent or mistyped. */
|
|
32
|
+
function bool(value, fallback) {
|
|
33
|
+
return typeof value === 'boolean' ? value : fallback;
|
|
34
|
+
}
|
|
35
|
+
/** Coerce one positive-integer field, keeping the default when invalid. */
|
|
36
|
+
function count(value, fallback) {
|
|
37
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
|
|
38
|
+
}
|
|
39
|
+
/** Read one nested section of an unknown document, or an empty object. */
|
|
40
|
+
function section(source, key) {
|
|
41
|
+
const value = source[key];
|
|
42
|
+
return typeof value === 'object' && value !== null ? value : {};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Resolve a raw settings document (already parsed, shape unknown) into the full
|
|
46
|
+
* document, field by field. Unknown keys are dropped: the file is not a place to
|
|
47
|
+
* smuggle configuration.
|
|
48
|
+
* @param raw - the parsed file contents.
|
|
49
|
+
* @returns the resolved settings.
|
|
50
|
+
*/
|
|
51
|
+
export function resolveSettings(raw) {
|
|
52
|
+
const source = typeof raw === 'object' && raw !== null ? raw : {};
|
|
53
|
+
const history = section(source, 'history');
|
|
54
|
+
const cookies = section(source, 'cookies');
|
|
55
|
+
const ui = section(source, 'ui');
|
|
56
|
+
const vision = section(source, 'vision');
|
|
57
|
+
const credentials = section(source, 'credentials');
|
|
58
|
+
return {
|
|
59
|
+
history: {
|
|
60
|
+
enabled: bool(history.enabled, DEFAULT_SETTINGS.history.enabled),
|
|
61
|
+
maxEntries: count(history.maxEntries, DEFAULT_SETTINGS.history.maxEntries),
|
|
62
|
+
maxAgeDays: count(history.maxAgeDays, DEFAULT_SETTINGS.history.maxAgeDays),
|
|
63
|
+
},
|
|
64
|
+
cookies: { persist: bool(cookies.persist, DEFAULT_SETTINGS.cookies.persist) },
|
|
65
|
+
ui: {
|
|
66
|
+
autoExpandOnce: bool(ui.autoExpandOnce, DEFAULT_SETTINGS.ui.autoExpandOnce),
|
|
67
|
+
closeWithSession: bool(ui.closeWithSession, DEFAULT_SETTINGS.ui.closeWithSession),
|
|
68
|
+
virtualCursor: bool(ui.virtualCursor, DEFAULT_SETTINGS.ui.virtualCursor),
|
|
69
|
+
},
|
|
70
|
+
vision: { strategy: vision.strategy === 'nonVisual' ? 'nonVisual' : DEFAULT_SETTINGS.vision.strategy },
|
|
71
|
+
credentials: { allowRead: bool(credentials.allowRead, DEFAULT_SETTINGS.credentials.allowRead) },
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* File-backed settings with an in-memory snapshot. Reads re-stat the file so an
|
|
76
|
+
* edit from another process (or the settings panel writing through a second
|
|
77
|
+
* code path) is picked up without a restart; writes are atomic enough for a
|
|
78
|
+
* single-writer plugin and never throw at the caller.
|
|
79
|
+
*/
|
|
80
|
+
export class SettingsStore {
|
|
81
|
+
file;
|
|
82
|
+
cached;
|
|
83
|
+
/** Last observed mtime, used to invalidate the cache on external edits. */
|
|
84
|
+
cachedMtimeMs = -1;
|
|
85
|
+
/**
|
|
86
|
+
* @param file - settings file path (defaults beside the browser profile).
|
|
87
|
+
*/
|
|
88
|
+
constructor(file = settingsPath()) {
|
|
89
|
+
this.file = file;
|
|
90
|
+
}
|
|
91
|
+
/** Path this store reads and writes. */
|
|
92
|
+
path() {
|
|
93
|
+
return this.file;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Current settings, re-read when the file changed on disk.
|
|
97
|
+
* @returns the resolved settings document.
|
|
98
|
+
*/
|
|
99
|
+
get() {
|
|
100
|
+
let mtimeMs = -1;
|
|
101
|
+
let text;
|
|
102
|
+
try {
|
|
103
|
+
if (existsSync(this.file)) {
|
|
104
|
+
mtimeMs = statSync(this.file).mtimeMs;
|
|
105
|
+
text = readFileSync(this.file, 'utf8');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// Unreadable file: fall through to defaults below.
|
|
110
|
+
mtimeMs = -1;
|
|
111
|
+
text = undefined;
|
|
112
|
+
}
|
|
113
|
+
if (this.cached !== undefined && mtimeMs === this.cachedMtimeMs)
|
|
114
|
+
return this.cached;
|
|
115
|
+
try {
|
|
116
|
+
this.cached = text === undefined ? DEFAULT_SETTINGS : resolveSettings(JSON.parse(text));
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// Malformed JSON: behave as defaults rather than fail startup.
|
|
120
|
+
this.cached = DEFAULT_SETTINGS;
|
|
121
|
+
}
|
|
122
|
+
this.cachedMtimeMs = mtimeMs;
|
|
123
|
+
return this.cached;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Merge a partial patch into the stored document and persist it.
|
|
127
|
+
* @param patch - the fields to change (unknown keys are ignored).
|
|
128
|
+
* @returns the settings after the merge.
|
|
129
|
+
*/
|
|
130
|
+
update(patch) {
|
|
131
|
+
const current = this.get();
|
|
132
|
+
const merged = resolveSettings(deepMerge(current, patch));
|
|
133
|
+
try {
|
|
134
|
+
mkdirSync(dirname(this.file), { recursive: true });
|
|
135
|
+
writeFileSync(this.file, `${JSON.stringify(merged, null, 2)}\n`);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
// Keep the merged value in memory even when the write fails.
|
|
139
|
+
}
|
|
140
|
+
this.cached = merged;
|
|
141
|
+
this.cachedMtimeMs = Date.now();
|
|
142
|
+
return merged;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/** One-level-deep merge over the settings sections (patch wins). */
|
|
146
|
+
function deepMerge(base, patch) {
|
|
147
|
+
if (typeof patch !== 'object' || patch === null)
|
|
148
|
+
return base;
|
|
149
|
+
const result = { ...base };
|
|
150
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
151
|
+
const existing = result[key];
|
|
152
|
+
if (typeof value === 'object' && value !== null && typeof existing === 'object' && existing !== null) {
|
|
153
|
+
result[key] = { ...existing, ...value };
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
result[key] = value;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return result;
|
|
160
|
+
}
|