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.
Files changed (38) hide show
  1. package/README.en.md +50 -3
  2. package/README.md +51 -4
  3. package/client.js +298 -0
  4. package/cordis.patch.yml +24 -5
  5. package/docs/README.md +1 -1
  6. package/docs/architecture.md +1 -1
  7. package/docs/tool-reference.md +2 -1
  8. package/lib/browser/runtime.d.ts +18 -1
  9. package/lib/browser/runtime.js +28 -1
  10. package/lib/browser/types.d.ts +33 -0
  11. package/lib/browser-electron/entry.js +115 -0
  12. package/lib/browser-electron/history-store.d.ts +71 -0
  13. package/lib/browser-electron/history-store.js +151 -0
  14. package/lib/browser-electron/host-main.d.ts +1 -0
  15. package/lib/browser-electron/host-main.js +88 -1
  16. package/lib/browser-electron/provider.d.ts +105 -4
  17. package/lib/browser-electron/provider.js +226 -14
  18. package/lib/browser-electron/remote-host.d.ts +29 -6
  19. package/lib/browser-electron/remote-host.js +114 -24
  20. package/lib/browser-electron/settings-store.d.ts +87 -0
  21. package/lib/browser-electron/settings-store.js +160 -0
  22. package/lib/browser-electron/virtual-cursor.d.ts +42 -0
  23. package/lib/browser-electron/virtual-cursor.js +71 -0
  24. package/lib/index.d.ts +16 -2
  25. package/lib/index.js +18 -2
  26. package/lib/tool-browser/index.js +72 -2
  27. package/package.json +20 -6
  28. package/src/browser/runtime.ts +37 -1
  29. package/src/browser/types.ts +31 -0
  30. package/src/browser-electron/entry.ts +135 -0
  31. package/src/browser-electron/history-store.ts +162 -0
  32. package/src/browser-electron/host-main.ts +83 -1
  33. package/src/browser-electron/provider.ts +267 -19
  34. package/src/browser-electron/remote-host.ts +114 -21
  35. package/src/browser-electron/settings-store.ts +198 -0
  36. package/src/browser-electron/virtual-cursor.ts +82 -0
  37. package/src/index.ts +20 -2
  38. 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
- /** Cached probe result so `available()` stays cheap after the first call. */
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, and the result
122
- * is cached for the host's lifetime — a missing binary surfaces as
123
- * `BROWSER_PROVIDER_UNAVAILABLE` at provider selection instead of a
124
- * confusing spawn failure on first use.
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 = `spawn error: ${String(error)}`;
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
- const line = `browser host exited (code=${String(code)} signal=${String(signal)})`;
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
- /** Cached probe result so `available()` stays cheap after the first call. */
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, and the result
678
- * is cached for the host's lifetime — a missing binary surfaces as
679
- * `BROWSER_PROVIDER_UNAVAILABLE` at provider selection instead of a
680
- * confusing spawn failure on first use.
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 === undefined) {
684
- try {
685
- resolveElectronPath();
686
- this.electronAvailable = true;
687
- }
688
- catch (error) {
689
- this.electronAvailable = false;
690
- // The provider will be reported unavailable, so the detailed
691
- // resolution error (install-electron / ELECTRON_PATH guidance) would
692
- // otherwise never reach the user — surface it on stderr.
693
- process.stderr.write(`[dsh-browser host] electron unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
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
- writeFileSync(file, '');
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;