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.
- package/README.en.md +91 -3
- package/README.md +98 -4
- package/client.js +334 -0
- package/cordis.patch.yml +24 -5
- package/desktop-bridge/install.mjs +98 -0
- package/desktop-bridge/plugin-browser-bridge.js +385 -0
- 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 +19 -0
- package/lib/browser/types.d.ts +33 -0
- package/lib/browser-electron/bridge-connection.d.ts +58 -0
- package/lib/browser-electron/bridge-connection.js +133 -0
- package/lib/browser-electron/desktop-bridge-host.d.ts +124 -0
- package/lib/browser-electron/desktop-bridge-host.js +297 -0
- package/lib/browser-electron/entry.js +195 -11
- package/lib/browser-electron/history-store.d.ts +75 -0
- package/lib/browser-electron/history-store.js +162 -0
- package/lib/browser-electron/host-main.js +28 -1
- package/lib/browser-electron/provider.d.ts +122 -1
- package/lib/browser-electron/provider.js +233 -21
- package/lib/browser-electron/remote-host.d.ts +17 -1
- package/lib/browser-electron/remote-host.js +81 -9
- package/lib/browser-electron/settings-store.d.ts +92 -0
- package/lib/browser-electron/settings-store.js +183 -0
- package/lib/browser-electron/system-browser.d.ts +100 -0
- package/lib/browser-electron/system-browser.js +322 -0
- package/lib/browser-electron/virtual-cursor.d.ts +67 -0
- package/lib/browser-electron/virtual-cursor.js +141 -0
- package/lib/index.d.ts +16 -2
- package/lib/index.js +18 -2
- package/lib/tool-browser/index.js +122 -16
- package/package.json +14 -1
- package/src/browser/runtime.ts +22 -0
- package/src/browser/types.ts +31 -0
- package/src/browser-electron/bridge-connection.ts +145 -0
- package/src/browser-electron/desktop-bridge-host.ts +305 -0
- package/src/browser-electron/entry.ts +217 -11
- package/src/browser-electron/history-store.ts +171 -0
- package/src/browser-electron/host-main.ts +26 -1
- package/src/browser-electron/provider.ts +284 -21
- package/src/browser-electron/remote-host.ts +84 -7
- package/src/browser-electron/settings-store.ts +227 -0
- package/src/browser-electron/system-browser.ts +318 -0
- package/src/browser-electron/virtual-cursor.ts +152 -0
- package/src/index.ts +20 -2
- 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', () => {
|
|
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.
|