dsh-builtin-browser 0.4.0 → 0.4.2
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 +68 -30
- package/README.md +66 -30
- package/client.js +60 -6
- package/cordis.patch.yml +21 -0
- package/desktop-bridge/plugin-browser-bridge.js +12 -4
- package/docs/architecture.md +12 -2
- package/docs/tool-reference.md +17 -17
- package/docs/user-guide.md +13 -4
- package/docs/why-browser.md +2 -2
- package/lib/browser/types.d.ts +25 -1
- package/lib/browser-electron/bridge-connection.d.ts +31 -5
- package/lib/browser-electron/bridge-connection.js +143 -21
- package/lib/browser-electron/entry.d.ts +1 -1
- package/lib/browser-electron/entry.js +61 -35
- package/lib/browser-electron/ephemeral-profile.d.ts +37 -0
- package/lib/browser-electron/ephemeral-profile.js +146 -0
- package/lib/browser-electron/history-store.d.ts +25 -0
- package/lib/browser-electron/history-store.js +51 -4
- package/lib/browser-electron/host-main.js +75 -27
- package/lib/browser-electron/provider.d.ts +44 -1
- package/lib/browser-electron/provider.js +379 -48
- package/lib/browser-electron/remote-host.d.ts +52 -3
- package/lib/browser-electron/remote-host.js +244 -23
- package/lib/browser-electron/settings-route.d.ts +28 -0
- package/lib/browser-electron/settings-route.js +28 -0
- package/lib/browser-electron/settings-store.d.ts +40 -0
- package/lib/browser-electron/settings-store.js +107 -11
- package/lib/browser-electron/system-browser.d.ts +71 -3
- package/lib/browser-electron/system-browser.js +355 -38
- package/lib/tool-browser/index.js +143 -34
- package/package.json +5 -3
- package/scripts/verify-host-compat.mjs +195 -0
- package/src/browser/types.ts +25 -1
- package/src/browser-electron/bridge-connection.ts +142 -21
- package/src/browser-electron/entry.ts +58 -33
- package/src/browser-electron/ephemeral-profile.ts +152 -0
- package/src/browser-electron/history-store.ts +51 -4
- package/src/browser-electron/host-main.ts +105 -35
- package/src/browser-electron/provider.ts +401 -51
- package/src/browser-electron/remote-host.ts +261 -36
- package/src/browser-electron/settings-route.ts +46 -0
- package/src/browser-electron/settings-store.ts +121 -11
- package/src/browser-electron/system-browser.ts +342 -21
- package/src/tool-browser/index.ts +144 -36
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* defaults" rather than an error.
|
|
9
9
|
* @module dsh-browser/browser-electron/settings-store
|
|
10
10
|
*/
|
|
11
|
-
import {
|
|
11
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
12
12
|
import { homedir } from 'node:os';
|
|
13
13
|
import { dirname, join } from 'node:path';
|
|
14
14
|
/** Defaults for every switch: the documented out-of-the-box behaviour. */
|
|
@@ -22,6 +22,7 @@ export const DEFAULT_SETTINGS = {
|
|
|
22
22
|
// it starts a real Chrome/Edge with its own profile.
|
|
23
23
|
browser: { channel: 'bundled' },
|
|
24
24
|
credentials: { allowRead: true },
|
|
25
|
+
actions: { allowExecute: true, allowDownload: true, allowCredentialWrite: true },
|
|
25
26
|
};
|
|
26
27
|
/**
|
|
27
28
|
* Absolute path of the settings file, beside the browser profile and the
|
|
@@ -36,6 +37,37 @@ export function settingsPath() {
|
|
|
36
37
|
function bool(value, fallback) {
|
|
37
38
|
return typeof value === 'boolean' ? value : fallback;
|
|
38
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Coerce one boolean field that GATES A CAPABILITY.
|
|
42
|
+
*
|
|
43
|
+
* The difference from {@link bool} is what a mistyped value means. Absent still takes the
|
|
44
|
+
* default — a document written before the switch existed is the documented behaviour. But
|
|
45
|
+
* present-and-not-a-boolean is a document this code cannot read, and for a switch that gates
|
|
46
|
+
* something the operator chose to limit, unreadable resolves to OFF. `bool`'s fallback made
|
|
47
|
+
* `"false"` (a string), `null` (a truncated write) and `1` all mean the same as `true`, so a
|
|
48
|
+
* single dropped byte reopened every capability that had been turned off.
|
|
49
|
+
* @param value - the raw value from the settings document.
|
|
50
|
+
* @param fallback - the default, used only when the field is absent.
|
|
51
|
+
* @returns the value when it is a boolean, `false` when it is anything else.
|
|
52
|
+
*/
|
|
53
|
+
function gate(value, fallback) {
|
|
54
|
+
if (value === undefined)
|
|
55
|
+
return fallback;
|
|
56
|
+
return value === true;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* What an unreadable document resolves to.
|
|
60
|
+
*
|
|
61
|
+
* Only the capability gates are refused; everything else keeps its default. The distinction is
|
|
62
|
+
* deliberate: refusing history, presentation or browser choice would punish the user for a
|
|
63
|
+
* corrupt file without protecting anything, while refusing the gates is the whole point —
|
|
64
|
+
* "the operator's intent cannot be read" must not resolve to "the operator permits".
|
|
65
|
+
*/
|
|
66
|
+
const SETTINGS_WHEN_UNREADABLE = {
|
|
67
|
+
...DEFAULT_SETTINGS,
|
|
68
|
+
credentials: { allowRead: false },
|
|
69
|
+
actions: { allowExecute: false, allowDownload: false, allowCredentialWrite: false },
|
|
70
|
+
};
|
|
39
71
|
/**
|
|
40
72
|
* Coerce the browser channel, keeping the default when it is not one of the
|
|
41
73
|
* known choices. A hand-edited typo must not silently select a different browser.
|
|
@@ -71,6 +103,7 @@ export function resolveSettings(raw) {
|
|
|
71
103
|
const vision = section(source, 'vision');
|
|
72
104
|
const credentials = section(source, 'credentials');
|
|
73
105
|
const browser = section(source, 'browser');
|
|
106
|
+
const actions = section(source, 'actions');
|
|
74
107
|
return {
|
|
75
108
|
history: {
|
|
76
109
|
enabled: bool(history.enabled, DEFAULT_SETTINGS.history.enabled),
|
|
@@ -85,7 +118,12 @@ export function resolveSettings(raw) {
|
|
|
85
118
|
},
|
|
86
119
|
vision: { strategy: vision.strategy === 'nonVisual' ? 'nonVisual' : DEFAULT_SETTINGS.vision.strategy },
|
|
87
120
|
browser: { channel: resolveChannel(browser.channel) },
|
|
88
|
-
credentials: { allowRead:
|
|
121
|
+
credentials: { allowRead: gate(credentials.allowRead, DEFAULT_SETTINGS.credentials.allowRead) },
|
|
122
|
+
actions: {
|
|
123
|
+
allowExecute: gate(actions.allowExecute, DEFAULT_SETTINGS.actions.allowExecute),
|
|
124
|
+
allowDownload: gate(actions.allowDownload, DEFAULT_SETTINGS.actions.allowDownload),
|
|
125
|
+
allowCredentialWrite: gate(actions.allowCredentialWrite, DEFAULT_SETTINGS.actions.allowCredentialWrite),
|
|
126
|
+
},
|
|
89
127
|
};
|
|
90
128
|
}
|
|
91
129
|
/**
|
|
@@ -115,20 +153,35 @@ export class SettingsStore {
|
|
|
115
153
|
*/
|
|
116
154
|
get() {
|
|
117
155
|
let mtimeMs = -1;
|
|
118
|
-
let text;
|
|
119
156
|
try {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}
|
|
157
|
+
// stat directly: existsSync costs about thirteen times what the stat does, and the
|
|
158
|
+
// stat already throws when the file is missing — which the catch below handles.
|
|
159
|
+
mtimeMs = statSync(this.file).mtimeMs;
|
|
124
160
|
}
|
|
125
161
|
catch {
|
|
126
162
|
// Unreadable file: fall through to defaults below.
|
|
127
163
|
mtimeMs = -1;
|
|
128
|
-
text = undefined;
|
|
129
164
|
}
|
|
165
|
+
// Return the cache BEFORE reading. The read used to happen first "just in case", which
|
|
166
|
+
// made the cache pointless: every call paid for a synchronous read of the whole file and
|
|
167
|
+
// then discarded it. This is on the path of every tool call that consults a setting, so
|
|
168
|
+
// it was a stat plus a read per call where a single stat will do.
|
|
130
169
|
if (this.cached !== undefined && mtimeMs === this.cachedMtimeMs)
|
|
131
170
|
return this.cached;
|
|
171
|
+
let text;
|
|
172
|
+
if (mtimeMs !== -1) {
|
|
173
|
+
try {
|
|
174
|
+
text = readFileSync(this.file, 'utf8');
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
// It was there a moment ago (the stat above) and cannot be read now: permissions, a
|
|
178
|
+
// lock, a directory in the way. That is not "no document" — the file is not absent, so
|
|
179
|
+
// what it said is unknown, and unknown resolves to the refusing document.
|
|
180
|
+
this.cached = SETTINGS_WHEN_UNREADABLE;
|
|
181
|
+
this.cachedMtimeMs = mtimeMs;
|
|
182
|
+
return this.cached;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
132
185
|
try {
|
|
133
186
|
// A leading BOM is what ordinary Windows editors (Notepad, PowerShell's
|
|
134
187
|
// `Set-Content -Encoding utf8`) leave behind, and `JSON.parse` rejects it.
|
|
@@ -139,8 +192,12 @@ export class SettingsStore {
|
|
|
139
192
|
this.cached = body === undefined ? DEFAULT_SETTINGS : resolveSettings(JSON.parse(body));
|
|
140
193
|
}
|
|
141
194
|
catch {
|
|
142
|
-
//
|
|
143
|
-
|
|
195
|
+
// A document that EXISTS but cannot be parsed is not the same as no document at all: the
|
|
196
|
+
// first is a corrupted or half-written file whose contents were the operator's decisions,
|
|
197
|
+
// the second is a first run. Resolving both to the defaults meant a kill during the write,
|
|
198
|
+
// or one stray character, silently reopened the capabilities that had been switched off —
|
|
199
|
+
// so this side refuses them instead (see SETTINGS_WHEN_UNREADABLE).
|
|
200
|
+
this.cached = SETTINGS_WHEN_UNREADABLE;
|
|
144
201
|
}
|
|
145
202
|
this.cachedMtimeMs = mtimeMs;
|
|
146
203
|
return this.cached;
|
|
@@ -155,7 +212,7 @@ export class SettingsStore {
|
|
|
155
212
|
const merged = resolveSettings(deepMerge(current, patch));
|
|
156
213
|
try {
|
|
157
214
|
mkdirSync(dirname(this.file), { recursive: true });
|
|
158
|
-
|
|
215
|
+
writeFileAtomic(this.file, `${JSON.stringify(merged, null, 2)}\n`);
|
|
159
216
|
}
|
|
160
217
|
catch (error) {
|
|
161
218
|
// Do NOT publish the new value as cached, and do not pretend the file changed: a
|
|
@@ -171,6 +228,45 @@ export class SettingsStore {
|
|
|
171
228
|
return merged;
|
|
172
229
|
}
|
|
173
230
|
}
|
|
231
|
+
/**
|
|
232
|
+
* Replace a file's contents atomically: write a sibling temporary file, then rename it over
|
|
233
|
+
* the target.
|
|
234
|
+
*
|
|
235
|
+
* `writeFileSync(target, …)` truncates and rewrites in place, so a crash, a full disk or an
|
|
236
|
+
* antivirus lock in the middle leaves HALF a JSON document. That used to be silent: the reader
|
|
237
|
+
* resolved an unparseable file to the defaults, so a torn write flipped `credentials.allowRead`
|
|
238
|
+
* and the action switches back ON, and the next successful update wrote those defaults to disk
|
|
239
|
+
* for good. The read side no longer does that (see {@link SETTINGS_WHEN_UNREADABLE}) — but the
|
|
240
|
+
* write side still has to stop producing torn files at all, because half a document is not
|
|
241
|
+
* something this plugin should ever leave behind.
|
|
242
|
+
*
|
|
243
|
+
* The rename is the commit point: a reader sees either the old document or the new one,
|
|
244
|
+
* never a partial one. It stays in the same directory so the rename is atomic on Windows
|
|
245
|
+
* as well as POSIX (`renameSync` maps to MoveFileEx there: same-volume replaces are
|
|
246
|
+
* atomic, cross-volume ones are not supported at all).
|
|
247
|
+
*
|
|
248
|
+
* The temporary is written with a unique suffix rather than a fixed `.tmp`, so two writers
|
|
249
|
+
* (the settings panel and a second DSH process) cannot rename each other's half-written
|
|
250
|
+
* file into place. Shared with the history store, which had the same hole.
|
|
251
|
+
* @param file - the target path.
|
|
252
|
+
* @param contents - the complete new document.
|
|
253
|
+
*/
|
|
254
|
+
export function writeFileAtomic(file, contents) {
|
|
255
|
+
const temporary = `${file}.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`;
|
|
256
|
+
try {
|
|
257
|
+
writeFileSync(temporary, contents);
|
|
258
|
+
renameSync(temporary, file);
|
|
259
|
+
}
|
|
260
|
+
catch (error) {
|
|
261
|
+
// The rename is the commit point, so a failure before it leaves the target untouched —
|
|
262
|
+
// only the temporary has to go, and it must not be left behind as garbage.
|
|
263
|
+
try {
|
|
264
|
+
rmSync(temporary, { force: true });
|
|
265
|
+
}
|
|
266
|
+
catch { /* the temp is already the least of it */ }
|
|
267
|
+
throw error;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
174
270
|
/** One-level-deep merge over the settings sections (patch wins). */
|
|
175
271
|
function deepMerge(base, patch) {
|
|
176
272
|
if (typeof patch !== 'object' || patch === null)
|
|
@@ -24,6 +24,17 @@
|
|
|
24
24
|
* only the carrier differs.
|
|
25
25
|
* @module dsh-browser/browser-electron/system-browser
|
|
26
26
|
*/
|
|
27
|
+
/**
|
|
28
|
+
* Starts a browser process and returns a handle to it.
|
|
29
|
+
*
|
|
30
|
+
* Injectable so tests can supply something that speaks CDP — the seam a recovery test needs
|
|
31
|
+
* and could not have while the launch was hardcoded to a stub that rejects Chromium's args.
|
|
32
|
+
* @param path - the executable to run.
|
|
33
|
+
* @param args - its arguments.
|
|
34
|
+
* @returns the child process.
|
|
35
|
+
*/
|
|
36
|
+
export type BrowserLauncher = (path: string, args: readonly string[]) => ReturnType<typeof spawn>;
|
|
37
|
+
import { spawn } from 'node:child_process';
|
|
27
38
|
import type { ElectronBrowserViewHost, ElectronViewHandle } from './provider.js';
|
|
28
39
|
/** Which browser the user asked for. */
|
|
29
40
|
export type BrowserChannel = 'bundled' | 'chrome' | 'edge' | 'auto';
|
|
@@ -59,13 +70,26 @@ export declare function detectBrowser(channel: BrowserChannel, env?: NodeJS.Proc
|
|
|
59
70
|
* @param platform - the platform whose names and locations apply.
|
|
60
71
|
* @returns the launcher names (as PATH lookups) and the fixed locations checked.
|
|
61
72
|
*/
|
|
62
|
-
export declare function searchSummary(kind: DetectedBrowser['kind'], platform?: NodeJS.Platform): string[];
|
|
73
|
+
export declare function searchSummary(kind: DetectedBrowser['kind'], platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv): string[];
|
|
63
74
|
/** A system browser driven over CDP, presented as a browser view host. */
|
|
64
75
|
export declare class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
65
76
|
private readonly browser;
|
|
66
77
|
private readonly profileDir;
|
|
67
78
|
private readonly extraArgs;
|
|
68
79
|
private readonly ephemeralDir?;
|
|
80
|
+
private readonly launcher;
|
|
81
|
+
/**
|
|
82
|
+
* viewId -> the session that view rides, and the client that issued it.
|
|
83
|
+
*
|
|
84
|
+
* The client is part of the value on purpose. A session id means nothing to a browser other
|
|
85
|
+
* than the one that handed it out, and this host replaces browsers routinely: the process
|
|
86
|
+
* dies, or its debugging socket closes while the process lives on. Keeping only the id meant
|
|
87
|
+
* the host could start a NEW browser successfully and then send every command to it carrying
|
|
88
|
+
* a session the old one had issued — `Session with given id not found`, on every call, with a
|
|
89
|
+
* browser that answers /json/version perfectly well and nothing the user can do short of
|
|
90
|
+
* restarting DSH. Comparing identity catches it with no round trip; validating each cached
|
|
91
|
+
* session against the browser would put one on every page command.
|
|
92
|
+
*/
|
|
69
93
|
private readonly views;
|
|
70
94
|
private disposed;
|
|
71
95
|
/**
|
|
@@ -77,16 +101,40 @@ export declare class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
77
101
|
* spawned when a view is actually needed.
|
|
78
102
|
*/
|
|
79
103
|
private client;
|
|
104
|
+
/**
|
|
105
|
+
* Set when a command went unanswered rather than being refused.
|
|
106
|
+
*
|
|
107
|
+
* A timeout says nothing about the connection: the page may simply be slow. It marks the
|
|
108
|
+
* client for a cheap check before the next command trusts it, which keeps a slow page
|
|
109
|
+
* from being mistaken for a dead browser while still catching one that has truly stopped
|
|
110
|
+
* answering.
|
|
111
|
+
*/
|
|
112
|
+
private clientSuspect;
|
|
80
113
|
private child;
|
|
81
114
|
private starting;
|
|
115
|
+
/**
|
|
116
|
+
* Browsers that ignored a kill.
|
|
117
|
+
*
|
|
118
|
+
* They are no longer this host's child — a replacement has taken that slot — but they
|
|
119
|
+
* are still running, still holding the profile, and still something release must try to
|
|
120
|
+
* stop. Forgetting them is what allowed a launch this host could not undo.
|
|
121
|
+
*/
|
|
122
|
+
private readonly stubborn;
|
|
82
123
|
/**
|
|
83
124
|
* @param browser - the detected installation to launch on first use.
|
|
84
125
|
* @param profileDir - a plugin-owned directory; the user's own profile is never touched.
|
|
85
126
|
* @param extraArgs - additional Chromium switches.
|
|
86
127
|
* @param ephemeralDir - a directory to delete on release, when the user has turned
|
|
87
128
|
* persistence off so no login state outlives the session.
|
|
129
|
+
* @param launcher - how to start the browser, for tests.
|
|
130
|
+
*
|
|
131
|
+
* Recovery after the browser dies was untestable without this: the suite spawns a stub
|
|
132
|
+
* with Chromium's arguments, and a stub that is not a browser rejects them and exits, so
|
|
133
|
+
* no CDP endpoint ever appears and no session is ever created. Verified by mutation —
|
|
134
|
+
* removing every sessions.clear() left the recovery tests green. A launcher lets a test
|
|
135
|
+
* provide something that actually speaks CDP, so the state those tests describe exists.
|
|
88
136
|
*/
|
|
89
|
-
constructor(browser: DetectedBrowser, profileDir: string, extraArgs?: readonly string[], ephemeralDir?: string | undefined);
|
|
137
|
+
constructor(browser: DetectedBrowser, profileDir: string, extraArgs?: readonly string[], ephemeralDir?: string | undefined, launcher?: BrowserLauncher);
|
|
90
138
|
/** Which product this host would drive (for diagnostics). */
|
|
91
139
|
get kind(): DetectedBrowser['kind'];
|
|
92
140
|
/** Whether the browser has actually been started yet (diagnostics and tests). */
|
|
@@ -95,6 +143,20 @@ export declare class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
95
143
|
* The CDP client, starting the browser on first use.
|
|
96
144
|
* @returns the connected client.
|
|
97
145
|
*/
|
|
146
|
+
/**
|
|
147
|
+
* Whether a client that reports itself open is actually answering.
|
|
148
|
+
*
|
|
149
|
+
* An open socket proves nothing: a browser can hold the connection and never reply, and
|
|
150
|
+
* that state must not be cached, or every later command waits out its own timeout. Asking
|
|
151
|
+
* the browser something cheap is a fact; reading the failure text is a guess, and the
|
|
152
|
+
* guess was wrong in both directions (it killed healthy browsers on a slow page, and it
|
|
153
|
+
* mistook the browser's own free text for a verdict).
|
|
154
|
+
*
|
|
155
|
+
* This is only reached when the socket is open, so the common path costs nothing.
|
|
156
|
+
* @param client - the client to question.
|
|
157
|
+
* @returns true when it answered.
|
|
158
|
+
*/
|
|
159
|
+
private probeClient;
|
|
98
160
|
private ensureClient;
|
|
99
161
|
/**
|
|
100
162
|
* Launch the browser with a private profile and connect over CDP.
|
|
@@ -146,7 +208,13 @@ export declare class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
146
208
|
* browser) simply tears down its bookkeeping.
|
|
147
209
|
*/
|
|
148
210
|
dispose(): void;
|
|
149
|
-
/**
|
|
211
|
+
/**
|
|
212
|
+
* The flattened session for a view, creating its page on first use.
|
|
213
|
+
*
|
|
214
|
+
* No cache check here: the only caller has already established that the cached session does
|
|
215
|
+
* not belong to the client it is about to use. Re-checking would mean either a round trip or
|
|
216
|
+
* a second copy of that rule.
|
|
217
|
+
*/
|
|
150
218
|
private ensureSession;
|
|
151
219
|
/** sessionId -> targetId, so a destroyed view can close the right page. */
|
|
152
220
|
private readonly sessions;
|