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
|
@@ -11,7 +11,13 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import z from '@deepseek-ai/schemastery';
|
|
13
13
|
import { ElectronBrowserProvider } from './provider.js';
|
|
14
|
+
import { randomUUID } from 'node:crypto';
|
|
15
|
+
import { homedir } from 'node:os';
|
|
16
|
+
import { join } from 'node:path';
|
|
14
17
|
import { defaultHostMainPath, RemoteElectronViewHost } from './remote-host.js';
|
|
18
|
+
import { DesktopBridgeViewHost } from './desktop-bridge-host.js';
|
|
19
|
+
import { detectBrowser, SystemBrowserViewHost } from './system-browser.js';
|
|
20
|
+
import { SettingsStore } from './settings-store.js';
|
|
15
21
|
export { ELECTRON_BROWSER_PROVIDER_ID, ElectronBrowserProvider, } from './provider.js';
|
|
16
22
|
export { RemoteElectronViewHost, defaultHostMainPath } from './remote-host.js';
|
|
17
23
|
/** Cordis plugin name used by loader diagnostics. */
|
|
@@ -28,23 +34,201 @@ export const Config = z.object({
|
|
|
28
34
|
});
|
|
29
35
|
/** Register the Electron browser provider with `ctx.browser`. */
|
|
30
36
|
export function apply(ctx, config) {
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
// row would otherwise collide with the still-registered provider
|
|
37
|
-
// (BROWSER_DUPLICATE_PROVIDER) or leave a stale provider behind.
|
|
38
|
-
const unregister = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(host, {
|
|
37
|
+
// One settings document per plugin instance: the settings panel writes it, the
|
|
38
|
+
// provider reads it live, and both ends agree on the same file.
|
|
39
|
+
const settings = new SettingsStore();
|
|
40
|
+
installSettingsRoute(ctx, settings);
|
|
41
|
+
const providerConfig = {
|
|
39
42
|
httpOnly: config.httpOnly,
|
|
40
43
|
downloadDir: config.downloadDir,
|
|
41
44
|
snapshotMaxElements: config.snapshotMaxElements,
|
|
42
45
|
contentMaxChars: config.contentMaxChars,
|
|
43
|
-
|
|
46
|
+
settings: () => settings.get(),
|
|
47
|
+
};
|
|
48
|
+
// A host supplied by the composition (the desktop shell's own seam) wins and is
|
|
49
|
+
// synchronous, so registration stays synchronous on that path.
|
|
50
|
+
if (config.viewHost !== undefined) {
|
|
51
|
+
const external = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(config.viewHost, providerConfig));
|
|
52
|
+
ctx.effect(() => () => { external(); });
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
// Otherwise start self-hosted — a working browser from the first call, on every
|
|
56
|
+
// surface — and upgrade to the desktop sidebar if this machine has one. The
|
|
57
|
+
// upgrade is deliberately a swap of the whole provider: the two carriers own
|
|
58
|
+
// different lifetimes (a spawned child vs. the shell's view), and pretending
|
|
59
|
+
// otherwise would leave a stray window behind.
|
|
60
|
+
const selfHosted = new RemoteElectronViewHost(defaultHostMainPath());
|
|
61
|
+
let unregister = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(selfHosted, providerConfig));
|
|
62
|
+
let upgraded = false;
|
|
44
63
|
ctx.effect(() => () => {
|
|
45
64
|
unregister();
|
|
46
|
-
|
|
47
|
-
|
|
65
|
+
selfHosted.dispose();
|
|
66
|
+
});
|
|
67
|
+
/**
|
|
68
|
+
* Swap the provider to another carrier, releasing the previous one.
|
|
69
|
+
* @param host - the carrier to adopt.
|
|
70
|
+
* @param note - a one-line description for the log.
|
|
71
|
+
*/
|
|
72
|
+
const adopt = (host, note) => {
|
|
73
|
+
if (upgraded) {
|
|
74
|
+
host.dispose?.();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
upgraded = true;
|
|
78
|
+
try {
|
|
79
|
+
unregister();
|
|
80
|
+
unregister = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(host, providerConfig));
|
|
81
|
+
selfHosted.dispose();
|
|
82
|
+
ctx.logger?.info?.(`dsh-builtin-browser: ${note}`);
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
// Never leave the surface without a provider.
|
|
86
|
+
unregister = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(new RemoteElectronViewHost(defaultHostMainPath()), providerConfig));
|
|
87
|
+
ctx.logger?.warn?.(`dsh-builtin-browser: could not adopt ${note} (${String(error)})`);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
// A browser the user explicitly asked for outranks every automatic choice: the
|
|
91
|
+
// whole point of the setting is that they know which browser they want. It is
|
|
92
|
+
// launched with a plugin-owned profile, so their own windows are untouched.
|
|
93
|
+
void (async () => {
|
|
94
|
+
const channel = settings.get().browser.channel;
|
|
95
|
+
if (channel === 'bundled')
|
|
96
|
+
return;
|
|
97
|
+
const detected = detectBrowser(channel);
|
|
98
|
+
if (detected === undefined) {
|
|
99
|
+
ctx.logger?.warn?.(`dsh-builtin-browser: ${channel} was requested but no installation was found; using the bundled browser`);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const home = process.env.DSH_HOME ?? homedir();
|
|
103
|
+
// Login state lives in the browser profile, so the cookies setting decides where
|
|
104
|
+
// that profile goes: a stable directory keeps the user signed in across restarts
|
|
105
|
+
// (the point of using their own browser), while turning persistence off gets a
|
|
106
|
+
// throwaway directory that is removed when the browser is released.
|
|
107
|
+
const persist = settings.get().cookies.persist;
|
|
108
|
+
const profileDir = persist
|
|
109
|
+
? join(home, 'dsh-builtin-browser-host', `${detected.kind}-profile`)
|
|
110
|
+
: join(home, 'dsh-builtin-browser-host', `${detected.kind}-profile-ephemeral-${randomUUID()}`);
|
|
111
|
+
const host = await SystemBrowserViewHost.launch(detected, profileDir, [], persist ? undefined : profileDir);
|
|
112
|
+
if (host === undefined) {
|
|
113
|
+
ctx.logger?.warn?.(`dsh-builtin-browser: ${detected.kind} did not start; using the bundled browser`);
|
|
114
|
+
return;
|
|
48
115
|
}
|
|
116
|
+
adopt(host, `driving the installed ${detected.kind} (${detected.path}${persist ? '' : ', ephemeral profile'})`);
|
|
117
|
+
})().catch(() => { });
|
|
118
|
+
void DesktopBridgeViewHost.discover().then(sidebar => {
|
|
119
|
+
// No bridge (plain `dsh web`, an older desktop build, or a shell that already
|
|
120
|
+
// exited): keep self-hosting, which is what every surface has always done.
|
|
121
|
+
if (sidebar === undefined)
|
|
122
|
+
return;
|
|
123
|
+
adopt(sidebar, 'driving the desktop sidebar browser');
|
|
124
|
+
}).catch(() => { });
|
|
125
|
+
}
|
|
126
|
+
/** Route the settings panel reads and writes. */
|
|
127
|
+
const SETTINGS_ROUTE = '/dsh-builtin-browser/settings';
|
|
128
|
+
/**
|
|
129
|
+
* Expose the settings document over the host's web server, which is how the
|
|
130
|
+
* settings panel reads and writes it. A host without that service (headless /
|
|
131
|
+
* CLI surfaces) simply skips this: the panel is then unreachable and the file
|
|
132
|
+
* stays hand-editable — never a plugin-startup failure.
|
|
133
|
+
* @param ctx - the plugin context.
|
|
134
|
+
* @param settings - the settings document to expose.
|
|
135
|
+
*/
|
|
136
|
+
function installSettingsRoute(ctx, settings) {
|
|
137
|
+
const inject = ctx.inject;
|
|
138
|
+
if (typeof inject !== 'function')
|
|
139
|
+
return;
|
|
140
|
+
try {
|
|
141
|
+
inject(['webServer'], (host) => {
|
|
142
|
+
host.effect(() => host.webServer.register({
|
|
143
|
+
kind: 'exact',
|
|
144
|
+
path: SETTINGS_ROUTE,
|
|
145
|
+
handler: (request, response) => handleSettingsRequest(request, response, settings),
|
|
146
|
+
}, 'dsh-builtin-browser: settings'));
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
// No web server on this surface: the settings file remains the interface.
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Same-origin guard. Settings are machine-local configuration, so a page loaded
|
|
155
|
+
* elsewhere must not be able to read or rewrite them.
|
|
156
|
+
* @param request - the incoming request.
|
|
157
|
+
* @returns whether the request may touch the settings document.
|
|
158
|
+
*/
|
|
159
|
+
function sameOrigin(request) {
|
|
160
|
+
const site = String(request.headers['sec-fetch-site'] ?? '').toLowerCase();
|
|
161
|
+
if (site === 'cross-site')
|
|
162
|
+
return false;
|
|
163
|
+
const origin = String(request.headers.origin ?? '').trim();
|
|
164
|
+
if (origin === 'null')
|
|
165
|
+
return false;
|
|
166
|
+
if (origin === '')
|
|
167
|
+
return true;
|
|
168
|
+
try {
|
|
169
|
+
return new URL(origin).host.toLowerCase() === String(request.headers.host ?? '').trim().toLowerCase();
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* One settings round-trip: `GET` reads the document, `PUT`/`POST` merges a
|
|
177
|
+
* partial patch into it.
|
|
178
|
+
* @param request - the incoming request.
|
|
179
|
+
* @param response - the response to write.
|
|
180
|
+
* @param settings - the settings document.
|
|
181
|
+
*/
|
|
182
|
+
async function handleSettingsRequest(request, response, settings) {
|
|
183
|
+
const json = (status, body) => {
|
|
184
|
+
response.writeHead(status, {
|
|
185
|
+
'content-type': 'application/json; charset=utf-8',
|
|
186
|
+
'cache-control': 'no-store',
|
|
187
|
+
});
|
|
188
|
+
response.end(JSON.stringify(body));
|
|
189
|
+
};
|
|
190
|
+
if (!sameOrigin(request)) {
|
|
191
|
+
json(403, { ok: false, error: 'cross-origin request refused' });
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const method = request.method ?? 'GET';
|
|
195
|
+
if (method === 'GET') {
|
|
196
|
+
json(200, { ok: true, settings: settings.get(), path: settings.path() });
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (method !== 'PUT' && method !== 'POST') {
|
|
200
|
+
response.writeHead(405, { allow: 'GET, PUT' });
|
|
201
|
+
response.end();
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
try {
|
|
205
|
+
const body = await readBody(request);
|
|
206
|
+
const patch = body.trim() === '' ? {} : JSON.parse(body);
|
|
207
|
+
json(200, { ok: true, settings: settings.update(patch) });
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
json(400, { ok: false, error: `invalid settings patch: ${String(error)}` });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Read a request body with a hard cap (the settings patch is tiny, and an
|
|
215
|
+
* unbounded read would let any same-origin caller exhaust memory).
|
|
216
|
+
* @param request - the incoming request.
|
|
217
|
+
* @param limit - maximum accepted characters.
|
|
218
|
+
* @returns the body text.
|
|
219
|
+
*/
|
|
220
|
+
function readBody(request, limit = 64 * 1024) {
|
|
221
|
+
return new Promise((resolve, reject) => {
|
|
222
|
+
let text = '';
|
|
223
|
+
request.setEncoding('utf8');
|
|
224
|
+
request.on('data', chunk => {
|
|
225
|
+
text += String(chunk);
|
|
226
|
+
if (text.length > limit) {
|
|
227
|
+
request.destroy();
|
|
228
|
+
reject(new Error('settings patch too large'));
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
request.on('end', () => resolve(text));
|
|
232
|
+
request.on('error', reject);
|
|
49
233
|
});
|
|
50
234
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
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
|
+
import type { VisitedPage } from '../browser/types.js';
|
|
16
|
+
export type { VisitedPage };
|
|
17
|
+
/** Retention limits; whichever is hit first starts trimming. */
|
|
18
|
+
export interface HistoryLimits {
|
|
19
|
+
/** Maximum retained entries. Default 5000. */
|
|
20
|
+
readonly maxEntries?: number;
|
|
21
|
+
/** Maximum age in days. Default 90. */
|
|
22
|
+
readonly maxAgeDays?: number;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Absolute path of the browsing-history file, beside the browser profile so the
|
|
26
|
+
* record shares the login state's persistence rules.
|
|
27
|
+
* @returns the history file path.
|
|
28
|
+
*/
|
|
29
|
+
export declare function visitedHistoryPath(): string;
|
|
30
|
+
/**
|
|
31
|
+
* Append-only browsing history with bounded retention. Every method tolerates a
|
|
32
|
+
* missing or damaged file: recording a visit must never fail a navigation, and
|
|
33
|
+
* a corrupted line must never hide the rest of the history.
|
|
34
|
+
*/
|
|
35
|
+
export declare class HistoryStore {
|
|
36
|
+
private readonly file;
|
|
37
|
+
private readonly maxEntries;
|
|
38
|
+
private readonly maxAgeMs;
|
|
39
|
+
/**
|
|
40
|
+
* @param file - absolute path of the JSONL history file.
|
|
41
|
+
* @param limits - retention overrides (entry count and age).
|
|
42
|
+
*/
|
|
43
|
+
constructor(file?: string, limits?: HistoryLimits);
|
|
44
|
+
/** Whether the history file exists yet (diagnostics and tests). */
|
|
45
|
+
exists(): boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Record one visit. Failures are swallowed by design: history is a
|
|
48
|
+
* convenience, and losing an entry must never break the tool call that
|
|
49
|
+
* produced it.
|
|
50
|
+
* @param page - the visited page.
|
|
51
|
+
*/
|
|
52
|
+
append(page: VisitedPage): void;
|
|
53
|
+
/**
|
|
54
|
+
* Read visits, newest first. Unparseable lines are skipped; entries older
|
|
55
|
+
* than the age limit are filtered out but left on disk until the next prune.
|
|
56
|
+
* @param options - optional result cap plus three filters: `domain` (hostname),
|
|
57
|
+
* `query` (substring of the URL or title) and `session` (the task that visited
|
|
58
|
+
* it). Filters compose, so "the pages THIS task opened on that host" is one call.
|
|
59
|
+
* @returns the matching visits, newest first.
|
|
60
|
+
*/
|
|
61
|
+
list(options?: {
|
|
62
|
+
readonly limit?: number;
|
|
63
|
+
readonly domain?: string;
|
|
64
|
+
readonly query?: string;
|
|
65
|
+
readonly session?: string;
|
|
66
|
+
}): VisitedPage[];
|
|
67
|
+
/**
|
|
68
|
+
* Enforce retention: drop entries past the age limit, then the oldest beyond
|
|
69
|
+
* the count cap. Cheap no-op until the file grows past the cap, so callers can
|
|
70
|
+
* run it after every append.
|
|
71
|
+
*/
|
|
72
|
+
prune(): void;
|
|
73
|
+
/** Parse the whole file, skipping blank and damaged lines. */
|
|
74
|
+
private readAll;
|
|
75
|
+
}
|
|
@@ -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
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { homedir } from 'node:os';
|
|
17
|
+
import { dirname, join } from 'node:path';
|
|
18
|
+
/** Default entry cap — mirrors the documented retention rule. */
|
|
19
|
+
const DEFAULT_MAX_ENTRIES = 5_000;
|
|
20
|
+
/** Default age cap in days. */
|
|
21
|
+
const DEFAULT_MAX_AGE_DAYS = 90;
|
|
22
|
+
/** Rewrite the file only once it has grown this far past the cap. */
|
|
23
|
+
const PRUNE_SLACK = 500;
|
|
24
|
+
/**
|
|
25
|
+
* Absolute path of the browsing-history file, beside the browser profile so the
|
|
26
|
+
* record shares the login state's persistence rules.
|
|
27
|
+
* @returns the history file path.
|
|
28
|
+
*/
|
|
29
|
+
export function visitedHistoryPath() {
|
|
30
|
+
const home = process.env.DSH_HOME ?? join(homedir(), '.dsh');
|
|
31
|
+
return join(home, 'dsh-builtin-browser-host', 'history.jsonl');
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Append-only browsing history with bounded retention. Every method tolerates a
|
|
35
|
+
* missing or damaged file: recording a visit must never fail a navigation, and
|
|
36
|
+
* a corrupted line must never hide the rest of the history.
|
|
37
|
+
*/
|
|
38
|
+
export class HistoryStore {
|
|
39
|
+
file;
|
|
40
|
+
maxEntries;
|
|
41
|
+
maxAgeMs;
|
|
42
|
+
/**
|
|
43
|
+
* @param file - absolute path of the JSONL history file.
|
|
44
|
+
* @param limits - retention overrides (entry count and age).
|
|
45
|
+
*/
|
|
46
|
+
constructor(file = visitedHistoryPath(), limits = {}) {
|
|
47
|
+
this.file = file;
|
|
48
|
+
this.maxEntries = limits.maxEntries ?? DEFAULT_MAX_ENTRIES;
|
|
49
|
+
this.maxAgeMs = (limits.maxAgeDays ?? DEFAULT_MAX_AGE_DAYS) * 24 * 60 * 60 * 1000;
|
|
50
|
+
}
|
|
51
|
+
/** Whether the history file exists yet (diagnostics and tests). */
|
|
52
|
+
exists() {
|
|
53
|
+
return existsSync(this.file);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Record one visit. Failures are swallowed by design: history is a
|
|
57
|
+
* convenience, and losing an entry must never break the tool call that
|
|
58
|
+
* produced it.
|
|
59
|
+
* @param page - the visited page.
|
|
60
|
+
*/
|
|
61
|
+
append(page) {
|
|
62
|
+
try {
|
|
63
|
+
mkdirSync(dirname(this.file), { recursive: true });
|
|
64
|
+
appendFileSync(this.file, `${JSON.stringify(page)}\n`);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// Best-effort: history is not worth failing a navigation over.
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Read visits, newest first. Unparseable lines are skipped; entries older
|
|
72
|
+
* than the age limit are filtered out but left on disk until the next prune.
|
|
73
|
+
* @param options - optional result cap plus three filters: `domain` (hostname),
|
|
74
|
+
* `query` (substring of the URL or title) and `session` (the task that visited
|
|
75
|
+
* it). Filters compose, so "the pages THIS task opened on that host" is one call.
|
|
76
|
+
* @returns the matching visits, newest first.
|
|
77
|
+
*/
|
|
78
|
+
list(options = {}) {
|
|
79
|
+
const all = this.readAll();
|
|
80
|
+
const cutoff = Date.now() - this.maxAgeMs;
|
|
81
|
+
const domain = options.domain?.toLowerCase();
|
|
82
|
+
const query = options.query?.toLowerCase();
|
|
83
|
+
const session = options.session;
|
|
84
|
+
const matches = all.filter(page => {
|
|
85
|
+
if (page.at < cutoff)
|
|
86
|
+
return false;
|
|
87
|
+
if (session !== undefined && page.session !== session)
|
|
88
|
+
return false;
|
|
89
|
+
if (query !== undefined) {
|
|
90
|
+
const haystack = `${page.url}\n${page.title ?? ''}`.toLowerCase();
|
|
91
|
+
if (!haystack.includes(query))
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
if (domain === undefined)
|
|
95
|
+
return true;
|
|
96
|
+
try {
|
|
97
|
+
return new URL(page.url).hostname.toLowerCase().includes(domain);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return page.url.toLowerCase().includes(domain);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
matches.reverse();
|
|
104
|
+
return options.limit === undefined ? matches : matches.slice(0, Math.max(0, options.limit));
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Enforce retention: drop entries past the age limit, then the oldest beyond
|
|
108
|
+
* the count cap. Cheap no-op until the file grows past the cap, so callers can
|
|
109
|
+
* run it after every append.
|
|
110
|
+
*/
|
|
111
|
+
prune() {
|
|
112
|
+
const all = this.readAll();
|
|
113
|
+
const cutoff = Date.now() - this.maxAgeMs;
|
|
114
|
+
const fresh = all.filter(page => page.at >= cutoff);
|
|
115
|
+
const kept = fresh.length > this.maxEntries ? fresh.slice(fresh.length - this.maxEntries) : fresh;
|
|
116
|
+
// Only rewrite when something actually fell off, and only once the excess
|
|
117
|
+
// is worth a full rewrite.
|
|
118
|
+
if (kept.length === all.length)
|
|
119
|
+
return;
|
|
120
|
+
if (all.length - kept.length < PRUNE_SLACK && fresh.length <= this.maxEntries)
|
|
121
|
+
return;
|
|
122
|
+
try {
|
|
123
|
+
const body = kept.map(page => `${JSON.stringify(page)}\n`).join('');
|
|
124
|
+
writeFileSync(this.file, body);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
// Best-effort: an unpruned file still reads correctly.
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/** Parse the whole file, skipping blank and damaged lines. */
|
|
131
|
+
readAll() {
|
|
132
|
+
let raw;
|
|
133
|
+
try {
|
|
134
|
+
raw = readFileSync(this.file, 'utf8');
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
139
|
+
const pages = [];
|
|
140
|
+
for (const line of raw.split('\n')) {
|
|
141
|
+
const trimmed = line.trim();
|
|
142
|
+
if (trimmed === '')
|
|
143
|
+
continue;
|
|
144
|
+
try {
|
|
145
|
+
const parsed = JSON.parse(trimmed);
|
|
146
|
+
if (typeof parsed.at !== 'number' || typeof parsed.url !== 'string')
|
|
147
|
+
continue;
|
|
148
|
+
pages.push({
|
|
149
|
+
at: parsed.at,
|
|
150
|
+
url: parsed.url,
|
|
151
|
+
...typeof parsed.title === 'string' && parsed.title !== '' ? { title: parsed.title } : {},
|
|
152
|
+
...typeof parsed.session === 'string' && parsed.session !== '' ? { session: parsed.session } : {},
|
|
153
|
+
});
|
|
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
|
+
}
|
|
@@ -338,7 +338,34 @@ function windowFor(windowId) {
|
|
|
338
338
|
if (existing !== undefined)
|
|
339
339
|
return existing;
|
|
340
340
|
const w = new BrowserWindow({ width: 1400, height: 900, show: true, title: 'dsh-browser' });
|
|
341
|
-
w.on('closed', () => {
|
|
341
|
+
w.on('closed', () => {
|
|
342
|
+
const closing = windows.get(key);
|
|
343
|
+
windows.delete(key);
|
|
344
|
+
// A BrowserWindow does not destroy its child views: their webContents (the
|
|
345
|
+
// renderer process, its timers and audio) outlive the window unless closed
|
|
346
|
+
// explicitly, so release them here instead of leaking a renderer per window.
|
|
347
|
+
if (closing !== undefined) {
|
|
348
|
+
for (const view of closing.views.values()) {
|
|
349
|
+
try {
|
|
350
|
+
view.webContentsView.webContents.close();
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
// Already destroyed along with the window.
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
closing.views.clear();
|
|
357
|
+
}
|
|
358
|
+
// The interface is gone for good. Report it, so the plugin ends that session
|
|
359
|
+
// instead of holding one whose window the human already closed. Browsing
|
|
360
|
+
// history and login state live on disk, so the next open starts a clean
|
|
361
|
+
// session rather than resurrecting this one.
|
|
362
|
+
try {
|
|
363
|
+
rpcSocket?.write(JSON.stringify({ id: 0, op: 'viewClosed', windowId: key }) + '\n');
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
// Socket already gone (parent shutting down): nothing left to report to.
|
|
367
|
+
}
|
|
368
|
+
});
|
|
342
369
|
const win = { windowId: key, window: w, toolbarView: undefined, views: new Map(), visibleViewId: undefined, lastFocusedViewId: undefined, closeTimer: undefined };
|
|
343
370
|
// Keep every view (and the toolbar) filling the window as the human
|
|
344
371
|
// resizes it; otherwise pages stay at their original size and break.
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* shell that owns the `BrowserWindow`.
|
|
7
7
|
* @module dsh-browser/browser-electron
|
|
8
8
|
*/
|
|
9
|
+
import { type VisitedPage } from './history-store.js';
|
|
10
|
+
import type { BrowserSettings } from './settings-store.js';
|
|
9
11
|
import type { BrowserA11yRequest, BrowserA11yResult, BrowserChallenge, BrowserCheckRequest, BrowserClearRequest, BrowserClickRequest, BrowserContentRequest, BrowserContentResult, BrowserElementTarget, BrowserExecuteRequest, BrowserExecuteResult, BrowserFillRequest, BrowserFillResult, BrowserGetValueRequest, BrowserGetValueResult, BrowserHistoryEntry, BrowserOpenRequest, BrowserProvider, BrowserScrapeRequest, BrowserScrapeResult, BrowserScreenshotRequest, BrowserSelectRequest, BrowserSelectResult, BrowserSessionId, BrowserSetValueRequest, BrowserSetValueResult, BrowserSnapshotResult, BrowserTab, BrowserTypeRequest, BrowserWaitRequest, BrowserWaitResult, BrowserScrollRequest, BrowserKeyRequest, ExportedCookie } from '../browser/types.js';
|
|
10
12
|
/** Stable provider id registered with `ctx.browser`. */
|
|
11
13
|
export declare const ELECTRON_BROWSER_PROVIDER_ID = "electron";
|
|
@@ -78,6 +80,38 @@ export interface ElectronBrowserViewHost {
|
|
|
78
80
|
* @param handler - called for every user action; must not throw.
|
|
79
81
|
*/
|
|
80
82
|
onUserAction?(handler: (action: BrowserUserAction) => void): void;
|
|
83
|
+
/**
|
|
84
|
+
* Optional: receive notice that the human closed a browser window. Closing the
|
|
85
|
+
* interface ends the session it showed — the next call opens a clean one —
|
|
86
|
+
* while browsing history and login state survive on disk.
|
|
87
|
+
* @param handler - called with the group (session) key; must not throw.
|
|
88
|
+
*/
|
|
89
|
+
onViewClosed?(handler: (windowId: string) => void): void;
|
|
90
|
+
/**
|
|
91
|
+
* Release the service's page(s) without ending the plugin's own lifetime.
|
|
92
|
+
*
|
|
93
|
+
* Only carriers that outlive a browser session need this. A self-hosted window
|
|
94
|
+
* dies with its view, but the desktop shell's sidebar keeps running no matter
|
|
95
|
+
* what the plugin does — so "release the browser when the session ends"
|
|
96
|
+
* (settings: ui.closeWithSession) has to be asked for explicitly there.
|
|
97
|
+
* @returns a promise that settles once the release was attempted.
|
|
98
|
+
*/
|
|
99
|
+
releasePage?(): Promise<void>;
|
|
100
|
+
/**
|
|
101
|
+
* Fold the carrier's presentation away while keeping the page alive.
|
|
102
|
+
*
|
|
103
|
+
* The desktop sidebar keeps running regardless of the plugin, so "do not take
|
|
104
|
+
* over the screen" (settings: ui.autoExpandOnce = false) can only be honoured by
|
|
105
|
+
* asking the shell to fold it. A self-hosted window has no such state and simply
|
|
106
|
+
* does not implement this.
|
|
107
|
+
* @returns a promise that settles once the fold was attempted.
|
|
108
|
+
*/
|
|
109
|
+
collapse?(): Promise<void>;
|
|
110
|
+
/**
|
|
111
|
+
* Release everything the carrier owns (a spawned browser, a socket, a child).
|
|
112
|
+
* Called when the plugin stops using it, never while it is still in service.
|
|
113
|
+
*/
|
|
114
|
+
dispose?(): void;
|
|
81
115
|
}
|
|
82
116
|
/**
|
|
83
117
|
* A user-initiated browser action from the host's own UI (toolbar). The
|
|
@@ -137,6 +171,28 @@ export interface ElectronViewHandle {
|
|
|
137
171
|
export interface ElectronBrowserProviderConfig {
|
|
138
172
|
/** Allow navigation only to HTTP(S) URLs; reject anything else. Default true. */
|
|
139
173
|
readonly httpOnly?: boolean;
|
|
174
|
+
/**
|
|
175
|
+
* Persistent browsing history (visited pages), independent of session
|
|
176
|
+
* lifetime. `enabled: false` keeps the browser working but records nothing;
|
|
177
|
+
* the retained limits ride along so a user's choice from the settings panel
|
|
178
|
+
* reaches the store without a second configuration path.
|
|
179
|
+
*/
|
|
180
|
+
readonly history?: {
|
|
181
|
+
readonly enabled?: boolean;
|
|
182
|
+
readonly maxEntries?: number;
|
|
183
|
+
readonly maxAgeDays?: number;
|
|
184
|
+
/**
|
|
185
|
+
* Explicit history file path. Default: beside the browser profile
|
|
186
|
+
* (`$DSH_HOME/dsh-builtin-browser-host/history.jsonl`).
|
|
187
|
+
*/
|
|
188
|
+
readonly file?: string;
|
|
189
|
+
};
|
|
190
|
+
/**
|
|
191
|
+
* Live settings source (the settings panel's document). When present it wins
|
|
192
|
+
* over the static `history` config, so a switch flipped in the UI takes effect
|
|
193
|
+
* without restarting DSH.
|
|
194
|
+
*/
|
|
195
|
+
readonly settings?: () => BrowserSettings;
|
|
140
196
|
/** Maximum snapshot elements before truncation. Default 60. */
|
|
141
197
|
readonly snapshotMaxElements?: number;
|
|
142
198
|
/** Maximum content characters before truncation when no maxChars is given. Default 100_000. */
|
|
@@ -208,6 +264,13 @@ export declare class ElectronBrowserProvider implements BrowserProvider {
|
|
|
208
264
|
private readonly snapshotMaxElements;
|
|
209
265
|
private readonly contentMaxChars;
|
|
210
266
|
private readonly downloadDir;
|
|
267
|
+
/**
|
|
268
|
+
* Persistent browsing history, or `undefined` when the user turned it off.
|
|
269
|
+
* Sessions come and go; this record outlives all of them.
|
|
270
|
+
*/
|
|
271
|
+
private readonly historyStore;
|
|
272
|
+
/** Live settings source; absent when nothing owns a settings document. */
|
|
273
|
+
private readonly settingsSource;
|
|
211
274
|
constructor(host: ElectronBrowserViewHost, config?: ElectronBrowserProviderConfig);
|
|
212
275
|
/**
|
|
213
276
|
* Usable when the host says it can back views (the self-hosted host probes
|
|
@@ -447,6 +510,39 @@ export declare class ElectronBrowserProvider implements BrowserProvider {
|
|
|
447
510
|
private record;
|
|
448
511
|
/** Return the session's chronological operation log (newest last). */
|
|
449
512
|
history(session: BrowserSessionId): Promise<readonly BrowserHistoryEntry[]>;
|
|
513
|
+
/**
|
|
514
|
+
* Record one page visit in the persistent browsing history. Fire-and-forget by
|
|
515
|
+
* design: a visit must never delay or fail the navigation that produced it,
|
|
516
|
+
* and the title is only worth reading once the document has settled.
|
|
517
|
+
* @param s - the session that drove the visit.
|
|
518
|
+
* @param handle - the view whose document just loaded.
|
|
519
|
+
*/
|
|
520
|
+
private recordVisit;
|
|
521
|
+
/**
|
|
522
|
+
* List persisted visits, newest first — what `browser_visited` reads back.
|
|
523
|
+
* @param options - result cap and an optional hostname filter.
|
|
524
|
+
* @returns the matching visits, or an empty list when history is disabled.
|
|
525
|
+
*/
|
|
526
|
+
visited(options?: {
|
|
527
|
+
readonly limit?: number;
|
|
528
|
+
readonly domain?: string;
|
|
529
|
+
readonly query?: string;
|
|
530
|
+
readonly session?: string;
|
|
531
|
+
}): readonly VisitedPage[];
|
|
532
|
+
/**
|
|
533
|
+
* Show the synthetic pointer on a view, unless the user switched it off. The
|
|
534
|
+
* cursor is the "the agent has taken over this tab" signal, so it is painted
|
|
535
|
+
* for every operation that has a landing point — including DOM-level ones that
|
|
536
|
+
* move no real pointer.
|
|
537
|
+
* @param handle - the view to paint into.
|
|
538
|
+
* @param x - viewport x in CSS pixels.
|
|
539
|
+
* @param y - viewport y in CSS pixels.
|
|
540
|
+
* @param action - click pulses a ripple; move only relocates.
|
|
541
|
+
* @param label - short description of the operation, shown beside the pointer.
|
|
542
|
+
* @param force - paint even when the pointer is already there (a click ripple must
|
|
543
|
+
* always play; a bare move need not repeat).
|
|
544
|
+
*/
|
|
545
|
+
private showCursor;
|
|
450
546
|
/**
|
|
451
547
|
* Replay one recorded operation by sequence number. Navigate/click/type are
|
|
452
548
|
* re-issued against the current page; execute re-runs its script. The
|
|
@@ -465,6 +561,20 @@ export declare class ElectronBrowserProvider implements BrowserProvider {
|
|
|
465
561
|
private newTab;
|
|
466
562
|
/** Find a session's tab by its backing view id (toolbar actions carry view ids). */
|
|
467
563
|
private tabByViewId;
|
|
564
|
+
/**
|
|
565
|
+
* Whether a session is still live. The human closing a window ends its session
|
|
566
|
+
* (see {@link handleViewClosed}), so callers that cache ids must ask first.
|
|
567
|
+
* @param session - the session id to test.
|
|
568
|
+
*/
|
|
569
|
+
exists(session: BrowserSessionId): boolean;
|
|
570
|
+
/**
|
|
571
|
+
* The human closed a browser window: the session that window showed is over.
|
|
572
|
+
* Ending it here is what makes the next call a clean start rather than a
|
|
573
|
+
* resurrection of an invisible window. Browsing history and login state live
|
|
574
|
+
* on disk, so nothing the human cares about is lost with it.
|
|
575
|
+
* @param windowId - the group key the host reported, which is the session id.
|
|
576
|
+
*/
|
|
577
|
+
private handleViewClosed;
|
|
468
578
|
/**
|
|
469
579
|
* Route a user-initiated action from the host's UI into the session model.
|
|
470
580
|
* Fire-and-forget by design: a user action failing (e.g. an unreachable
|
|
@@ -472,7 +582,18 @@ export declare class ElectronBrowserProvider implements BrowserProvider {
|
|
|
472
582
|
* is reported to the host (toolbar) when the host supports it, else logged.
|
|
473
583
|
*/
|
|
474
584
|
private handleUserAction;
|
|
475
|
-
/**
|
|
585
|
+
/**
|
|
586
|
+
* Report a failed user action to the host UI (toolbar), when supported.
|
|
587
|
+
*
|
|
588
|
+
* Two properties matter here, because this runs inside the catch of an async
|
|
589
|
+
* handler (issue #16):
|
|
590
|
+
* - the receiver must be preserved. Reading the method off the host and calling
|
|
591
|
+
* it unbound runs the host's implementation with `this === undefined`, so its
|
|
592
|
+
* very first statement (`void this.ready()`) throws, the throw escapes the
|
|
593
|
+
* async catch as an unhandled rejection, and the whole DSH host exits;
|
|
594
|
+
* - this method must be incapable of throwing. Reporting a failed action is
|
|
595
|
+
* diagnostics: it can never be allowed to become the failure.
|
|
596
|
+
*/
|
|
476
597
|
private notifyUserActionError;
|
|
477
598
|
/** Ask the host to show the active tab's view, carrying the session label. */
|
|
478
599
|
private showActive;
|