dsh-builtin-browser 0.1.22 → 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 +50 -3
- package/README.md +51 -4
- 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 +28 -1
- 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.d.ts +1 -0
- package/lib/browser-electron/host-main.js +88 -1
- package/lib/browser-electron/provider.d.ts +105 -4
- package/lib/browser-electron/provider.js +226 -14
- package/lib/browser-electron/remote-host.d.ts +29 -6
- package/lib/browser-electron/remote-host.js +114 -24
- 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 +20 -6
- package/src/browser/runtime.ts +37 -1
- 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 +83 -1
- package/src/browser-electron/provider.ts +267 -19
- package/src/browser-electron/remote-host.ts +114 -21
- 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
|
@@ -99,29 +99,50 @@ export declare class RemoteElectronViewHost implements ElectronBrowserViewHost {
|
|
|
99
99
|
/** Test seam: the executable to spawn instead of the resolved Electron
|
|
100
100
|
* binary. Absent -> resolveElectronPath() (production behavior). */
|
|
101
101
|
private readonly spawnExecutable?;
|
|
102
|
+
/** Test seam: the binary probe behind `available()`. Absent ->
|
|
103
|
+
* resolveElectronPath(). */
|
|
104
|
+
private readonly probe;
|
|
102
105
|
private client;
|
|
103
106
|
private server;
|
|
104
107
|
private pendingSocket;
|
|
105
108
|
private readonly views;
|
|
106
109
|
private readyPromise;
|
|
107
110
|
private disposed;
|
|
108
|
-
/**
|
|
111
|
+
/** Last probe result. A success is kept for the host's lifetime; a failure
|
|
112
|
+
* is retried after `PROBE_RETRY_MS` (see `available()`). */
|
|
109
113
|
private electronAvailable;
|
|
114
|
+
/** Earliest time the next probe may run after a negative one. */
|
|
115
|
+
private nextProbeAt;
|
|
110
116
|
/** Window groups (windowId per view), re-sent on every materialization so
|
|
111
117
|
* a restarted child still places views in the right windows. */
|
|
112
118
|
private readonly groups;
|
|
113
119
|
/** The provider's user-action handler; routes toolbar actions into sessions. */
|
|
114
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;
|
|
115
127
|
constructor(hostMainPath: string,
|
|
116
128
|
/** Test seam: the executable to spawn instead of the resolved Electron
|
|
117
129
|
* binary. Absent -> resolveElectronPath() (production behavior). */
|
|
118
|
-
spawnExecutable?: string | undefined
|
|
130
|
+
spawnExecutable?: string | undefined,
|
|
131
|
+
/** Test seam: the binary probe behind `available()`. Absent ->
|
|
132
|
+
* resolveElectronPath(). */
|
|
133
|
+
probe?: () => void);
|
|
119
134
|
/**
|
|
120
135
|
* Cheap usability probe: can we find an Electron binary to spawn? The scan
|
|
121
|
-
* is filesystem-only (no network), per the seam's contract,
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
136
|
+
* is filesystem-only (no network), per the seam's contract, so a missing
|
|
137
|
+
* binary surfaces as `BROWSER_PROVIDER_UNAVAILABLE` at provider selection
|
|
138
|
+
* instead of a confusing spawn failure on first use.
|
|
139
|
+
*
|
|
140
|
+
* A success is cached for the host's lifetime. A failure is NOT: an install
|
|
141
|
+
* (`npm i electron`, or the postinstall that fetches the binary) can land
|
|
142
|
+
* long after DSH started, and provider selection happens once per process —
|
|
143
|
+
* caching "no" permanently reported "no usable browser provider is
|
|
144
|
+
* registered" until DSH restarted, with no way back short of that. Retrying
|
|
145
|
+
* on a cooldown heals it by itself while keeping the scan off the hot path.
|
|
125
146
|
*/
|
|
126
147
|
available(): boolean;
|
|
127
148
|
/** Ensure the child is up and ready (lazy on first use; restarts after a crash). */
|
|
@@ -152,6 +173,8 @@ export declare class RemoteElectronViewHost implements ElectronBrowserViewHost {
|
|
|
152
173
|
groupView(handle: ElectronViewHandle, windowId: string, label?: string): void;
|
|
153
174
|
/** Register the provider's handler for user-initiated toolbar actions. */
|
|
154
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;
|
|
155
178
|
/** Surface a failed user action to the child's toolbar (address bar etc.). */
|
|
156
179
|
notifyUserActionError(windowId: string, message: string): void;
|
|
157
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
|
|
@@ -624,6 +659,10 @@ class RemoteView {
|
|
|
624
659
|
params: params ?? {},
|
|
625
660
|
});
|
|
626
661
|
}
|
|
662
|
+
/** Give this view web focus in the child, so keyboard input reaches the page. */
|
|
663
|
+
focus() {
|
|
664
|
+
return this.client.call('focus', { viewId: this.id });
|
|
665
|
+
}
|
|
627
666
|
/** Ask the child to download a URL to a local file (keeps cookies/login). */
|
|
628
667
|
async download(url, savePath) {
|
|
629
668
|
// The child writes the file itself (temp + rename); only a small
|
|
@@ -644,6 +683,14 @@ class RemoteView {
|
|
|
644
683
|
return this.client.call('restoreAuth', { viewId: this.id, cookies }).then(r => r.restored);
|
|
645
684
|
}
|
|
646
685
|
}
|
|
686
|
+
/**
|
|
687
|
+
* How long a FAILED Electron-binary probe is remembered before the binary
|
|
688
|
+
* search runs again (`available()`). A success is kept for the host's lifetime.
|
|
689
|
+
*/
|
|
690
|
+
const PROBE_RETRY_MS = (() => {
|
|
691
|
+
const raw = Number(process.env.DSH_BROWSER_PROBE_RETRY_MS);
|
|
692
|
+
return Number.isFinite(raw) && raw >= 0 ? raw : 30000;
|
|
693
|
+
})();
|
|
647
694
|
/**
|
|
648
695
|
* Self-hosted view host: spawns the plugin's Electron child on first use and
|
|
649
696
|
* keeps it alive until dispose(). Fallback when no desktop shell provides
|
|
@@ -652,46 +699,69 @@ class RemoteView {
|
|
|
652
699
|
export class RemoteElectronViewHost {
|
|
653
700
|
hostMainPath;
|
|
654
701
|
spawnExecutable;
|
|
702
|
+
probe;
|
|
655
703
|
client;
|
|
656
704
|
server;
|
|
657
705
|
pendingSocket;
|
|
658
706
|
views = new Map();
|
|
659
707
|
readyPromise;
|
|
660
708
|
disposed = false;
|
|
661
|
-
/**
|
|
709
|
+
/** Last probe result. A success is kept for the host's lifetime; a failure
|
|
710
|
+
* is retried after `PROBE_RETRY_MS` (see `available()`). */
|
|
662
711
|
electronAvailable;
|
|
712
|
+
/** Earliest time the next probe may run after a negative one. */
|
|
713
|
+
nextProbeAt = 0;
|
|
663
714
|
/** Window groups (windowId per view), re-sent on every materialization so
|
|
664
715
|
* a restarted child still places views in the right windows. */
|
|
665
716
|
groups = new Map();
|
|
666
717
|
/** The provider's user-action handler; routes toolbar actions into sessions. */
|
|
667
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;
|
|
668
725
|
constructor(hostMainPath,
|
|
669
726
|
/** Test seam: the executable to spawn instead of the resolved Electron
|
|
670
727
|
* binary. Absent -> resolveElectronPath() (production behavior). */
|
|
671
|
-
spawnExecutable
|
|
728
|
+
spawnExecutable,
|
|
729
|
+
/** Test seam: the binary probe behind `available()`. Absent ->
|
|
730
|
+
* resolveElectronPath(). */
|
|
731
|
+
probe = resolveElectronPath) {
|
|
672
732
|
this.hostMainPath = hostMainPath;
|
|
673
733
|
this.spawnExecutable = spawnExecutable;
|
|
734
|
+
this.probe = probe;
|
|
674
735
|
}
|
|
675
736
|
/**
|
|
676
737
|
* Cheap usability probe: can we find an Electron binary to spawn? The scan
|
|
677
|
-
* is filesystem-only (no network), per the seam's contract,
|
|
678
|
-
*
|
|
679
|
-
*
|
|
680
|
-
*
|
|
738
|
+
* is filesystem-only (no network), per the seam's contract, so a missing
|
|
739
|
+
* binary surfaces as `BROWSER_PROVIDER_UNAVAILABLE` at provider selection
|
|
740
|
+
* instead of a confusing spawn failure on first use.
|
|
741
|
+
*
|
|
742
|
+
* A success is cached for the host's lifetime. A failure is NOT: an install
|
|
743
|
+
* (`npm i electron`, or the postinstall that fetches the binary) can land
|
|
744
|
+
* long after DSH started, and provider selection happens once per process —
|
|
745
|
+
* caching "no" permanently reported "no usable browser provider is
|
|
746
|
+
* registered" until DSH restarted, with no way back short of that. Retrying
|
|
747
|
+
* on a cooldown heals it by itself while keeping the scan off the hot path.
|
|
681
748
|
*/
|
|
682
749
|
available() {
|
|
683
|
-
if (this.electronAvailable ===
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
750
|
+
if (this.electronAvailable === true)
|
|
751
|
+
return true;
|
|
752
|
+
if (Date.now() < this.nextProbeAt)
|
|
753
|
+
return false;
|
|
754
|
+
this.nextProbeAt = Date.now() + PROBE_RETRY_MS;
|
|
755
|
+
try {
|
|
756
|
+
this.probe();
|
|
757
|
+
this.electronAvailable = true;
|
|
758
|
+
}
|
|
759
|
+
catch (error) {
|
|
760
|
+
this.electronAvailable = false;
|
|
761
|
+
// The provider will be reported unavailable, so the detailed
|
|
762
|
+
// resolution error (install-electron / ELECTRON_PATH guidance) would
|
|
763
|
+
// otherwise never reach the user — surface it on stderr.
|
|
764
|
+
process.stderr.write(`[dsh-browser host] electron unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
695
765
|
}
|
|
696
766
|
return this.electronAvailable;
|
|
697
767
|
}
|
|
@@ -758,7 +828,7 @@ export class RemoteElectronViewHost {
|
|
|
758
828
|
// Random per-spawn token: the child must prove knowledge of it (via
|
|
759
829
|
// stdin hello) before any command is written to the socket.
|
|
760
830
|
const token = randomBytes(24).toString('hex');
|
|
761
|
-
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);
|
|
762
832
|
if (this.pendingSocket !== undefined) {
|
|
763
833
|
this.client.attach(this.pendingSocket);
|
|
764
834
|
this.pendingSocket = undefined;
|
|
@@ -887,6 +957,10 @@ export class RemoteElectronViewHost {
|
|
|
887
957
|
onUserAction(handler) {
|
|
888
958
|
this.userActionHandler = handler;
|
|
889
959
|
}
|
|
960
|
+
/** Register the provider's handler for windows the human closed. */
|
|
961
|
+
onViewClosed(handler) {
|
|
962
|
+
this.viewClosedHandler = handler;
|
|
963
|
+
}
|
|
890
964
|
/** Surface a failed user action to the child's toolbar (address bar etc.). */
|
|
891
965
|
notifyUserActionError(windowId, message) {
|
|
892
966
|
void this.ready()
|
|
@@ -978,6 +1052,9 @@ class DeferredRemoteView {
|
|
|
978
1052
|
async capture(opts) {
|
|
979
1053
|
return this.withRecovery(view => view.capture(opts));
|
|
980
1054
|
}
|
|
1055
|
+
async focus() {
|
|
1056
|
+
return this.withRecovery(view => view.focus());
|
|
1057
|
+
}
|
|
981
1058
|
async flushAuth() {
|
|
982
1059
|
return this.withRecovery(view => view.flushAuth());
|
|
983
1060
|
}
|
|
@@ -996,6 +1073,15 @@ class DeferredRemoteView {
|
|
|
996
1073
|
* gets large (a crash loop writes quickly).
|
|
997
1074
|
*/
|
|
998
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
|
+
}
|
|
999
1085
|
function appendHostLog(text) {
|
|
1000
1086
|
const home = process.env.DSH_HOME;
|
|
1001
1087
|
if (home === undefined || home === '')
|
|
@@ -1004,8 +1090,12 @@ function appendHostLog(text) {
|
|
|
1004
1090
|
const dir = join(home, 'logs');
|
|
1005
1091
|
const file = join(dir, 'dsh-builtin-browser-host.log');
|
|
1006
1092
|
try {
|
|
1007
|
-
if (statSync(file).size > HOST_LOG_MAX_BYTES)
|
|
1008
|
-
|
|
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
|
+
}
|
|
1009
1099
|
}
|
|
1010
1100
|
catch { /* first write, or unreadable: just append */ }
|
|
1011
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
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synthetic pointer feedback for a human watching the tab: an overlay drawn
|
|
3
|
+
* INSIDE the page, never the real system pointer. Its presence is the "the agent
|
|
4
|
+
* has taken over this tab" signal, so it also covers DOM-level operations that
|
|
5
|
+
* move no pointer at all.
|
|
6
|
+
*
|
|
7
|
+
* Implementation notes that matter:
|
|
8
|
+
* - The overlay is `position: fixed` and positioned from viewport coordinates,
|
|
9
|
+
* which is exactly what the element locator already computes and what
|
|
10
|
+
* `Input.dispatchMouseEvent` consumes.
|
|
11
|
+
* - All styling is inline and the ripple uses the Web Animations API: a
|
|
12
|
+
* `<style>` element would be subject to the page's `style-src` CSP, an
|
|
13
|
+
* injected script is not. Injected page feedback must never be silently
|
|
14
|
+
* dropped by a strict CSP.
|
|
15
|
+
* - Every call is best-effort: feedback is never allowed to fail an operation.
|
|
16
|
+
* @module dsh-browser/browser-electron/virtual-cursor
|
|
17
|
+
*/
|
|
18
|
+
/** What the pointer is doing at the reported point. */
|
|
19
|
+
export type CursorAction = 'move' | 'click';
|
|
20
|
+
/**
|
|
21
|
+
* The in-page expression that materializes or moves the overlay. Idempotent: the
|
|
22
|
+
* first call creates the nodes, later calls only move them (and pulse).
|
|
23
|
+
* @param x - viewport x in CSS pixels.
|
|
24
|
+
* @param y - viewport y in CSS pixels.
|
|
25
|
+
* @param action - whether to pulse a click ripple at the point.
|
|
26
|
+
* @returns a self-contained expression that evaluates to `true`.
|
|
27
|
+
*/
|
|
28
|
+
export declare function cursorScript(x: number, y: number, action: CursorAction): string;
|
|
29
|
+
/**
|
|
30
|
+
* Draw (or move) the pointer overlay on one view's page.
|
|
31
|
+
*
|
|
32
|
+
* Fire-and-forget: a page that is mid-navigation, cross-origin-blocked, or
|
|
33
|
+
* otherwise unable to answer must not turn a successful operation into a
|
|
34
|
+
* failure, and the navigation that just committed may still be replacing the
|
|
35
|
+
* document this call would have painted into.
|
|
36
|
+
* @param handle - the view to paint into.
|
|
37
|
+
* @param x - viewport x in CSS pixels.
|
|
38
|
+
* @param y - viewport y in CSS pixels.
|
|
39
|
+
* @param action - whether to pulse a click ripple.
|
|
40
|
+
* @param evaluate - the provider's evaluate bridge (keeps the CDP coupling in one place).
|
|
41
|
+
*/
|
|
42
|
+
export declare function paintCursor<TView>(handle: TView, x: number, y: number, action: CursorAction, evaluate: (handle: TView, expression: string) => Promise<unknown>): void;
|