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.
- package/README.en.md +50 -3
- package/README.md +51 -4
- package/client.js +298 -0
- package/cordis.patch.yml +24 -5
- 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 +28 -1
- package/lib/browser/types.d.ts +33 -0
- package/lib/browser-electron/entry.js +115 -0
- package/lib/browser-electron/history-store.d.ts +71 -0
- package/lib/browser-electron/history-store.js +151 -0
- package/lib/browser-electron/host-main.d.ts +1 -0
- package/lib/browser-electron/host-main.js +88 -1
- package/lib/browser-electron/provider.d.ts +105 -4
- package/lib/browser-electron/provider.js +226 -14
- package/lib/browser-electron/remote-host.d.ts +29 -6
- package/lib/browser-electron/remote-host.js +114 -24
- package/lib/browser-electron/settings-store.d.ts +87 -0
- package/lib/browser-electron/settings-store.js +160 -0
- package/lib/browser-electron/virtual-cursor.d.ts +42 -0
- package/lib/browser-electron/virtual-cursor.js +71 -0
- package/lib/index.d.ts +16 -2
- package/lib/index.js +18 -2
- package/lib/tool-browser/index.js +72 -2
- package/package.json +20 -6
- package/src/browser/runtime.ts +37 -1
- package/src/browser/types.ts +31 -0
- package/src/browser-electron/entry.ts +135 -0
- package/src/browser-electron/history-store.ts +162 -0
- package/src/browser-electron/host-main.ts +83 -1
- package/src/browser-electron/provider.ts +267 -19
- package/src/browser-electron/remote-host.ts +114 -21
- package/src/browser-electron/settings-store.ts +198 -0
- package/src/browser-electron/virtual-cursor.ts +82 -0
- package/src/index.ts +20 -2
- package/src/tool-browser/index.ts +71 -1
package/lib/browser/runtime.js
CHANGED
|
@@ -70,7 +70,15 @@ export class BrowserRuntime extends Service {
|
|
|
70
70
|
const usable = [...providers.values()].filter(provider => provider.available());
|
|
71
71
|
const [single] = usable;
|
|
72
72
|
if (single === undefined) {
|
|
73
|
-
|
|
73
|
+
// Two very different causes used to read identically here — nothing is
|
|
74
|
+
// registered, or something is registered but cannot run — and only the
|
|
75
|
+
// second one has a fix the user can apply without reinstalling.
|
|
76
|
+
const registered = [...providers.values()].map(provider => provider.id);
|
|
77
|
+
if (registered.length === 0) {
|
|
78
|
+
throw new BrowserError('no browser provider is registered — install a browser plugin (e.g. dsh-builtin-browser) and enable it in this profile', 'BROWSER_PROVIDER_UNAVAILABLE');
|
|
79
|
+
}
|
|
80
|
+
throw new BrowserError(`browser provider(s) ${registered.join(', ')} are registered but report themselves unavailable — `
|
|
81
|
+
+ 'for dsh-builtin-browser this means no spawnable Electron binary was found; run `npx install-electron` in the active profile or set ELECTRON_PATH', 'BROWSER_PROVIDER_UNAVAILABLE');
|
|
74
82
|
}
|
|
75
83
|
if (usable.length > 1) {
|
|
76
84
|
const ids = usable.map(provider => provider.id).join(', ');
|
|
@@ -194,6 +202,25 @@ export class BrowserRuntime extends Service {
|
|
|
194
202
|
async history(session) {
|
|
195
203
|
return this.resolveProvider().history(session);
|
|
196
204
|
}
|
|
205
|
+
/**
|
|
206
|
+
* Persisted visits, newest first — the record that outlives the browser
|
|
207
|
+
* process, as opposed to {@link history}'s session-scoped operation log.
|
|
208
|
+
* @param options - result cap and an optional hostname filter.
|
|
209
|
+
* @returns the retained visits (empty when history recording is disabled).
|
|
210
|
+
*/
|
|
211
|
+
visited(options) {
|
|
212
|
+
return this.resolveProvider().visited(options);
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Whether a session is still live. Sessions can end outside the tool layer
|
|
216
|
+
* (the human closed its window), so a cached id must be re-checked before
|
|
217
|
+
* reuse; a provider that does not implement the check counts as live.
|
|
218
|
+
* @param session - the session id to test.
|
|
219
|
+
*/
|
|
220
|
+
exists(session) {
|
|
221
|
+
const provider = this.resolveProvider();
|
|
222
|
+
return provider.exists === undefined ? true : provider.exists(session);
|
|
223
|
+
}
|
|
197
224
|
/** Replay one recorded operation by sequence number through the provider. */
|
|
198
225
|
async replay(session, seq) {
|
|
199
226
|
return this.resolveProvider().replay(session, seq);
|
package/lib/browser/types.d.ts
CHANGED
|
@@ -544,11 +544,44 @@ export interface BrowserProvider {
|
|
|
544
544
|
restoreAuth(session: BrowserSessionId, cookies: readonly ExportedCookie[]): Promise<number>;
|
|
545
545
|
/** Return the session's chronological operation log. */
|
|
546
546
|
history(session: BrowserSessionId): Promise<readonly BrowserHistoryEntry[]>;
|
|
547
|
+
/**
|
|
548
|
+
* Persisted visits, newest first: what survives the browser process, so a
|
|
549
|
+
* human or an Agent can find a page again after the interface was closed.
|
|
550
|
+
* @param options - result cap and an optional hostname filter.
|
|
551
|
+
* @returns the retained visits (empty when history is disabled).
|
|
552
|
+
*/
|
|
553
|
+
visited(options?: {
|
|
554
|
+
readonly limit?: number;
|
|
555
|
+
readonly domain?: string;
|
|
556
|
+
}): readonly VisitedPage[];
|
|
557
|
+
/**
|
|
558
|
+
* Optional: whether a session is still live. A session can end outside the
|
|
559
|
+
* tool layer — the human closed the browser window — so a cached id must be
|
|
560
|
+
* checked before it is reused. Providers that never end a session on their own
|
|
561
|
+
* can omit this, and callers treat absence as "still live".
|
|
562
|
+
* @param session - the session id to test.
|
|
563
|
+
*/
|
|
564
|
+
exists?(session: BrowserSessionId): boolean;
|
|
547
565
|
/** Replay one recorded operation by sequence number. */
|
|
548
566
|
replay(session: BrowserSessionId, seq: number): Promise<void>;
|
|
549
567
|
/** Close the session and destroy its backing surface. Idempotent. */
|
|
550
568
|
close(session: BrowserSessionId): Promise<void>;
|
|
551
569
|
}
|
|
570
|
+
/**
|
|
571
|
+
* One visited page in the persistent browsing history — the durable record that
|
|
572
|
+
* outlives sessions, unlike {@link BrowserHistoryEntry}, which is a
|
|
573
|
+
* session-scoped operation log (navigate/click/type/…).
|
|
574
|
+
*/
|
|
575
|
+
export interface VisitedPage {
|
|
576
|
+
/** Epoch milliseconds when the visit was recorded. */
|
|
577
|
+
readonly at: number;
|
|
578
|
+
/** Absolute URL of the visited document. */
|
|
579
|
+
readonly url: string;
|
|
580
|
+
/** Document title at visit time, when the page exposed one. */
|
|
581
|
+
readonly title?: string;
|
|
582
|
+
/** Session/task label that drove the visit, when the provider knows it. */
|
|
583
|
+
readonly session?: string;
|
|
584
|
+
}
|
|
552
585
|
/** One recorded browser operation, in chronological order (seq 1, 2, 3…). */
|
|
553
586
|
export interface BrowserHistoryEntry {
|
|
554
587
|
/** Monotonic sequence number within the session. */
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import z from '@deepseek-ai/schemastery';
|
|
13
13
|
import { ElectronBrowserProvider } from './provider.js';
|
|
14
14
|
import { defaultHostMainPath, RemoteElectronViewHost } from './remote-host.js';
|
|
15
|
+
import { SettingsStore } from './settings-store.js';
|
|
15
16
|
export { ELECTRON_BROWSER_PROVIDER_ID, ElectronBrowserProvider, } from './provider.js';
|
|
16
17
|
export { RemoteElectronViewHost, defaultHostMainPath } from './remote-host.js';
|
|
17
18
|
/** Cordis plugin name used by loader diagnostics. */
|
|
@@ -31,6 +32,9 @@ export function apply(ctx, config) {
|
|
|
31
32
|
// External host (desktop shell) wins; otherwise self-host. The self-hosted
|
|
32
33
|
// child is disposed with the fiber, mirroring the shell's lifetime.
|
|
33
34
|
const host = config.viewHost ?? new RemoteElectronViewHost(defaultHostMainPath());
|
|
35
|
+
// One settings document per plugin instance: the settings panel writes it, the
|
|
36
|
+
// provider reads it live, and both ends agree on the same file.
|
|
37
|
+
const settings = new SettingsStore();
|
|
34
38
|
// Own the disposer on THIS plugin's fiber: registerBrowserProvider's effect
|
|
35
39
|
// is bound to the seam's own fiber (the browser row), so a reload of this
|
|
36
40
|
// row would otherwise collide with the still-registered provider
|
|
@@ -40,6 +44,7 @@ export function apply(ctx, config) {
|
|
|
40
44
|
downloadDir: config.downloadDir,
|
|
41
45
|
snapshotMaxElements: config.snapshotMaxElements,
|
|
42
46
|
contentMaxChars: config.contentMaxChars,
|
|
47
|
+
settings: () => settings.get(),
|
|
43
48
|
}));
|
|
44
49
|
ctx.effect(() => () => {
|
|
45
50
|
unregister();
|
|
@@ -47,4 +52,114 @@ export function apply(ctx, config) {
|
|
|
47
52
|
host.dispose();
|
|
48
53
|
}
|
|
49
54
|
});
|
|
55
|
+
installSettingsRoute(ctx, settings);
|
|
56
|
+
}
|
|
57
|
+
/** Route the settings panel reads and writes. */
|
|
58
|
+
const SETTINGS_ROUTE = '/dsh-builtin-browser/settings';
|
|
59
|
+
/**
|
|
60
|
+
* Expose the settings document over the host's web server, which is how the
|
|
61
|
+
* settings panel reads and writes it. A host without that service (headless /
|
|
62
|
+
* CLI surfaces) simply skips this: the panel is then unreachable and the file
|
|
63
|
+
* stays hand-editable — never a plugin-startup failure.
|
|
64
|
+
* @param ctx - the plugin context.
|
|
65
|
+
* @param settings - the settings document to expose.
|
|
66
|
+
*/
|
|
67
|
+
function installSettingsRoute(ctx, settings) {
|
|
68
|
+
const inject = ctx.inject;
|
|
69
|
+
if (typeof inject !== 'function')
|
|
70
|
+
return;
|
|
71
|
+
try {
|
|
72
|
+
inject(['webServer'], (host) => {
|
|
73
|
+
host.effect(() => host.webServer.register({
|
|
74
|
+
kind: 'exact',
|
|
75
|
+
path: SETTINGS_ROUTE,
|
|
76
|
+
handler: (request, response) => handleSettingsRequest(request, response, settings),
|
|
77
|
+
}, 'dsh-builtin-browser: settings'));
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// No web server on this surface: the settings file remains the interface.
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Same-origin guard. Settings are machine-local configuration, so a page loaded
|
|
86
|
+
* elsewhere must not be able to read or rewrite them.
|
|
87
|
+
* @param request - the incoming request.
|
|
88
|
+
* @returns whether the request may touch the settings document.
|
|
89
|
+
*/
|
|
90
|
+
function sameOrigin(request) {
|
|
91
|
+
const site = String(request.headers['sec-fetch-site'] ?? '').toLowerCase();
|
|
92
|
+
if (site === 'cross-site')
|
|
93
|
+
return false;
|
|
94
|
+
const origin = String(request.headers.origin ?? '').trim();
|
|
95
|
+
if (origin === 'null')
|
|
96
|
+
return false;
|
|
97
|
+
if (origin === '')
|
|
98
|
+
return true;
|
|
99
|
+
try {
|
|
100
|
+
return new URL(origin).host.toLowerCase() === String(request.headers.host ?? '').trim().toLowerCase();
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* One settings round-trip: `GET` reads the document, `PUT`/`POST` merges a
|
|
108
|
+
* partial patch into it.
|
|
109
|
+
* @param request - the incoming request.
|
|
110
|
+
* @param response - the response to write.
|
|
111
|
+
* @param settings - the settings document.
|
|
112
|
+
*/
|
|
113
|
+
async function handleSettingsRequest(request, response, settings) {
|
|
114
|
+
const json = (status, body) => {
|
|
115
|
+
response.writeHead(status, {
|
|
116
|
+
'content-type': 'application/json; charset=utf-8',
|
|
117
|
+
'cache-control': 'no-store',
|
|
118
|
+
});
|
|
119
|
+
response.end(JSON.stringify(body));
|
|
120
|
+
};
|
|
121
|
+
if (!sameOrigin(request)) {
|
|
122
|
+
json(403, { ok: false, error: 'cross-origin request refused' });
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const method = request.method ?? 'GET';
|
|
126
|
+
if (method === 'GET') {
|
|
127
|
+
json(200, { ok: true, settings: settings.get(), path: settings.path() });
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (method !== 'PUT' && method !== 'POST') {
|
|
131
|
+
response.writeHead(405, { allow: 'GET, PUT' });
|
|
132
|
+
response.end();
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
const body = await readBody(request);
|
|
137
|
+
const patch = body.trim() === '' ? {} : JSON.parse(body);
|
|
138
|
+
json(200, { ok: true, settings: settings.update(patch) });
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
json(400, { ok: false, error: `invalid settings patch: ${String(error)}` });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Read a request body with a hard cap (the settings patch is tiny, and an
|
|
146
|
+
* unbounded read would let any same-origin caller exhaust memory).
|
|
147
|
+
* @param request - the incoming request.
|
|
148
|
+
* @param limit - maximum accepted characters.
|
|
149
|
+
* @returns the body text.
|
|
150
|
+
*/
|
|
151
|
+
function readBody(request, limit = 64 * 1024) {
|
|
152
|
+
return new Promise((resolve, reject) => {
|
|
153
|
+
let text = '';
|
|
154
|
+
request.setEncoding('utf8');
|
|
155
|
+
request.on('data', chunk => {
|
|
156
|
+
text += String(chunk);
|
|
157
|
+
if (text.length > limit) {
|
|
158
|
+
request.destroy();
|
|
159
|
+
reject(new Error('settings patch too large'));
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
request.on('end', () => resolve(text));
|
|
163
|
+
request.on('error', reject);
|
|
164
|
+
});
|
|
50
165
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
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 and domain filter.
|
|
57
|
+
* @returns the matching visits, newest first.
|
|
58
|
+
*/
|
|
59
|
+
list(options?: {
|
|
60
|
+
readonly limit?: number;
|
|
61
|
+
readonly domain?: string;
|
|
62
|
+
}): VisitedPage[];
|
|
63
|
+
/**
|
|
64
|
+
* Enforce retention: drop entries past the age limit, then the oldest beyond
|
|
65
|
+
* the count cap. Cheap no-op until the file grows past the cap, so callers can
|
|
66
|
+
* run it after every append.
|
|
67
|
+
*/
|
|
68
|
+
prune(): void;
|
|
69
|
+
/** Parse the whole file, skipping blank and damaged lines. */
|
|
70
|
+
private readAll;
|
|
71
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
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 and domain filter.
|
|
74
|
+
* @returns the matching visits, newest first.
|
|
75
|
+
*/
|
|
76
|
+
list(options = {}) {
|
|
77
|
+
const all = this.readAll();
|
|
78
|
+
const cutoff = Date.now() - this.maxAgeMs;
|
|
79
|
+
const domain = options.domain?.toLowerCase();
|
|
80
|
+
const matches = all.filter(page => {
|
|
81
|
+
if (page.at < cutoff)
|
|
82
|
+
return false;
|
|
83
|
+
if (domain === undefined)
|
|
84
|
+
return true;
|
|
85
|
+
try {
|
|
86
|
+
return new URL(page.url).hostname.toLowerCase().includes(domain);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return page.url.toLowerCase().includes(domain);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
matches.reverse();
|
|
93
|
+
return options.limit === undefined ? matches : matches.slice(0, Math.max(0, options.limit));
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Enforce retention: drop entries past the age limit, then the oldest beyond
|
|
97
|
+
* the count cap. Cheap no-op until the file grows past the cap, so callers can
|
|
98
|
+
* run it after every append.
|
|
99
|
+
*/
|
|
100
|
+
prune() {
|
|
101
|
+
const all = this.readAll();
|
|
102
|
+
const cutoff = Date.now() - this.maxAgeMs;
|
|
103
|
+
const fresh = all.filter(page => page.at >= cutoff);
|
|
104
|
+
const kept = fresh.length > this.maxEntries ? fresh.slice(fresh.length - this.maxEntries) : fresh;
|
|
105
|
+
// Only rewrite when something actually fell off, and only once the excess
|
|
106
|
+
// is worth a full rewrite.
|
|
107
|
+
if (kept.length === all.length)
|
|
108
|
+
return;
|
|
109
|
+
if (all.length - kept.length < PRUNE_SLACK && fresh.length <= this.maxEntries)
|
|
110
|
+
return;
|
|
111
|
+
try {
|
|
112
|
+
const body = kept.map(page => `${JSON.stringify(page)}\n`).join('');
|
|
113
|
+
writeFileSync(this.file, body);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// Best-effort: an unpruned file still reads correctly.
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/** Parse the whole file, skipping blank and damaged lines. */
|
|
120
|
+
readAll() {
|
|
121
|
+
let raw;
|
|
122
|
+
try {
|
|
123
|
+
raw = readFileSync(this.file, 'utf8');
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
const pages = [];
|
|
129
|
+
for (const line of raw.split('\n')) {
|
|
130
|
+
const trimmed = line.trim();
|
|
131
|
+
if (trimmed === '')
|
|
132
|
+
continue;
|
|
133
|
+
try {
|
|
134
|
+
const parsed = JSON.parse(trimmed);
|
|
135
|
+
if (typeof parsed.at !== 'number' || typeof parsed.url !== 'string')
|
|
136
|
+
continue;
|
|
137
|
+
pages.push({
|
|
138
|
+
at: parsed.at,
|
|
139
|
+
url: parsed.url,
|
|
140
|
+
...typeof parsed.title === 'string' && parsed.title !== '' ? { title: parsed.title } : {},
|
|
141
|
+
...typeof parsed.session === 'string' && parsed.session !== '' ? { session: parsed.session } : {},
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
// A damaged line (typically a truncated tail) must not hide the rest.
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return pages;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -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)
|
|
@@ -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)
|
|
@@ -235,6 +236,13 @@ function withTimeout(promise, ms, label) {
|
|
|
235
236
|
* only the tool layer's 10–30s timeout eventually cuts off.
|
|
236
237
|
*/
|
|
237
238
|
const COMMAND_TIMEOUT_MS = 20000;
|
|
239
|
+
/**
|
|
240
|
+
* How long to let a `focus` request land before answering it. Focus moves
|
|
241
|
+
* asynchronously and a key dispatched in the same turn as the request is still
|
|
242
|
+
* dropped, so the caller needs this settle — but only on the call where focus
|
|
243
|
+
* actually had to move.
|
|
244
|
+
*/
|
|
245
|
+
const FOCUS_SETTLE_MS = 80;
|
|
238
246
|
/**
|
|
239
247
|
* A brand-new WebContentsView has no renderer process until something loads,
|
|
240
248
|
* and CDP commands (Runtime.evaluate …) sent to a renderer-less view never
|
|
@@ -330,7 +338,34 @@ function windowFor(windowId) {
|
|
|
330
338
|
if (existing !== undefined)
|
|
331
339
|
return existing;
|
|
332
340
|
const w = new BrowserWindow({ width: 1400, height: 900, show: true, title: 'dsh-browser' });
|
|
333
|
-
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
|
+
});
|
|
334
369
|
const win = { windowId: key, window: w, toolbarView: undefined, views: new Map(), visibleViewId: undefined, lastFocusedViewId: undefined, closeTimer: undefined };
|
|
335
370
|
// Keep every view (and the toolbar) filling the window as the human
|
|
336
371
|
// resizes it; otherwise pages stay at their original size and break.
|
|
@@ -725,6 +760,29 @@ async function handle(op, msg) {
|
|
|
725
760
|
reply(msg.id, { ok: true, result });
|
|
726
761
|
return;
|
|
727
762
|
}
|
|
763
|
+
case 'focus': {
|
|
764
|
+
const viewId = msg.viewId;
|
|
765
|
+
if (viewId === undefined)
|
|
766
|
+
throw new Error('focus missing viewId');
|
|
767
|
+
const win = windowOfView(viewId);
|
|
768
|
+
const entry = win?.views.get(viewId);
|
|
769
|
+
if (win === undefined || entry === undefined)
|
|
770
|
+
throw new Error(`focus: unknown view ${viewId}`);
|
|
771
|
+
// Keys go to the view that holds web focus. A view that was never
|
|
772
|
+
// clicked holds none, so the FIRST browser_key of a session was dropped
|
|
773
|
+
// by the renderer while CDP still answered the dispatch with success —
|
|
774
|
+
// and `document.hasFocus()` inside the page even read true.
|
|
775
|
+
const contents = entry.webContentsView.webContents;
|
|
776
|
+
const hadWebFocus = contents.isFocused();
|
|
777
|
+
contents.focus();
|
|
778
|
+
// Focus lands asynchronously, and a key dispatched in the same turn as
|
|
779
|
+
// the request is still dropped, so hold the reply until the renderer can
|
|
780
|
+
// take input. Only on the call where focus actually had to move.
|
|
781
|
+
if (!hadWebFocus)
|
|
782
|
+
await new Promise(resolve => setTimeout(resolve, FOCUS_SETTLE_MS));
|
|
783
|
+
reply(msg.id, { ok: true, focused: contents.isFocused() });
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
728
786
|
case 'capture': {
|
|
729
787
|
const viewId = msg.viewId;
|
|
730
788
|
if (viewId === undefined)
|
|
@@ -997,6 +1055,35 @@ async function handle(op, msg) {
|
|
|
997
1055
|
reply(msg.id, { ok: false, err: String(error) });
|
|
998
1056
|
}
|
|
999
1057
|
}
|
|
1058
|
+
/**
|
|
1059
|
+
* Chromium's native-window occlusion tracker (Windows only) asks DWM whether a
|
|
1060
|
+
* window is covered by another window and, when it is, moves the WebContents to
|
|
1061
|
+
* `Visibility::HIDDEN`. That is the wrong model for this host: the shared
|
|
1062
|
+
* browser window normally sits *behind* whatever the human is working in, and
|
|
1063
|
+
* that is exactly the state the agent drives it from. A hidden WebContents
|
|
1064
|
+
* produces no frames at all (`requestAnimationFrame` never fires) and
|
|
1065
|
+
* `RenderWidgetHostImpl::CanReceiveInput()` returns false, so every
|
|
1066
|
+
* `Input.dispatchMouseEvent`/`dispatchKeyEvent` sent over CDP is dropped on the
|
|
1067
|
+
* floor while the CDP call itself still answers `{}` — clicks and keys that
|
|
1068
|
+
* silently do nothing, with no error anywhere. Keep the window's visibility
|
|
1069
|
+
* state application-controlled instead; the parent already shows/raises views
|
|
1070
|
+
* explicitly, and `webContentsView.setVisible()` remains the single source of
|
|
1071
|
+
* truth for whether a view renders.
|
|
1072
|
+
*/
|
|
1073
|
+
function keepWindowsNonOccluding() {
|
|
1074
|
+
if (process.platform !== 'win32')
|
|
1075
|
+
return;
|
|
1076
|
+
const DISABLED = 'CalculateNativeWinOcclusion';
|
|
1077
|
+
const current = app.commandLine.getSwitchValue('disable-features') ?? '';
|
|
1078
|
+
const features = current
|
|
1079
|
+
.split(',')
|
|
1080
|
+
.map(part => part.trim())
|
|
1081
|
+
.filter(part => part !== '');
|
|
1082
|
+
if (!features.includes(DISABLED))
|
|
1083
|
+
features.push(DISABLED);
|
|
1084
|
+
app.commandLine.appendSwitch('disable-features', features.join(','));
|
|
1085
|
+
}
|
|
1086
|
+
keepWindowsNonOccluding();
|
|
1000
1087
|
/**
|
|
1001
1088
|
* Electron entry: connect back to the parent's RPC server (port from
|
|
1002
1089
|
* `--rpc-port`) and serve line-delimited JSON-RPC. `ELECTRON_RUN_AS_NODE` is
|