dsh-builtin-browser 0.4.2 → 0.4.6
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/CHANGELOG.md +1753 -0
- package/README.en.md +63 -20
- package/README.md +60 -20
- package/client.js +433 -24
- package/desktop-bridge/install.mjs +66 -6
- package/desktop-bridge/plugin-browser-bridge.js +425 -163
- package/docs/architecture.md +4 -4
- package/docs/tool-reference.md +3 -3
- package/docs/user-guide.md +50 -2
- package/lib/browser/types.d.ts +9 -2
- package/lib/browser-electron/bridge-connection.d.ts +18 -21
- package/lib/browser-electron/bridge-connection.js +42 -1
- package/lib/browser-electron/desktop-bridge-host.d.ts +69 -36
- package/lib/browser-electron/desktop-bridge-host.js +151 -86
- package/lib/browser-electron/entry.d.ts +20 -0
- package/lib/browser-electron/entry.js +110 -7
- package/lib/browser-electron/history-store.d.ts +11 -2
- package/lib/browser-electron/history-store.js +27 -5
- package/lib/browser-electron/host-main.js +11 -1
- package/lib/browser-electron/provider.d.ts +62 -2
- package/lib/browser-electron/provider.js +28 -12
- package/lib/browser-electron/remote-host.js +19 -6
- package/lib/browser-electron/rpc-ops.d.ts +18 -0
- package/lib/browser-electron/rpc-ops.js +36 -0
- package/lib/browser-electron/settings-route.d.ts +6 -6
- package/lib/browser-electron/settings-route.js +48 -8
- package/lib/browser-electron/settings-store.js +57 -16
- package/lib/browser-electron/system-browser.d.ts +31 -9
- package/lib/browser-electron/system-browser.js +116 -27
- package/lib/index.js +0 -1
- package/lib/tool-browser/element-fields.d.ts +145 -0
- package/lib/tool-browser/element-fields.js +152 -0
- package/lib/tool-browser/index.js +26 -60
- package/package.json +6 -2
- package/src/browser/types.ts +9 -2
- package/src/browser-electron/bridge-connection.ts +37 -1
- package/src/browser-electron/desktop-bridge-host.ts +146 -84
- package/src/browser-electron/entry.ts +113 -8
- package/src/browser-electron/history-store.ts +32 -7
- package/src/browser-electron/host-main.ts +10 -1
- package/src/browser-electron/provider.ts +91 -18
- package/src/browser-electron/remote-host.ts +19 -6
- package/src/browser-electron/rpc-ops.ts +41 -0
- package/src/browser-electron/settings-route.ts +54 -13
- package/src/browser-electron/settings-store.ts +56 -17
- package/src/browser-electron/system-browser.ts +121 -28
- package/src/index.ts +3 -2
- package/src/tool-browser/element-fields.ts +167 -0
- package/src/tool-browser/index.ts +31 -60
|
@@ -832,6 +832,7 @@ async function handle(op, msg) {
|
|
|
832
832
|
catch { /* not minimized */ }
|
|
833
833
|
win.window.focus();
|
|
834
834
|
let base64 = '';
|
|
835
|
+
let renderedSize;
|
|
835
836
|
try {
|
|
836
837
|
let image;
|
|
837
838
|
try {
|
|
@@ -856,6 +857,10 @@ async function handle(op, msg) {
|
|
|
856
857
|
}
|
|
857
858
|
if (w > 0 && h > 0 && (w !== size.width || h !== size.height))
|
|
858
859
|
image = image.resize({ width: w, height: h });
|
|
860
|
+
// What the caller actually receives: after the downscale, not the captured size.
|
|
861
|
+
// Recorded here because this is the last point that knows both.
|
|
862
|
+
if (w > 0 && h > 0)
|
|
863
|
+
renderedSize = { width: w, height: h };
|
|
859
864
|
const buf = wantFormat === 'jpeg' ? image.toJPEG(wantQuality) : image.toPNG();
|
|
860
865
|
if (wantFormat === 'jpeg')
|
|
861
866
|
mime = 'image/jpeg';
|
|
@@ -914,7 +919,12 @@ async function handle(op, msg) {
|
|
|
914
919
|
if (base64 === '') {
|
|
915
920
|
throw new Error('capture produced no image (view not painted)');
|
|
916
921
|
}
|
|
917
|
-
|
|
922
|
+
// The measured size travels with the image. Without it the native path — which is the
|
|
923
|
+
// default self-hosted one — could never answer `browser_screenshot`'s width/height,
|
|
924
|
+
// because the seam's only channel was {base64, mime} and the provider had nothing to
|
|
925
|
+
// forward. The caller's `maxWidth`/`maxHeight` are applied here, so this is the only
|
|
926
|
+
// place that knows what came back.
|
|
927
|
+
reply(msg.id, { ok: true, result: { base64, mime, width: renderedSize?.width, height: renderedSize?.height } });
|
|
918
928
|
return;
|
|
919
929
|
}
|
|
920
930
|
case 'download': {
|
|
@@ -20,8 +20,13 @@ export interface ElectronBrowserViewHost {
|
|
|
20
20
|
* Create a new browser view and return a handle to its webContents-like
|
|
21
21
|
* surface. The host owns windowing (adding the view to the window, sizing,
|
|
22
22
|
* removal); the provider owns CDP-driven behavior.
|
|
23
|
+
* @param owner - the conversation this view serves. A host shared by several conversations
|
|
24
|
+
* needs it to keep one conversation's page out of another's; a host with one conversation per
|
|
25
|
+
* process may ignore it. It is per VIEW and not per host on purpose: the desktop's host is a
|
|
26
|
+
* single instance that every conversation runs through, so an owner held there would be one
|
|
27
|
+
* conversation's id applied to all of them.
|
|
23
28
|
*/
|
|
24
|
-
createView(): ElectronViewHandle;
|
|
29
|
+
createView(owner?: string): ElectronViewHandle;
|
|
25
30
|
/**
|
|
26
31
|
* Destroy a view created by this host. Called on session close; idempotent
|
|
27
32
|
* for an already-destroyed view.
|
|
@@ -61,6 +66,18 @@ export interface ElectronBrowserViewHost {
|
|
|
61
66
|
* of failing only at first use.
|
|
62
67
|
*/
|
|
63
68
|
available?(): boolean;
|
|
69
|
+
/**
|
|
70
|
+
* Tell the human that a toolbar action the agent could not complete has failed.
|
|
71
|
+
*
|
|
72
|
+
* Optional. Declared rather than reached through a cast because this is what issue #16 was:
|
|
73
|
+
* the call used to be unbound, so `this` inside the host's implementation was undefined and
|
|
74
|
+
* its first statement threw — inside an async catch, which turned it into an unhandled
|
|
75
|
+
* rejection that took the whole DSH process down. A declared member is checkable; a cast is
|
|
76
|
+
* not.
|
|
77
|
+
* @param windowId - the window the action came from.
|
|
78
|
+
* @param message - what to show the human.
|
|
79
|
+
*/
|
|
80
|
+
notifyUserActionError?(windowId: string, message: string): void;
|
|
64
81
|
/**
|
|
65
82
|
* Optional: associate a view with a window group. Views grouped under the
|
|
66
83
|
* same `windowId` share one window (one window per browser session); a host
|
|
@@ -96,7 +113,12 @@ export interface ElectronBrowserViewHost {
|
|
|
96
113
|
* (settings: ui.closeWithSession) has to be asked for explicitly there.
|
|
97
114
|
* @returns a promise that settles once the release was attempted.
|
|
98
115
|
*/
|
|
99
|
-
|
|
116
|
+
/**
|
|
117
|
+
* Release this carrier's browser pages for one conversation.
|
|
118
|
+
* @param owner - the conversation whose pages to close. Omit to close every conversation's,
|
|
119
|
+
* which is only correct from a teardown that is itself global.
|
|
120
|
+
*/
|
|
121
|
+
releasePage?(owner?: string): Promise<void>;
|
|
100
122
|
/**
|
|
101
123
|
* Fold the carrier's presentation away while keeping the page alive.
|
|
102
124
|
*
|
|
@@ -175,6 +197,42 @@ export interface ElectronViewHandle {
|
|
|
175
197
|
* then behaves exactly as before.
|
|
176
198
|
*/
|
|
177
199
|
focus?(): Promise<void>;
|
|
200
|
+
/**
|
|
201
|
+
* Save a URL straight to disk, when the backing view can do that itself.
|
|
202
|
+
*
|
|
203
|
+
* Optional. Declared here rather than reached through a structural cast at the call site:
|
|
204
|
+
* a cast tells the compiler nothing, so a host that implements the interface without this
|
|
205
|
+
* method compiles and then silently does nothing.
|
|
206
|
+
* @param url - the URL to fetch.
|
|
207
|
+
* @param savePath - where to write it.
|
|
208
|
+
*/
|
|
209
|
+
download?(url: string, savePath: string): Promise<void>;
|
|
210
|
+
/**
|
|
211
|
+
* Export this view's cookies. Optional; see {@link download} for why it is declared.
|
|
212
|
+
* @returns the cookies, scoped to the view's own page.
|
|
213
|
+
*/
|
|
214
|
+
flushAuth?(): Promise<readonly ExportedCookie[]>;
|
|
215
|
+
/**
|
|
216
|
+
* Import cookies into this view. Optional.
|
|
217
|
+
* @param cookies - the cookies to write.
|
|
218
|
+
* @returns how many were accepted.
|
|
219
|
+
*/
|
|
220
|
+
restoreAuth?(cookies: readonly ExportedCookie[]): Promise<number>;
|
|
221
|
+
/**
|
|
222
|
+
* Capture this view natively, returning the image and its measured size.
|
|
223
|
+
*
|
|
224
|
+
* Optional. The size is part of the contract because the caller's own tool schema declares
|
|
225
|
+
* `width`/`height`; a host that can measure the image should say so here rather than leaving
|
|
226
|
+
* the provider to guess.
|
|
227
|
+
* @param opts - format, quality and downscale bounds.
|
|
228
|
+
* @returns the encoded image and the pixel size the caller will receive.
|
|
229
|
+
*/
|
|
230
|
+
capture?(opts?: ScreenshotOptions): Promise<{
|
|
231
|
+
base64: string;
|
|
232
|
+
mime: string;
|
|
233
|
+
width?: number;
|
|
234
|
+
height?: number;
|
|
235
|
+
}>;
|
|
178
236
|
}
|
|
179
237
|
/** Provider config: navigation admission defaults and snapshot caps. */
|
|
180
238
|
export interface ElectronBrowserProviderConfig {
|
|
@@ -583,6 +641,8 @@ export declare class ElectronBrowserProvider implements BrowserProvider {
|
|
|
583
641
|
screenshot(session: BrowserSessionId, request?: BrowserScreenshotRequest, signal?: AbortSignal): Promise<{
|
|
584
642
|
readonly dataUrl: string;
|
|
585
643
|
readonly path?: string;
|
|
644
|
+
readonly width?: number;
|
|
645
|
+
readonly height?: number;
|
|
586
646
|
}>;
|
|
587
647
|
/**
|
|
588
648
|
* Build the data URL and optionally write the image to disk. The caller's
|
|
@@ -343,7 +343,10 @@ export class ElectronBrowserProvider {
|
|
|
343
343
|
* in the window title so a human can tell which task's page is visible.
|
|
344
344
|
*/
|
|
345
345
|
open(label) {
|
|
346
|
-
|
|
346
|
+
// The label is the calling DSH task, and it is handed to the view as its owner: a host shared
|
|
347
|
+
// by several conversations (the desktop's) keeps one conversation's page out of another's on
|
|
348
|
+
// the strength of it, so it is not decoration.
|
|
349
|
+
const handle = this.host.createView(label);
|
|
347
350
|
const id = `browser:${randomUUID()}`;
|
|
348
351
|
// Group the session's views under its own window (one window per
|
|
349
352
|
// session), carrying the label for the window title. Hosts without
|
|
@@ -2289,7 +2292,13 @@ export class ElectronBrowserProvider {
|
|
|
2289
2292
|
if (shot.base64 === '') {
|
|
2290
2293
|
throw new BrowserError('browser: capture returned an empty image (view not painted); retry shortly', 'BROWSER_SCREENSHOT_FAILED');
|
|
2291
2294
|
}
|
|
2292
|
-
return this.saveScreenshot(shot.base64, request?.savePath, shot.mime ?? 'image/png'
|
|
2295
|
+
return this.saveScreenshot(shot.base64, request?.savePath, shot.mime ?? 'image/png',
|
|
2296
|
+
// The child measured these after applying any downscale, and they describe the image
|
|
2297
|
+
// the caller receives. Without them this path — the default self-hosted one — could
|
|
2298
|
+
// never answer the width/height its own tool schema declares.
|
|
2299
|
+
shot.width !== undefined && shot.height !== undefined
|
|
2300
|
+
? { width: shot.width, height: shot.height }
|
|
2301
|
+
: undefined);
|
|
2293
2302
|
}
|
|
2294
2303
|
// Fallback: a handle without a native capture() (the sidebar, or an installed Chrome
|
|
2295
2304
|
// or Edge) uses CDP; full-page needs `captureBeyondViewport`, which capturePage lacks.
|
|
@@ -2403,9 +2412,15 @@ export class ElectronBrowserProvider {
|
|
|
2403
2412
|
if (store === undefined)
|
|
2404
2413
|
return;
|
|
2405
2414
|
// The settings panel can switch recording off at runtime; the static config
|
|
2406
|
-
// only supplies the startup default.
|
|
2407
|
-
|
|
2408
|
-
|
|
2415
|
+
// only supplies the startup default. The RETENTION limits are read here too: they were
|
|
2416
|
+
// parsed and stored but never handed to the store, so editing maxEntries or maxAgeDays in
|
|
2417
|
+
// the file changed nothing — the switch worked and the numbers beside it did not.
|
|
2418
|
+
if (this.settingsSource !== undefined) {
|
|
2419
|
+
const live = this.settingsSource().history;
|
|
2420
|
+
if (!live.enabled)
|
|
2421
|
+
return;
|
|
2422
|
+
store.updateLimits({ maxEntries: live.maxEntries, maxAgeDays: live.maxAgeDays });
|
|
2423
|
+
}
|
|
2409
2424
|
void handleSendEvaluate(handle, 'location.href + "\\u0000" + (document.title || "")')
|
|
2410
2425
|
.then(result => {
|
|
2411
2426
|
if (!result.ok || typeof result.value !== 'string')
|
|
@@ -2537,7 +2552,9 @@ export class ElectronBrowserProvider {
|
|
|
2537
2552
|
// the views above; the desktop's sidebar does not, so it is asked explicitly
|
|
2538
2553
|
// — and its cookies plus the browsing history outlive the page either way.
|
|
2539
2554
|
if (this.settingsSource !== undefined && this.settingsSource().ui.closeWithSession) {
|
|
2540
|
-
|
|
2555
|
+
// Named, because one host serves every conversation: an unnamed release would end the
|
|
2556
|
+
// pages of conversations that are still running.
|
|
2557
|
+
void this.host.releasePage?.(existing.label).catch(() => undefined);
|
|
2541
2558
|
}
|
|
2542
2559
|
}
|
|
2543
2560
|
return Promise.resolve();
|
|
@@ -2559,7 +2576,7 @@ export class ElectronBrowserProvider {
|
|
|
2559
2576
|
}
|
|
2560
2577
|
/** Append a fresh tab and make it active. */
|
|
2561
2578
|
newTab(s) {
|
|
2562
|
-
const handle = this.host.createView();
|
|
2579
|
+
const handle = this.host.createView(s.label);
|
|
2563
2580
|
// Same window as the session's other tabs.
|
|
2564
2581
|
this.host.groupView?.(handle, s.id, s.label);
|
|
2565
2582
|
s.tabs.push({ id: `tab:${randomUUID()}`, handle });
|
|
@@ -2664,11 +2681,10 @@ export class ElectronBrowserProvider {
|
|
|
2664
2681
|
const message = `action ${action.type} failed: ${String(error)}`;
|
|
2665
2682
|
let reported = false;
|
|
2666
2683
|
try {
|
|
2667
|
-
const
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
notify.call(host, action.windowId, message);
|
|
2684
|
+
const notify = this.host.notifyUserActionError?.bind(this.host);
|
|
2685
|
+
if (notify !== undefined) {
|
|
2686
|
+
// Bound at the call: an unbound call here kills the host process (issue #16).
|
|
2687
|
+
notify(action.windowId, message);
|
|
2672
2688
|
reported = true;
|
|
2673
2689
|
}
|
|
2674
2690
|
}
|
|
@@ -487,9 +487,11 @@ class ElectronChildClient {
|
|
|
487
487
|
/**
|
|
488
488
|
* Drop the escalation kill once there is nothing left for it to target.
|
|
489
489
|
*
|
|
490
|
-
*
|
|
491
|
-
*
|
|
492
|
-
*
|
|
490
|
+
* Called on the paths where the child is known to be gone — after an observed exit, and on
|
|
491
|
+
* release. It is deliberately NOT called from `fail()`: a dead socket says nothing about the
|
|
492
|
+
* process on the other end, and the timer exists for exactly the case where the child ignores
|
|
493
|
+
* the socket closing. Clearing it there disarmed the only escalation there was, which is how
|
|
494
|
+
* a child that ignored its socket could outlive dispose() and keep holding the profile.
|
|
493
495
|
*/
|
|
494
496
|
clearKillGrace() {
|
|
495
497
|
if (this.killGraceTimer === undefined)
|
|
@@ -515,7 +517,11 @@ class ElectronChildClient {
|
|
|
515
517
|
// kept a live timer until it fired (harmless, but it is a dangling timer in a process
|
|
516
518
|
// that may be shutting down).
|
|
517
519
|
this.clearHelloTimer();
|
|
518
|
-
|
|
520
|
+
// NOT clearKillGrace(): this path runs when the SOCKET died, which says nothing about
|
|
521
|
+
// whether the process did. Clearing the escalation here disarmed it for exactly the case it
|
|
522
|
+
// exists for — a child that ignores its socket closing — so dispose() could return with the
|
|
523
|
+
// child still running and still holding the profile. The timer clears itself when the child
|
|
524
|
+
// exits, and `kill()` replaces it on an explicit release.
|
|
519
525
|
// Tag every failure as "host gone" so the view layer can tell a dead host
|
|
520
526
|
// apart from a page-level error and rebuild against a fresh child.
|
|
521
527
|
const wrapped = err instanceof HostGoneError ? err : new HostGoneError(err.message);
|
|
@@ -1245,11 +1251,18 @@ class DeferredRemoteView {
|
|
|
1245
1251
|
materializeOnce() {
|
|
1246
1252
|
if (this.materialized === undefined) {
|
|
1247
1253
|
const pending = this.materialize();
|
|
1248
|
-
|
|
1249
|
-
|
|
1254
|
+
// Compare against the promise actually stored — the derived one — not against `pending`.
|
|
1255
|
+
// Storing `pending.catch(...)` and comparing `this.materialized === pending` compared two
|
|
1256
|
+
// different objects, so the test was never true and the reset never ran: a materialization
|
|
1257
|
+
// that failed with anything other than a HostGoneError (the child alive but not answering
|
|
1258
|
+
// a ping, say) poisoned the handle for good, and the retry the comment below promises was
|
|
1259
|
+
// unreachable.
|
|
1260
|
+
const guarded = pending.catch(error => {
|
|
1261
|
+
if (this.materialized === guarded)
|
|
1250
1262
|
this.materialized = undefined;
|
|
1251
1263
|
throw error;
|
|
1252
1264
|
});
|
|
1265
|
+
this.materialized = guarded;
|
|
1253
1266
|
}
|
|
1254
1267
|
return this.materialized;
|
|
1255
1268
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The RPC operation vocabulary, in one place.
|
|
3
|
+
*
|
|
4
|
+
* The child (`host-main.ts`) switches on these names and the parent (`remote-host.ts`) sends
|
|
5
|
+
* them. Both spelled them out independently and nothing connected the two, so a rename on one
|
|
6
|
+
* side compiled fine and failed at run time as "unknown op" — and this pair has already
|
|
7
|
+
* produced one silent failure of that shape (`noteStartFailure`'s dedupe comparison, which was
|
|
8
|
+
* never true and meant a log line that never appeared).
|
|
9
|
+
*
|
|
10
|
+
* The lists are the source of truth for a test that reads both switch statements and asserts
|
|
11
|
+
* they agree, which is what makes a one-sided rename fail loudly instead of at run time.
|
|
12
|
+
*/
|
|
13
|
+
/** Operations the parent asks the child to perform. */
|
|
14
|
+
export declare const RPC_OPS: readonly ["ping", "createView", "destroyView", "showView", "groupView", "focus", "command", "capture", "download", "flushAuth", "restoreAuth", "navigate", "new-tab", "activate", "close", "back", "forward", "reload", "userActionError"];
|
|
15
|
+
/** Notifications the child sends the parent, which carry no reply. */
|
|
16
|
+
export declare const RPC_NOTIFICATIONS: readonly ["userAction", "viewClosed"];
|
|
17
|
+
/** One operation name. */
|
|
18
|
+
export type RpcOp = (typeof RPC_OPS)[number];
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The RPC operation vocabulary, in one place.
|
|
3
|
+
*
|
|
4
|
+
* The child (`host-main.ts`) switches on these names and the parent (`remote-host.ts`) sends
|
|
5
|
+
* them. Both spelled them out independently and nothing connected the two, so a rename on one
|
|
6
|
+
* side compiled fine and failed at run time as "unknown op" — and this pair has already
|
|
7
|
+
* produced one silent failure of that shape (`noteStartFailure`'s dedupe comparison, which was
|
|
8
|
+
* never true and meant a log line that never appeared).
|
|
9
|
+
*
|
|
10
|
+
* The lists are the source of truth for a test that reads both switch statements and asserts
|
|
11
|
+
* they agree, which is what makes a one-sided rename fail loudly instead of at run time.
|
|
12
|
+
*/
|
|
13
|
+
/** Operations the parent asks the child to perform. */
|
|
14
|
+
export const RPC_OPS = [
|
|
15
|
+
'ping',
|
|
16
|
+
'createView',
|
|
17
|
+
'destroyView',
|
|
18
|
+
'showView',
|
|
19
|
+
'groupView',
|
|
20
|
+
'focus',
|
|
21
|
+
'command',
|
|
22
|
+
'capture',
|
|
23
|
+
'download',
|
|
24
|
+
'flushAuth',
|
|
25
|
+
'restoreAuth',
|
|
26
|
+
'navigate',
|
|
27
|
+
'new-tab',
|
|
28
|
+
'activate',
|
|
29
|
+
'close',
|
|
30
|
+
'back',
|
|
31
|
+
'forward',
|
|
32
|
+
'reload',
|
|
33
|
+
'userActionError',
|
|
34
|
+
];
|
|
35
|
+
/** Notifications the child sends the parent, which carry no reply. */
|
|
36
|
+
export const RPC_NOTIFICATIONS = ['userAction', 'viewClosed'];
|
|
@@ -9,14 +9,14 @@
|
|
|
9
9
|
* It is NOT authentication, and cannot be. The plugin has no credential to check: DSH hands
|
|
10
10
|
* plugin routes the raw request (`packages/host/webserver/src/index.ts` registers handlers with
|
|
11
11
|
* no gate of its own), so the strongest same-origin evidence available is what a browser
|
|
12
|
-
* reliably sends
|
|
12
|
+
* reliably sends.
|
|
13
13
|
*
|
|
14
14
|
* What that buys and what it does not:
|
|
15
|
-
* - a plain HTTP client (curl, a script, another process) cannot WRITE
|
|
16
|
-
*
|
|
17
|
-
* - a page of THIS origin
|
|
18
|
-
*
|
|
19
|
-
*
|
|
15
|
+
* - a plain HTTP client (curl, a script, another process) cannot WRITE, because it sends no
|
|
16
|
+
* `user-agent` and no `accept-language`;
|
|
17
|
+
* - a page of THIS origin can, because a browser sending a same-origin write looks exactly
|
|
18
|
+
* like the panel. That is inherent to exposing an HTTP endpoint at all, and it is why the
|
|
19
|
+
* settings panel's own text must not promise a lock that does not exist.
|
|
20
20
|
*/
|
|
21
21
|
import type { IncomingMessage } from 'node:http';
|
|
22
22
|
/**
|
|
@@ -1,3 +1,20 @@
|
|
|
1
|
+
/** The shell's own URL schemes. Only the shell can mint a document on one of these. */
|
|
2
|
+
const APP_SCHEMES = ['dsh-app:', 'dsh-desktop:'];
|
|
3
|
+
/**
|
|
4
|
+
* The scheme of a header value, when it names a URL.
|
|
5
|
+
* @param value - a raw header value.
|
|
6
|
+
* @returns the scheme, or undefined when the value is absent, opaque or unparseable.
|
|
7
|
+
*/
|
|
8
|
+
function schemeOf(value) {
|
|
9
|
+
if (value === '' || value === 'null')
|
|
10
|
+
return undefined;
|
|
11
|
+
try {
|
|
12
|
+
return new URL(value).protocol;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
1
18
|
/**
|
|
2
19
|
* Whether a request may touch the settings document.
|
|
3
20
|
* @param request - the incoming request.
|
|
@@ -5,22 +22,45 @@
|
|
|
5
22
|
* @returns whether the request is admitted.
|
|
6
23
|
*/
|
|
7
24
|
export function sameOrigin(request, writing = false) {
|
|
25
|
+
const origin = String(request.headers.origin ?? '').trim();
|
|
8
26
|
const site = String(request.headers['sec-fetch-site'] ?? '').toLowerCase();
|
|
27
|
+
// A shell URL, in either the Origin or the referrer: the strongest evidence there is.
|
|
28
|
+
if (APP_SCHEMES.includes(schemeOf(origin) ?? ''))
|
|
29
|
+
return true;
|
|
30
|
+
if (APP_SCHEMES.includes(schemeOf(String(request.headers.referer ?? '')) ?? ''))
|
|
31
|
+
return true;
|
|
32
|
+
// MEASURED on the running desktop, and what three earlier attempts each guessed wrong: a
|
|
33
|
+
// request from the shell's own settings panel carries NO origin, NO sec-fetch-site and NO
|
|
34
|
+
// referer. It is not "dsh-app://app" and it is not "null" — the headers are simply absent,
|
|
35
|
+
// because Chromium attaches no cross-origin metadata to a request leaving a custom scheme.
|
|
36
|
+
//
|
|
37
|
+
// So the rule for that shape cannot be "a request without an Origin is a script", which is
|
|
38
|
+
// what refused every desktop save, and it cannot lean on `Sec-Fetch-Site` either. It has to
|
|
39
|
+
// test what a hand-rolled client does not send:
|
|
40
|
+
// - a renderer sends a full Mozilla-compatible `user-agent` AND an `accept-language`;
|
|
41
|
+
// - curl sends neither unless told to, and a local process that forges both can also read
|
|
42
|
+
// the settings document directly — it sits readable on disk, as the panel's own text
|
|
43
|
+
// says. This was never the boundary it looked like.
|
|
44
|
+
if (origin === '') {
|
|
45
|
+
const agent = String(request.headers['user-agent'] ?? '');
|
|
46
|
+
const language = String(request.headers['accept-language'] ?? '');
|
|
47
|
+
if (/^Mozilla\/5\.0/.test(agent) && language !== '')
|
|
48
|
+
return true;
|
|
49
|
+
// Reads stay open: they return the document the panel is already showing, and refusing them
|
|
50
|
+
// would only break hand-inspection of a file that sits readable on disk anyway.
|
|
51
|
+
return !writing;
|
|
52
|
+
}
|
|
9
53
|
// `cross-site` is another origin entirely; `same-site` is another origin of the same site
|
|
10
54
|
// (a.example.com -> b.example.com), which is not this panel either. Both are refused.
|
|
11
55
|
if (site === 'cross-site' || site === 'same-site')
|
|
12
56
|
return false;
|
|
13
|
-
|
|
14
|
-
// `Origin: null` is what a sandboxed or opaque-origin document sends. Never this panel.
|
|
57
|
+
// `Origin: null` is a sandboxed or opaque-origin document. Never this panel.
|
|
15
58
|
if (origin === 'null')
|
|
16
59
|
return false;
|
|
17
|
-
// Reads are let through without an Origin: they return the document the panel is already
|
|
18
|
-
// showing, and refusing them would only break hand-inspection of a file that sits readable
|
|
19
|
-
// on disk anyway. Writes are not: a browser always sends `Origin` on one.
|
|
20
|
-
if (origin === '')
|
|
21
|
-
return !writing;
|
|
22
60
|
try {
|
|
23
|
-
|
|
61
|
+
const parsed = new URL(origin);
|
|
62
|
+
// The host comparison is for http(s), where a page and its route share one host.
|
|
63
|
+
return parsed.host.toLowerCase() === String(request.headers.host ?? '').trim().toLowerCase();
|
|
24
64
|
}
|
|
25
65
|
catch {
|
|
26
66
|
return false;
|
|
@@ -75,18 +75,42 @@ const SETTINGS_WHEN_UNREADABLE = {
|
|
|
75
75
|
* @returns a valid channel.
|
|
76
76
|
*/
|
|
77
77
|
function resolveChannel(value) {
|
|
78
|
-
return value === 'chrome' || value === 'edge' || value === 'auto' || value === 'bundled'
|
|
78
|
+
return value === 'chrome' || value === 'edge' || value === 'auto' || value === 'bundled' || value === 'sidebar'
|
|
79
79
|
? value
|
|
80
80
|
: DEFAULT_SETTINGS.browser.channel;
|
|
81
81
|
}
|
|
82
82
|
/** Coerce one positive-integer field, keeping the default when invalid. */
|
|
83
83
|
function count(value, fallback) {
|
|
84
|
-
|
|
84
|
+
if (typeof value !== 'number' || !Number.isFinite(value))
|
|
85
|
+
return fallback;
|
|
86
|
+
const floored = Math.floor(value);
|
|
87
|
+
// A fractional value below 1 floors to 0, and 0 is not "very few" in either place these are
|
|
88
|
+
// used: as a history entry cap it means `slice(len - 0)`, i.e. keep nothing, so a hand-typed
|
|
89
|
+
// `0.5` silently wiped the history file on the next rewrite. Anything that survives the
|
|
90
|
+
// floor has to still be a usable limit.
|
|
91
|
+
return floored > 0 ? floored : fallback;
|
|
85
92
|
}
|
|
86
93
|
/** Read one nested section of an unknown document, or an empty object. */
|
|
94
|
+
/**
|
|
95
|
+
* Read one section of the document, distinguishing ABSENT from UNREADABLE.
|
|
96
|
+
*
|
|
97
|
+
* Absent (the key is not there at all) is a document written before the section existed, and
|
|
98
|
+
* the defaults apply — that is the documented behaviour. Present-but-not-an-object
|
|
99
|
+
* (`"actions": false`, `null` from a truncated write, a stray string) is a document this code
|
|
100
|
+
* cannot read, and returning `{}` for it made every gate inside look absent, so each one took
|
|
101
|
+
* its permissive default. A whole section mistyped is exactly the shape a careless hand edit
|
|
102
|
+
* produces, and it opened every capability the section was there to bound.
|
|
103
|
+
* @param source - the parsed document.
|
|
104
|
+
* @param key - the section name.
|
|
105
|
+
* @returns the section, or undefined when it is present but not an object.
|
|
106
|
+
*/
|
|
87
107
|
function section(source, key) {
|
|
88
108
|
const value = source[key];
|
|
89
|
-
|
|
109
|
+
if (value === undefined)
|
|
110
|
+
return {};
|
|
111
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
112
|
+
? value
|
|
113
|
+
: undefined;
|
|
90
114
|
}
|
|
91
115
|
/**
|
|
92
116
|
* Resolve a raw settings document (already parsed, shape unknown) into the full
|
|
@@ -104,25 +128,42 @@ export function resolveSettings(raw) {
|
|
|
104
128
|
const credentials = section(source, 'credentials');
|
|
105
129
|
const browser = section(source, 'browser');
|
|
106
130
|
const actions = section(source, 'actions');
|
|
131
|
+
// A section that is present but unreadable refuses its gates rather than defaulting them
|
|
132
|
+
// open. The rest of a broken section keeps its defaults: refusing history or presentation
|
|
133
|
+
// would punish the user for a bad file without protecting anything.
|
|
134
|
+
const credentialsUsable = credentials ?? {};
|
|
135
|
+
const actionsUsable = actions ?? {};
|
|
107
136
|
return {
|
|
108
137
|
history: {
|
|
109
|
-
enabled: bool(history
|
|
110
|
-
maxEntries: count(history
|
|
111
|
-
maxAgeDays: count(history
|
|
138
|
+
enabled: bool(history?.enabled, DEFAULT_SETTINGS.history.enabled),
|
|
139
|
+
maxEntries: count(history?.maxEntries, DEFAULT_SETTINGS.history.maxEntries),
|
|
140
|
+
maxAgeDays: count(history?.maxAgeDays, DEFAULT_SETTINGS.history.maxAgeDays),
|
|
112
141
|
},
|
|
113
|
-
cookies: { persist: bool(cookies
|
|
142
|
+
cookies: { persist: bool(cookies?.persist, DEFAULT_SETTINGS.cookies.persist) },
|
|
114
143
|
ui: {
|
|
115
|
-
autoExpandOnce: bool(ui
|
|
116
|
-
closeWithSession: bool(ui
|
|
117
|
-
virtualCursor: bool(ui
|
|
144
|
+
autoExpandOnce: bool(ui?.autoExpandOnce, DEFAULT_SETTINGS.ui.autoExpandOnce),
|
|
145
|
+
closeWithSession: bool(ui?.closeWithSession, DEFAULT_SETTINGS.ui.closeWithSession),
|
|
146
|
+
virtualCursor: bool(ui?.virtualCursor, DEFAULT_SETTINGS.ui.virtualCursor),
|
|
147
|
+
},
|
|
148
|
+
vision: { strategy: vision?.strategy === 'nonVisual' ? 'nonVisual' : DEFAULT_SETTINGS.vision.strategy },
|
|
149
|
+
browser: { channel: resolveChannel(browser?.channel) },
|
|
150
|
+
credentials: {
|
|
151
|
+
// `gate` returns false for anything present-and-not-a-boolean, so an unreadable section
|
|
152
|
+
// is refused here for free: the section itself is the "present but wrong" value.
|
|
153
|
+
allowRead: credentials === undefined
|
|
154
|
+
? false
|
|
155
|
+
: gate(credentialsUsable.allowRead, DEFAULT_SETTINGS.credentials.allowRead),
|
|
118
156
|
},
|
|
119
|
-
vision: { strategy: vision.strategy === 'nonVisual' ? 'nonVisual' : DEFAULT_SETTINGS.vision.strategy },
|
|
120
|
-
browser: { channel: resolveChannel(browser.channel) },
|
|
121
|
-
credentials: { allowRead: gate(credentials.allowRead, DEFAULT_SETTINGS.credentials.allowRead) },
|
|
122
157
|
actions: {
|
|
123
|
-
allowExecute:
|
|
124
|
-
|
|
125
|
-
|
|
158
|
+
allowExecute: actions === undefined
|
|
159
|
+
? false
|
|
160
|
+
: gate(actionsUsable.allowExecute, DEFAULT_SETTINGS.actions.allowExecute),
|
|
161
|
+
allowDownload: actions === undefined
|
|
162
|
+
? false
|
|
163
|
+
: gate(actionsUsable.allowDownload, DEFAULT_SETTINGS.actions.allowDownload),
|
|
164
|
+
allowCredentialWrite: actions === undefined
|
|
165
|
+
? false
|
|
166
|
+
: gate(actionsUsable.allowCredentialWrite, DEFAULT_SETTINGS.actions.allowCredentialWrite),
|
|
126
167
|
},
|
|
127
168
|
};
|
|
128
169
|
}
|
|
@@ -37,7 +37,7 @@ export type BrowserLauncher = (path: string, args: readonly string[]) => ReturnT
|
|
|
37
37
|
import { spawn } from 'node:child_process';
|
|
38
38
|
import type { ElectronBrowserViewHost, ElectronViewHandle } from './provider.js';
|
|
39
39
|
/** Which browser the user asked for. */
|
|
40
|
-
export type BrowserChannel = 'bundled' | 'chrome' | 'edge' | 'auto';
|
|
40
|
+
export type BrowserChannel = 'bundled' | 'chrome' | 'edge' | 'auto' | 'sidebar';
|
|
41
41
|
/** A resolved browser installation. */
|
|
42
42
|
export interface DetectedBrowser {
|
|
43
43
|
/** Which product this is. */
|
|
@@ -157,6 +157,15 @@ export declare class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
157
157
|
* @returns true when it answered.
|
|
158
158
|
*/
|
|
159
159
|
private probeClient;
|
|
160
|
+
/**
|
|
161
|
+
* Forget which browser issued what, after that browser is gone.
|
|
162
|
+
*
|
|
163
|
+
* The two maps must move together: `views` maps a view id to a session id and `sessions`
|
|
164
|
+
* maps a session id to a target id, so clearing one alone leaves a mapping the next command
|
|
165
|
+
* will act on. Every path that abandons a browser calls this rather than spelling the pair
|
|
166
|
+
* out — four separate copies is how the third one came to be missing.
|
|
167
|
+
*/
|
|
168
|
+
private forgetBrowserState;
|
|
160
169
|
private ensureClient;
|
|
161
170
|
/**
|
|
162
171
|
* Launch the browser with a private profile and connect over CDP.
|
|
@@ -179,10 +188,23 @@ export declare class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
179
188
|
destroyView(handle: ElectronViewHandle): void;
|
|
180
189
|
/** Nothing to show: the browser owns its own windows. */
|
|
181
190
|
showView(): void;
|
|
182
|
-
/**
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
191
|
+
/**
|
|
192
|
+
* Bring the target's page to the front, then report success.
|
|
193
|
+
*
|
|
194
|
+
* The comment on `focus` below used to say this was impossible — that raising the page needs
|
|
195
|
+
* `Page.bringToFront` on a target session, which is a view-level concern the host cannot
|
|
196
|
+
* reach. The handle this method is handed IS that view, and it can send commands, so the
|
|
197
|
+
* capability was here all along; it just was not used. Without it `presentView` was an empty
|
|
198
|
+
* async method, so the provider's whole presentation barrier — the one that stops
|
|
199
|
+
* `Input.*` from being silently dropped for a view with no display surface, and reports
|
|
200
|
+
* `BROWSER_VIEW_NOT_PRESENTED` when it cannot present — never ran on this carrier.
|
|
201
|
+
*
|
|
202
|
+
* A failure is swallowed rather than propagated: a real Chrome window that refuses to come
|
|
203
|
+
* forward is not a reason to refuse the click that follows. The barrier exists to order the
|
|
204
|
+
* two, and the order is honoured either way.
|
|
205
|
+
* @param handle - the view to present.
|
|
206
|
+
*/
|
|
207
|
+
presentView(handle: ElectronViewHandle): Promise<void>;
|
|
186
208
|
/**
|
|
187
209
|
* Best effort at bringing the browser's window forward.
|
|
188
210
|
*
|
|
@@ -196,10 +218,6 @@ export declare class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
196
218
|
* view-level concern and not available to the host.
|
|
197
219
|
*/
|
|
198
220
|
focus(): Promise<void>;
|
|
199
|
-
/** The browser reports its own window lifecycle. */
|
|
200
|
-
onUserAction(): void;
|
|
201
|
-
/** The browser reports its own window lifecycle. */
|
|
202
|
-
onViewClosed(): void;
|
|
203
221
|
/**
|
|
204
222
|
* Close the browser we launched, if we ever launched one.
|
|
205
223
|
*
|
|
@@ -216,6 +234,10 @@ export declare class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
216
234
|
* a second copy of that rule.
|
|
217
235
|
*/
|
|
218
236
|
private ensureSession;
|
|
237
|
+
/** Create and attach the target for one view. Serialised by {@link ensureSession}. */
|
|
238
|
+
private buildSession;
|
|
239
|
+
/** Views whose session is being created right now, so a second caller waits for the first. */
|
|
240
|
+
private readonly sessionBuilding;
|
|
219
241
|
/** sessionId -> targetId, so a destroyed view can close the right page. */
|
|
220
242
|
private readonly sessions;
|
|
221
243
|
}
|