dsh-builtin-browser 0.1.23 → 0.3.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 (48) hide show
  1. package/README.en.md +91 -3
  2. package/README.md +98 -4
  3. package/client.js +334 -0
  4. package/cordis.patch.yml +24 -5
  5. package/desktop-bridge/install.mjs +98 -0
  6. package/desktop-bridge/plugin-browser-bridge.js +385 -0
  7. package/docs/README.md +1 -1
  8. package/docs/architecture.md +1 -1
  9. package/docs/tool-reference.md +2 -1
  10. package/lib/browser/runtime.d.ts +18 -1
  11. package/lib/browser/runtime.js +19 -0
  12. package/lib/browser/types.d.ts +33 -0
  13. package/lib/browser-electron/bridge-connection.d.ts +58 -0
  14. package/lib/browser-electron/bridge-connection.js +133 -0
  15. package/lib/browser-electron/desktop-bridge-host.d.ts +124 -0
  16. package/lib/browser-electron/desktop-bridge-host.js +297 -0
  17. package/lib/browser-electron/entry.js +195 -11
  18. package/lib/browser-electron/history-store.d.ts +75 -0
  19. package/lib/browser-electron/history-store.js +162 -0
  20. package/lib/browser-electron/host-main.js +28 -1
  21. package/lib/browser-electron/provider.d.ts +122 -1
  22. package/lib/browser-electron/provider.js +233 -21
  23. package/lib/browser-electron/remote-host.d.ts +17 -1
  24. package/lib/browser-electron/remote-host.js +81 -9
  25. package/lib/browser-electron/settings-store.d.ts +92 -0
  26. package/lib/browser-electron/settings-store.js +183 -0
  27. package/lib/browser-electron/system-browser.d.ts +100 -0
  28. package/lib/browser-electron/system-browser.js +322 -0
  29. package/lib/browser-electron/virtual-cursor.d.ts +67 -0
  30. package/lib/browser-electron/virtual-cursor.js +141 -0
  31. package/lib/index.d.ts +16 -2
  32. package/lib/index.js +18 -2
  33. package/lib/tool-browser/index.js +122 -16
  34. package/package.json +14 -1
  35. package/src/browser/runtime.ts +22 -0
  36. package/src/browser/types.ts +31 -0
  37. package/src/browser-electron/bridge-connection.ts +145 -0
  38. package/src/browser-electron/desktop-bridge-host.ts +305 -0
  39. package/src/browser-electron/entry.ts +217 -11
  40. package/src/browser-electron/history-store.ts +171 -0
  41. package/src/browser-electron/host-main.ts +26 -1
  42. package/src/browser-electron/provider.ts +284 -21
  43. package/src/browser-electron/remote-host.ts +84 -7
  44. package/src/browser-electron/settings-store.ts +227 -0
  45. package/src/browser-electron/system-browser.ts +318 -0
  46. package/src/browser-electron/virtual-cursor.ts +152 -0
  47. package/src/index.ts +20 -2
  48. package/src/tool-browser/index.ts +121 -15
@@ -0,0 +1,171 @@
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 plus three filters: `domain` (hostname),
93
+ * `query` (substring of the URL or title) and `session` (the task that visited
94
+ * it). Filters compose, so "the pages THIS task opened on that host" is one call.
95
+ * @returns the matching visits, newest first.
96
+ */
97
+ list(options: { readonly limit?: number; readonly domain?: string; readonly query?: string; readonly session?: string } = {}): VisitedPage[] {
98
+ const all = this.readAll()
99
+ const cutoff = Date.now() - this.maxAgeMs
100
+ const domain = options.domain?.toLowerCase()
101
+ const query = options.query?.toLowerCase()
102
+ const session = options.session
103
+ const matches = all.filter(page => {
104
+ if (page.at < cutoff) return false
105
+ if (session !== undefined && page.session !== session) return false
106
+ if (query !== undefined) {
107
+ const haystack = `${page.url}\n${page.title ?? ''}`.toLowerCase()
108
+ if (!haystack.includes(query)) return false
109
+ }
110
+ if (domain === undefined) return true
111
+ try {
112
+ return new URL(page.url).hostname.toLowerCase().includes(domain)
113
+ } catch {
114
+ return page.url.toLowerCase().includes(domain)
115
+ }
116
+ })
117
+ matches.reverse()
118
+ return options.limit === undefined ? matches : matches.slice(0, Math.max(0, options.limit))
119
+ }
120
+
121
+ /**
122
+ * Enforce retention: drop entries past the age limit, then the oldest beyond
123
+ * the count cap. Cheap no-op until the file grows past the cap, so callers can
124
+ * run it after every append.
125
+ */
126
+ prune(): void {
127
+ const all = this.readAll()
128
+ const cutoff = Date.now() - this.maxAgeMs
129
+ const fresh = all.filter(page => page.at >= cutoff)
130
+ const kept = fresh.length > this.maxEntries ? fresh.slice(fresh.length - this.maxEntries) : fresh
131
+ // Only rewrite when something actually fell off, and only once the excess
132
+ // is worth a full rewrite.
133
+ if (kept.length === all.length) return
134
+ if (all.length - kept.length < PRUNE_SLACK && fresh.length <= this.maxEntries) return
135
+ try {
136
+ const body = kept.map(page => `${JSON.stringify(page)}\n`).join('')
137
+ writeFileSync(this.file, body)
138
+ } catch {
139
+ // Best-effort: an unpruned file still reads correctly.
140
+ }
141
+ }
142
+
143
+ /** Parse the whole file, skipping blank and damaged lines. */
144
+ private readAll(): VisitedPage[] {
145
+ let raw: string
146
+ try {
147
+ raw = readFileSync(this.file, 'utf8')
148
+ } catch {
149
+ return []
150
+ }
151
+ const pages: VisitedPage[] = []
152
+ for (const line of raw.split('\n')) {
153
+ const trimmed = line.trim()
154
+ if (trimmed === '') continue
155
+ try {
156
+ const parsed = JSON.parse(trimmed) as Partial<VisitedPage>
157
+ if (typeof parsed.at !== 'number' || typeof parsed.url !== 'string') continue
158
+ pages.push({
159
+ at: parsed.at,
160
+ url: parsed.url,
161
+ ...typeof parsed.title === 'string' && parsed.title !== '' ? { title: parsed.title } : {},
162
+ ...typeof parsed.session === 'string' && parsed.session !== '' ? { session: parsed.session } : {},
163
+ })
164
+ } catch {
165
+ // A damaged line (typically a truncated tail) must not hide the rest.
166
+ continue
167
+ }
168
+ }
169
+ return pages
170
+ }
171
+ }
@@ -389,7 +389,32 @@ function windowFor(windowId: string | undefined): HostWindow {
389
389
  const existing = windows.get(key)
390
390
  if (existing !== undefined) return existing
391
391
  const w = new BrowserWindow({ width: 1400, height: 900, show: true, title: 'dsh-browser' })
392
- 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
+ })
393
418
  const win: HostWindow = { windowId: key, window: w, toolbarView: undefined, views: new Map(), visibleViewId: undefined, lastFocusedViewId: undefined, closeTimer: undefined }
394
419
  // Keep every view (and the toolbar) filling the window as the human
395
420
  // resizes it; otherwise pages stay at their original size and break.