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
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Persistent browsing history: which pages the shared browser actually visited,
3
+ * kept independently from any session's operation log (`browser_history`) and
4
+ * from the browser process's lifetime.
5
+ *
6
+ * It lives beside the browser profile (`$DSH_HOME/dsh-builtin-browser-host/
7
+ * history.jsonl`), so it follows the same persistence rules as the login state:
8
+ * closing the interface releases the process, never the record.
9
+ *
10
+ * Format: one JSON object per line, append-only. Appending stays cheap for a
11
+ * long session, and a half-written tail (crash, power loss) is dropped on read
12
+ * instead of poisoning the whole file.
13
+ * @module dsh-browser/browser-electron/history-store
14
+ */
15
+
16
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
17
+ import { homedir } from 'node:os'
18
+ import { dirname, join } from 'node:path'
19
+ import type { VisitedPage } from '../browser/types.js'
20
+
21
+ export type { VisitedPage }
22
+
23
+ /** Retention limits; whichever is hit first starts trimming. */
24
+ export interface HistoryLimits {
25
+ /** Maximum retained entries. Default 5000. */
26
+ readonly maxEntries?: number
27
+ /** Maximum age in days. Default 90. */
28
+ readonly maxAgeDays?: number
29
+ }
30
+
31
+ /** Default entry cap — mirrors the documented retention rule. */
32
+ const DEFAULT_MAX_ENTRIES = 5_000
33
+ /** Default age cap in days. */
34
+ const DEFAULT_MAX_AGE_DAYS = 90
35
+ /** Rewrite the file only once it has grown this far past the cap. */
36
+ const PRUNE_SLACK = 500
37
+
38
+ /**
39
+ * Absolute path of the browsing-history file, beside the browser profile so the
40
+ * record shares the login state's persistence rules.
41
+ * @returns the history file path.
42
+ */
43
+ export function visitedHistoryPath(): string {
44
+ const home = process.env.DSH_HOME ?? join(homedir(), '.dsh')
45
+ return join(home, 'dsh-builtin-browser-host', 'history.jsonl')
46
+ }
47
+
48
+ /**
49
+ * Append-only browsing history with bounded retention. Every method tolerates a
50
+ * missing or damaged file: recording a visit must never fail a navigation, and
51
+ * a corrupted line must never hide the rest of the history.
52
+ */
53
+ export class HistoryStore {
54
+ private readonly maxEntries: number
55
+ private readonly maxAgeMs: number
56
+
57
+ /**
58
+ * @param file - absolute path of the JSONL history file.
59
+ * @param limits - retention overrides (entry count and age).
60
+ */
61
+ constructor(
62
+ private readonly file: string = visitedHistoryPath(),
63
+ limits: HistoryLimits = {},
64
+ ) {
65
+ this.maxEntries = limits.maxEntries ?? DEFAULT_MAX_ENTRIES
66
+ this.maxAgeMs = (limits.maxAgeDays ?? DEFAULT_MAX_AGE_DAYS) * 24 * 60 * 60 * 1000
67
+ }
68
+
69
+ /** Whether the history file exists yet (diagnostics and tests). */
70
+ exists(): boolean {
71
+ return existsSync(this.file)
72
+ }
73
+
74
+ /**
75
+ * Record one visit. Failures are swallowed by design: history is a
76
+ * convenience, and losing an entry must never break the tool call that
77
+ * produced it.
78
+ * @param page - the visited page.
79
+ */
80
+ append(page: VisitedPage): void {
81
+ try {
82
+ mkdirSync(dirname(this.file), { recursive: true })
83
+ appendFileSync(this.file, `${JSON.stringify(page)}\n`)
84
+ } catch {
85
+ // Best-effort: history is not worth failing a navigation over.
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Read visits, newest first. Unparseable lines are skipped; entries older
91
+ * than the age limit are filtered out but left on disk until the next prune.
92
+ * @param options - optional result cap and domain filter.
93
+ * @returns the matching visits, newest first.
94
+ */
95
+ list(options: { readonly limit?: number; readonly domain?: string } = {}): VisitedPage[] {
96
+ const all = this.readAll()
97
+ const cutoff = Date.now() - this.maxAgeMs
98
+ const domain = options.domain?.toLowerCase()
99
+ const matches = all.filter(page => {
100
+ if (page.at < cutoff) return false
101
+ if (domain === undefined) return true
102
+ try {
103
+ return new URL(page.url).hostname.toLowerCase().includes(domain)
104
+ } catch {
105
+ return page.url.toLowerCase().includes(domain)
106
+ }
107
+ })
108
+ matches.reverse()
109
+ return options.limit === undefined ? matches : matches.slice(0, Math.max(0, options.limit))
110
+ }
111
+
112
+ /**
113
+ * Enforce retention: drop entries past the age limit, then the oldest beyond
114
+ * the count cap. Cheap no-op until the file grows past the cap, so callers can
115
+ * run it after every append.
116
+ */
117
+ prune(): void {
118
+ const all = this.readAll()
119
+ const cutoff = Date.now() - this.maxAgeMs
120
+ const fresh = all.filter(page => page.at >= cutoff)
121
+ const kept = fresh.length > this.maxEntries ? fresh.slice(fresh.length - this.maxEntries) : fresh
122
+ // Only rewrite when something actually fell off, and only once the excess
123
+ // is worth a full rewrite.
124
+ if (kept.length === all.length) return
125
+ if (all.length - kept.length < PRUNE_SLACK && fresh.length <= this.maxEntries) return
126
+ try {
127
+ const body = kept.map(page => `${JSON.stringify(page)}\n`).join('')
128
+ writeFileSync(this.file, body)
129
+ } catch {
130
+ // Best-effort: an unpruned file still reads correctly.
131
+ }
132
+ }
133
+
134
+ /** Parse the whole file, skipping blank and damaged lines. */
135
+ private readAll(): VisitedPage[] {
136
+ let raw: string
137
+ try {
138
+ raw = readFileSync(this.file, 'utf8')
139
+ } catch {
140
+ return []
141
+ }
142
+ const pages: VisitedPage[] = []
143
+ for (const line of raw.split('\n')) {
144
+ const trimmed = line.trim()
145
+ if (trimmed === '') continue
146
+ try {
147
+ const parsed = JSON.parse(trimmed) as Partial<VisitedPage>
148
+ if (typeof parsed.at !== 'number' || typeof parsed.url !== 'string') continue
149
+ pages.push({
150
+ at: parsed.at,
151
+ url: parsed.url,
152
+ ...typeof parsed.title === 'string' && parsed.title !== '' ? { title: parsed.title } : {},
153
+ ...typeof parsed.session === 'string' && parsed.session !== '' ? { session: parsed.session } : {},
154
+ })
155
+ } catch {
156
+ // A damaged line (typically a truncated tail) must not hide the rest.
157
+ continue
158
+ }
159
+ }
160
+ return pages
161
+ }
162
+ }
@@ -13,6 +13,7 @@
13
13
  * { id, op: 'destroyView', viewId } | { id, op: 'showView', viewId } |
14
14
  * { id, op: 'groupView', viewId, windowId, label? } |
15
15
  * { id, op: 'command', viewId, method, params } |
16
+ * { id, op: 'focus', viewId } |
16
17
  * { id, op: 'userActionError', windowId, message }
17
18
  * -> { id: 0, op: 'hello', token } (our FIRST message — proves we know the
18
19
  * parent's stdin token; the parent refuses the connection otherwise)
@@ -286,6 +287,14 @@ function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise
286
287
  */
287
288
  const COMMAND_TIMEOUT_MS = 20000
288
289
 
290
+ /**
291
+ * How long to let a `focus` request land before answering it. Focus moves
292
+ * asynchronously and a key dispatched in the same turn as the request is still
293
+ * dropped, so the caller needs this settle — but only on the call where focus
294
+ * actually had to move.
295
+ */
296
+ const FOCUS_SETTLE_MS = 80
297
+
289
298
  /**
290
299
  * A brand-new WebContentsView has no renderer process until something loads,
291
300
  * and CDP commands (Runtime.evaluate …) sent to a renderer-less view never
@@ -380,7 +389,32 @@ function windowFor(windowId: string | undefined): HostWindow {
380
389
  const existing = windows.get(key)
381
390
  if (existing !== undefined) return existing
382
391
  const w = new BrowserWindow({ width: 1400, height: 900, show: true, title: 'dsh-browser' })
383
- w.on('closed', () => { windows.delete(key) })
392
+ w.on('closed', () => {
393
+ const closing = windows.get(key)
394
+ windows.delete(key)
395
+ // A BrowserWindow does not destroy its child views: their webContents (the
396
+ // renderer process, its timers and audio) outlive the window unless closed
397
+ // explicitly, so release them here instead of leaking a renderer per window.
398
+ if (closing !== undefined) {
399
+ for (const view of closing.views.values()) {
400
+ try {
401
+ view.webContentsView.webContents.close()
402
+ } catch {
403
+ // Already destroyed along with the window.
404
+ }
405
+ }
406
+ closing.views.clear()
407
+ }
408
+ // The interface is gone for good. Report it, so the plugin ends that session
409
+ // instead of holding one whose window the human already closed. Browsing
410
+ // history and login state live on disk, so the next open starts a clean
411
+ // session rather than resurrecting this one.
412
+ try {
413
+ rpcSocket?.write(JSON.stringify({ id: 0, op: 'viewClosed', windowId: key }) + '\n')
414
+ } catch {
415
+ // Socket already gone (parent shutting down): nothing left to report to.
416
+ }
417
+ })
384
418
  const win: HostWindow = { windowId: key, window: w, toolbarView: undefined, views: new Map(), visibleViewId: undefined, lastFocusedViewId: undefined, closeTimer: undefined }
385
419
  // Keep every view (and the toolbar) filling the window as the human
386
420
  // resizes it; otherwise pages stay at their original size and break.
@@ -714,6 +748,26 @@ async function handle(op: string, msg: { id: number; viewId?: string; windowId?:
714
748
  reply(msg.id, { ok: true, result })
715
749
  return
716
750
  }
751
+ case 'focus': {
752
+ const viewId = msg.viewId
753
+ if (viewId === undefined) throw new Error('focus missing viewId')
754
+ const win = windowOfView(viewId)
755
+ const entry = win?.views.get(viewId)
756
+ if (win === undefined || entry === undefined) throw new Error(`focus: unknown view ${viewId}`)
757
+ // Keys go to the view that holds web focus. A view that was never
758
+ // clicked holds none, so the FIRST browser_key of a session was dropped
759
+ // by the renderer while CDP still answered the dispatch with success —
760
+ // and `document.hasFocus()` inside the page even read true.
761
+ const contents = entry.webContentsView.webContents
762
+ const hadWebFocus = contents.isFocused()
763
+ contents.focus()
764
+ // Focus lands asynchronously, and a key dispatched in the same turn as
765
+ // the request is still dropped, so hold the reply until the renderer can
766
+ // take input. Only on the call where focus actually had to move.
767
+ if (!hadWebFocus) await new Promise(resolve => setTimeout(resolve, FOCUS_SETTLE_MS))
768
+ reply(msg.id, { ok: true, focused: contents.isFocused() })
769
+ return
770
+ }
717
771
  case 'capture': {
718
772
  const viewId = msg.viewId
719
773
  if (viewId === undefined) throw new Error('capture missing viewId')
@@ -942,6 +996,34 @@ async function handle(op: string, msg: { id: number; viewId?: string; windowId?:
942
996
  }
943
997
  }
944
998
 
999
+ /**
1000
+ * Chromium's native-window occlusion tracker (Windows only) asks DWM whether a
1001
+ * window is covered by another window and, when it is, moves the WebContents to
1002
+ * `Visibility::HIDDEN`. That is the wrong model for this host: the shared
1003
+ * browser window normally sits *behind* whatever the human is working in, and
1004
+ * that is exactly the state the agent drives it from. A hidden WebContents
1005
+ * produces no frames at all (`requestAnimationFrame` never fires) and
1006
+ * `RenderWidgetHostImpl::CanReceiveInput()` returns false, so every
1007
+ * `Input.dispatchMouseEvent`/`dispatchKeyEvent` sent over CDP is dropped on the
1008
+ * floor while the CDP call itself still answers `{}` — clicks and keys that
1009
+ * silently do nothing, with no error anywhere. Keep the window's visibility
1010
+ * state application-controlled instead; the parent already shows/raises views
1011
+ * explicitly, and `webContentsView.setVisible()` remains the single source of
1012
+ * truth for whether a view renders.
1013
+ */
1014
+ function keepWindowsNonOccluding(): void {
1015
+ if (process.platform !== 'win32') return
1016
+ const DISABLED = 'CalculateNativeWinOcclusion'
1017
+ const current = app.commandLine.getSwitchValue('disable-features') ?? ''
1018
+ const features = current
1019
+ .split(',')
1020
+ .map(part => part.trim())
1021
+ .filter(part => part !== '')
1022
+ if (!features.includes(DISABLED)) features.push(DISABLED)
1023
+ app.commandLine.appendSwitch('disable-features', features.join(','))
1024
+ }
1025
+ keepWindowsNonOccluding()
1026
+
945
1027
  /**
946
1028
  * Electron entry: connect back to the parent's RPC server (port from
947
1029
  * `--rpc-port`) and serve line-delimited JSON-RPC. `ELECTRON_RUN_AS_NODE` is