dsh-builtin-browser 0.4.0 → 0.4.2
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 +68 -30
- package/README.md +66 -30
- package/client.js +60 -6
- package/cordis.patch.yml +21 -0
- package/desktop-bridge/plugin-browser-bridge.js +12 -4
- package/docs/architecture.md +12 -2
- package/docs/tool-reference.md +17 -17
- package/docs/user-guide.md +13 -4
- package/docs/why-browser.md +2 -2
- package/lib/browser/types.d.ts +25 -1
- package/lib/browser-electron/bridge-connection.d.ts +31 -5
- package/lib/browser-electron/bridge-connection.js +143 -21
- package/lib/browser-electron/entry.d.ts +1 -1
- package/lib/browser-electron/entry.js +61 -35
- package/lib/browser-electron/ephemeral-profile.d.ts +37 -0
- package/lib/browser-electron/ephemeral-profile.js +146 -0
- package/lib/browser-electron/history-store.d.ts +25 -0
- package/lib/browser-electron/history-store.js +51 -4
- package/lib/browser-electron/host-main.js +75 -27
- package/lib/browser-electron/provider.d.ts +44 -1
- package/lib/browser-electron/provider.js +379 -48
- package/lib/browser-electron/remote-host.d.ts +52 -3
- package/lib/browser-electron/remote-host.js +244 -23
- package/lib/browser-electron/settings-route.d.ts +28 -0
- package/lib/browser-electron/settings-route.js +28 -0
- package/lib/browser-electron/settings-store.d.ts +40 -0
- package/lib/browser-electron/settings-store.js +107 -11
- package/lib/browser-electron/system-browser.d.ts +71 -3
- package/lib/browser-electron/system-browser.js +355 -38
- package/lib/tool-browser/index.js +143 -34
- package/package.json +5 -3
- package/scripts/verify-host-compat.mjs +195 -0
- package/src/browser/types.ts +25 -1
- package/src/browser-electron/bridge-connection.ts +142 -21
- package/src/browser-electron/entry.ts +58 -33
- package/src/browser-electron/ephemeral-profile.ts +152 -0
- package/src/browser-electron/history-store.ts +51 -4
- package/src/browser-electron/host-main.ts +105 -35
- package/src/browser-electron/provider.ts +401 -51
- package/src/browser-electron/remote-host.ts +261 -36
- package/src/browser-electron/settings-route.ts +46 -0
- package/src/browser-electron/settings-store.ts +121 -11
- package/src/browser-electron/system-browser.ts +342 -21
- package/src/tool-browser/index.ts +144 -36
|
@@ -17,8 +17,10 @@ import { join } from 'node:path';
|
|
|
17
17
|
import { defaultHostMainPath, RemoteElectronViewHost } from './remote-host.js';
|
|
18
18
|
import { DesktopBridgeViewHost } from './desktop-bridge-host.js';
|
|
19
19
|
import { detectBrowser, searchSummary, SystemBrowserViewHost } from './system-browser.js';
|
|
20
|
+
import { claimEphemeralProfile, ephemeralProfileName, sweepAbandonedEphemeralProfiles } from './ephemeral-profile.js';
|
|
20
21
|
import { MissingSystemBrowserHost } from './missing-system-browser.js';
|
|
21
22
|
import { SettingsStore } from './settings-store.js';
|
|
23
|
+
import { sameOrigin } from './settings-route.js';
|
|
22
24
|
export { ELECTRON_BROWSER_PROVIDER_ID, ElectronBrowserProvider, } from './provider.js';
|
|
23
25
|
export { RemoteElectronViewHost, defaultHostMainPath } from './remote-host.js';
|
|
24
26
|
/** Cordis plugin name used by loader diagnostics. */
|
|
@@ -83,7 +85,14 @@ export function apply(ctx, config) {
|
|
|
83
85
|
ctx.logger?.info?.(`dsh-builtin-browser: ${note}`);
|
|
84
86
|
}
|
|
85
87
|
catch (error) {
|
|
86
|
-
// Never leave the surface without a provider.
|
|
88
|
+
// Never leave the surface without a provider. The half-built host is disposed FIRST: an
|
|
89
|
+
// adopt that got as far as constructing it may already hold a socket, a listener or a child
|
|
90
|
+
// process, and dropping it on the floor is how a failed upgrade becomes a leak that only
|
|
91
|
+
// shows up as a stray Electron in the task manager.
|
|
92
|
+
try {
|
|
93
|
+
host.dispose?.();
|
|
94
|
+
}
|
|
95
|
+
catch { /* the fallback below matters more than this */ }
|
|
87
96
|
unregister = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(new RemoteElectronViewHost(defaultHostMainPath()), providerConfig));
|
|
88
97
|
ctx.logger?.warn?.(`dsh-builtin-browser: could not adopt ${note} (${String(error)})`);
|
|
89
98
|
}
|
|
@@ -118,15 +127,29 @@ export function apply(ctx, config) {
|
|
|
118
127
|
}
|
|
119
128
|
}
|
|
120
129
|
else {
|
|
121
|
-
|
|
130
|
+
// Same profile root as settings-store, history-store and desktop-bridge-host: the
|
|
131
|
+
// `?.dsh` component used to be missing HERE only, so a desktop launched from a
|
|
132
|
+
// shortcut (no DSH_HOME in the environment) put its browser profile in
|
|
133
|
+
// `C:\Users\<user>\dsh-builtin-browser-host\` while its settings and history lived in
|
|
134
|
+
// `~/.dsh/dsh-builtin-browser-host/` — two half-profiles that never met, and a
|
|
135
|
+
// settings panel whose switches appeared not to apply.
|
|
136
|
+
const home = process.env.DSH_HOME ?? join(homedir(), '.dsh');
|
|
122
137
|
// Login state lives in the browser profile, so the cookies setting decides where
|
|
123
138
|
// that profile goes: a stable directory keeps the user signed in across restarts
|
|
124
139
|
// (the point of using their own browser), while turning persistence off gets a
|
|
125
140
|
// throwaway directory that is removed when the browser is released.
|
|
126
141
|
const persist = settings.get().cookies.persist;
|
|
142
|
+
const profileRoot = join(home, BROWSER_PROFILE_DIR);
|
|
127
143
|
const profileDir = persist
|
|
128
|
-
? join(
|
|
129
|
-
: join(
|
|
144
|
+
? join(profileRoot, `${detected.kind}-profile`)
|
|
145
|
+
: join(profileRoot, ephemeralProfileName(detected.kind, randomUUID()));
|
|
146
|
+
if (!persist) {
|
|
147
|
+
// A process killed outright never runs its own cleanup (see ephemeral-profile.ts),
|
|
148
|
+
// so the sweep happens HERE — at the only moment that can still act on a profile
|
|
149
|
+
// whose owner is gone — and before this run claims a directory of its own.
|
|
150
|
+
sweepAbandonedEphemeralProfiles(profileRoot, detected.kind);
|
|
151
|
+
claimEphemeralProfile(profileDir);
|
|
152
|
+
}
|
|
130
153
|
adopt(new SystemBrowserViewHost(detected, profileDir, [], persist ? undefined : profileDir), `will drive the installed ${detected.kind} (${detected.path}${persist ? '' : ', ephemeral profile'})`);
|
|
131
154
|
}
|
|
132
155
|
}
|
|
@@ -134,16 +157,41 @@ export function apply(ctx, config) {
|
|
|
134
157
|
// replace it, which is exactly what this guard prevents.
|
|
135
158
|
if (explicitChoice)
|
|
136
159
|
return;
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
160
|
+
// The shell may not be up yet. `dsh web` starts before — or entirely without — the desktop app
|
|
161
|
+
// that hosts the bridge, and a one-shot discovery meant a bridge that appeared a second later
|
|
162
|
+
// was never adopted for the rest of the process's life. Retry a handful of times over roughly
|
|
163
|
+
// half a minute, then stop: this is a convenience, not a promise, and the session the user is
|
|
164
|
+
// already driving in the self-hosted window is not worth replacing minutes into their work.
|
|
165
|
+
// `adopt` is itself one-shot (its `upgraded` guard), so the only possible effect of a retry
|
|
166
|
+
// that lands late is adopting a bridge that showed up just after startup.
|
|
167
|
+
void (async () => {
|
|
168
|
+
for (const delay of [0, 2_000, 5_000, 15_000]) {
|
|
169
|
+
if (delay > 0)
|
|
170
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
171
|
+
let sidebar;
|
|
172
|
+
try {
|
|
173
|
+
sidebar = await DesktopBridgeViewHost.discover();
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
sidebar = undefined; // discovery is documented not to throw; never let it stop the retries
|
|
177
|
+
}
|
|
178
|
+
if (sidebar === undefined)
|
|
179
|
+
continue;
|
|
180
|
+
adopt(sidebar, 'driving the desktop sidebar browser');
|
|
141
181
|
return;
|
|
142
|
-
|
|
143
|
-
})
|
|
182
|
+
}
|
|
183
|
+
})();
|
|
144
184
|
}
|
|
145
185
|
/** Route the settings panel reads and writes. */
|
|
146
186
|
const SETTINGS_ROUTE = '/dsh-builtin-browser/settings';
|
|
187
|
+
/**
|
|
188
|
+
* The directory under the DSH home that holds this plugin's browser profile.
|
|
189
|
+
*
|
|
190
|
+
* Named once, because every consumer of it must agree: `settingsPath()` and the history
|
|
191
|
+
* store resolve their own copy from `$DSH_HOME`, and a profile written under a different
|
|
192
|
+
* root than the settings document is a pair that silently never meets.
|
|
193
|
+
*/
|
|
194
|
+
const BROWSER_PROFILE_DIR = 'dsh-builtin-browser-host';
|
|
147
195
|
/**
|
|
148
196
|
* Expose the settings document over the host's web server, which is how the
|
|
149
197
|
* settings panel reads and writes it. A host without that service (headless /
|
|
@@ -169,31 +217,9 @@ function installSettingsRoute(ctx, settings) {
|
|
|
169
217
|
// No web server on this surface: the settings file remains the interface.
|
|
170
218
|
}
|
|
171
219
|
}
|
|
172
|
-
/**
|
|
173
|
-
* Same-origin guard. Settings are machine-local configuration, so a page loaded
|
|
174
|
-
* elsewhere must not be able to read or rewrite them.
|
|
175
|
-
* @param request - the incoming request.
|
|
176
|
-
* @returns whether the request may touch the settings document.
|
|
177
|
-
*/
|
|
178
|
-
function sameOrigin(request) {
|
|
179
|
-
const site = String(request.headers['sec-fetch-site'] ?? '').toLowerCase();
|
|
180
|
-
if (site === 'cross-site')
|
|
181
|
-
return false;
|
|
182
|
-
const origin = String(request.headers.origin ?? '').trim();
|
|
183
|
-
if (origin === 'null')
|
|
184
|
-
return false;
|
|
185
|
-
if (origin === '')
|
|
186
|
-
return true;
|
|
187
|
-
try {
|
|
188
|
-
return new URL(origin).host.toLowerCase() === String(request.headers.host ?? '').trim().toLowerCase();
|
|
189
|
-
}
|
|
190
|
-
catch {
|
|
191
|
-
return false;
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
220
|
/**
|
|
195
221
|
* One settings round-trip: `GET` reads the document, `PUT`/`POST` merges a
|
|
196
|
-
* partial patch into it.
|
|
222
|
+
* partial patch into it. Admission lives in `settings-route.ts` so it can be tested.
|
|
197
223
|
* @param request - the incoming request.
|
|
198
224
|
* @param response - the response to write.
|
|
199
225
|
* @param settings - the settings document.
|
|
@@ -206,11 +232,11 @@ async function handleSettingsRequest(request, response, settings) {
|
|
|
206
232
|
});
|
|
207
233
|
response.end(JSON.stringify(body));
|
|
208
234
|
};
|
|
209
|
-
|
|
235
|
+
const method = request.method ?? 'GET';
|
|
236
|
+
if (!sameOrigin(request, method !== 'GET')) {
|
|
210
237
|
json(403, { ok: false, error: 'cross-origin request refused' });
|
|
211
238
|
return;
|
|
212
239
|
}
|
|
213
|
-
const method = request.method ?? 'GET';
|
|
214
240
|
if (method === 'GET') {
|
|
215
241
|
json(200, { ok: true, settings: settings.get(), path: settings.path() });
|
|
216
242
|
return;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** Test seams; production passes neither. */
|
|
2
|
+
export interface SweepOptions {
|
|
3
|
+
/** The clock, for the age check. */
|
|
4
|
+
readonly now?: number;
|
|
5
|
+
/** Liveness test for an owner pid. */
|
|
6
|
+
readonly alive?: (pid: number) => boolean;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* The directory name an ephemeral profile for `kind` gets, before it is claimed.
|
|
10
|
+
*
|
|
11
|
+
* The sweep matches on the same shape, so the two must agree: a name built anywhere else
|
|
12
|
+
* is a directory the sweep will never find.
|
|
13
|
+
* @param kind - the detected browser product (`chrome`, `edge`, ...).
|
|
14
|
+
* @param id - a per-run unique suffix.
|
|
15
|
+
* @returns the directory NAME, not a path.
|
|
16
|
+
*/
|
|
17
|
+
export declare function ephemeralProfileName(kind: string, id: string): string;
|
|
18
|
+
/**
|
|
19
|
+
* Mark an ephemeral profile directory as this process's, so a later run can tell whether
|
|
20
|
+
* the process that created it is still there.
|
|
21
|
+
* @param dir - the profile directory (created if it does not exist yet).
|
|
22
|
+
* @param pid - the claiming process, defaulting to this one.
|
|
23
|
+
* @param now - the claim time, defaulting to the current clock.
|
|
24
|
+
* @returns true when the marker was written.
|
|
25
|
+
*/
|
|
26
|
+
export declare function claimEphemeralProfile(dir: string, pid?: number, now?: number): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Delete the ephemeral profiles under `root` that belong to processes which are gone.
|
|
29
|
+
*
|
|
30
|
+
* Called before a new one is claimed, because that is the only moment that can still act
|
|
31
|
+
* on a profile whose owner was killed: nothing runs when the signal lands.
|
|
32
|
+
* @param root - the plugin's profile root (`<DSH_HOME>/dsh-builtin-browser-host`).
|
|
33
|
+
* @param kind - the browser product, which selects the directory-name prefix.
|
|
34
|
+
* @param options - test seams for the clock and the liveness test.
|
|
35
|
+
* @returns the names of the directories that were removed.
|
|
36
|
+
*/
|
|
37
|
+
export declare function sweepAbandonedEphemeralProfiles(root: string, kind: string, options?: SweepOptions): string[];
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ephemeral browser profile: a throwaway directory under the DSH home, used when the
|
|
3
|
+
* user has turned profile persistence off, and deleted when the browser is released.
|
|
4
|
+
*
|
|
5
|
+
* Deleting on release is not enough by itself. A process killed outright — a DSH restart
|
|
6
|
+
* that ends in SIGKILL, "End task" in a task manager, a crashed host — never runs its
|
|
7
|
+
* cleanup, so the profile it was using survives on disk: the user asked for no profile to
|
|
8
|
+
* be kept and the machine keeps one anyway, holding cookies and history. Nothing can run
|
|
9
|
+
* at SIGKILL time, so the cleanup has to happen on the NEXT run instead, which is what
|
|
10
|
+
* this module provides.
|
|
11
|
+
*
|
|
12
|
+
* Ownership is a marker file inside the directory naming the process that claimed it. A
|
|
13
|
+
* directory whose owner is gone AND which has not been written to for a while is
|
|
14
|
+
* abandoned. The age check is what keeps the sweep away from a profile an orphaned
|
|
15
|
+
* browser (a child that outlived the parent which was killed) may still be using: a live
|
|
16
|
+
* Chromium writes to its profile continuously, so a directory that has not changed in an
|
|
17
|
+
* hour is not in use.
|
|
18
|
+
*/
|
|
19
|
+
import { mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
/** The marker file, named once because both the claim and the sweep read it. */
|
|
22
|
+
const OWNER_FILE = '.dsh-ephemeral-owner';
|
|
23
|
+
/**
|
|
24
|
+
* How long a profile is left alone regardless of ownership. An orphaned browser keeps
|
|
25
|
+
* writing to its profile, so this is what distinguishes "abandoned" from "in use by a
|
|
26
|
+
* process this one did not start".
|
|
27
|
+
*/
|
|
28
|
+
const ABANDONED_AFTER_MS = 60 * 60 * 1000;
|
|
29
|
+
/**
|
|
30
|
+
* Whether a process with this pid exists.
|
|
31
|
+
*
|
|
32
|
+
* A signal of 0 performs the permission and existence checks without delivering anything.
|
|
33
|
+
* EPERM is a live process owned by someone else — alive, not gone.
|
|
34
|
+
* @param pid - the pid to test.
|
|
35
|
+
* @returns true when the process exists.
|
|
36
|
+
*/
|
|
37
|
+
function processAlive(pid) {
|
|
38
|
+
try {
|
|
39
|
+
process.kill(pid, 0);
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
return error.code === 'EPERM';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The directory name an ephemeral profile for `kind` gets, before it is claimed.
|
|
48
|
+
*
|
|
49
|
+
* The sweep matches on the same shape, so the two must agree: a name built anywhere else
|
|
50
|
+
* is a directory the sweep will never find.
|
|
51
|
+
* @param kind - the detected browser product (`chrome`, `edge`, ...).
|
|
52
|
+
* @param id - a per-run unique suffix.
|
|
53
|
+
* @returns the directory NAME, not a path.
|
|
54
|
+
*/
|
|
55
|
+
export function ephemeralProfileName(kind, id) {
|
|
56
|
+
return `${kind}-profile-ephemeral-${id}`;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Mark an ephemeral profile directory as this process's, so a later run can tell whether
|
|
60
|
+
* the process that created it is still there.
|
|
61
|
+
* @param dir - the profile directory (created if it does not exist yet).
|
|
62
|
+
* @param pid - the claiming process, defaulting to this one.
|
|
63
|
+
* @param now - the claim time, defaulting to the current clock.
|
|
64
|
+
* @returns true when the marker was written.
|
|
65
|
+
*/
|
|
66
|
+
export function claimEphemeralProfile(dir, pid = process.pid, now = Date.now()) {
|
|
67
|
+
try {
|
|
68
|
+
mkdirSync(dir, { recursive: true });
|
|
69
|
+
writeFileSync(join(dir, OWNER_FILE), JSON.stringify({ pid, at: now }));
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// The browser creates the directory itself when this fails, and cleanup is best
|
|
74
|
+
// effort throughout: a marker that could not be written must not stop the launch.
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The claimed owner of an ephemeral profile, when it has one.
|
|
80
|
+
* @param dir - the profile directory.
|
|
81
|
+
* @returns the owner pid, or undefined when there is no readable marker.
|
|
82
|
+
*/
|
|
83
|
+
function readOwner(dir) {
|
|
84
|
+
try {
|
|
85
|
+
const parsed = JSON.parse(readFileSync(join(dir, OWNER_FILE), 'utf8'));
|
|
86
|
+
return typeof parsed.pid === 'number' ? parsed.pid : undefined;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// No marker: a run from before this existed, or one killed before it wrote one. The
|
|
90
|
+
// age check has already applied, and a directory with no claimant that old is
|
|
91
|
+
// abandoned by any reading.
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Delete the ephemeral profiles under `root` that belong to processes which are gone.
|
|
97
|
+
*
|
|
98
|
+
* Called before a new one is claimed, because that is the only moment that can still act
|
|
99
|
+
* on a profile whose owner was killed: nothing runs when the signal lands.
|
|
100
|
+
* @param root - the plugin's profile root (`<DSH_HOME>/dsh-builtin-browser-host`).
|
|
101
|
+
* @param kind - the browser product, which selects the directory-name prefix.
|
|
102
|
+
* @param options - test seams for the clock and the liveness test.
|
|
103
|
+
* @returns the names of the directories that were removed.
|
|
104
|
+
*/
|
|
105
|
+
export function sweepAbandonedEphemeralProfiles(root, kind, options = {}) {
|
|
106
|
+
const now = options.now ?? Date.now();
|
|
107
|
+
const alive = options.alive ?? processAlive;
|
|
108
|
+
const prefix = `${kind}-profile-ephemeral-`;
|
|
109
|
+
const removed = [];
|
|
110
|
+
let names;
|
|
111
|
+
try {
|
|
112
|
+
names = readdirSync(root);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
// No profile root yet: nothing has ever been written here, so nothing to sweep.
|
|
116
|
+
return removed;
|
|
117
|
+
}
|
|
118
|
+
for (const name of names) {
|
|
119
|
+
if (!name.startsWith(prefix))
|
|
120
|
+
continue;
|
|
121
|
+
const dir = join(root, name);
|
|
122
|
+
let ageMs;
|
|
123
|
+
try {
|
|
124
|
+
ageMs = now - statSync(dir).mtimeMs;
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
// Recent writes mean a browser is still working in there. A killed DSH can leave its
|
|
130
|
+
// Chromium child running, and that child's profile is not garbage.
|
|
131
|
+
if (ageMs < ABANDONED_AFTER_MS)
|
|
132
|
+
continue;
|
|
133
|
+
const owner = readOwner(dir);
|
|
134
|
+
if (owner !== undefined && alive(owner))
|
|
135
|
+
continue;
|
|
136
|
+
try {
|
|
137
|
+
rmSync(dir, { recursive: true, force: true });
|
|
138
|
+
removed.push(name);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
// In use, or not ours to remove. Leaving it is the safe outcome and the next run
|
|
142
|
+
// tries again — the alternative is deleting a profile out from under a live browser.
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return removed;
|
|
146
|
+
}
|
|
@@ -34,6 +34,22 @@ export declare function visitedHistoryPath(): string;
|
|
|
34
34
|
*/
|
|
35
35
|
export declare class HistoryStore {
|
|
36
36
|
private readonly file;
|
|
37
|
+
/**
|
|
38
|
+
* File size at which the next rewrite happens.
|
|
39
|
+
*
|
|
40
|
+
* Advanced after each rewrite so the cost is paid once per PRUNE_SLACK appends
|
|
41
|
+
* rather than on every append: asking "is it over the cap?" was true from the first
|
|
42
|
+
* over-cap write onwards, which is what made every later navigation rewrite the file.
|
|
43
|
+
*/
|
|
44
|
+
private pruneThreshold;
|
|
45
|
+
/**
|
|
46
|
+
* Appends since the last rewrite.
|
|
47
|
+
*
|
|
48
|
+
* prune() runs after every navigation, and the answer is almost always "nothing to
|
|
49
|
+
* do" — but working that out from the file costs a full parse. Counting instead makes
|
|
50
|
+
* the common case a comparison.
|
|
51
|
+
*/
|
|
52
|
+
private appendedSincePrune;
|
|
37
53
|
private readonly maxEntries;
|
|
38
54
|
private readonly maxAgeMs;
|
|
39
55
|
/**
|
|
@@ -69,6 +85,15 @@ export declare class HistoryStore {
|
|
|
69
85
|
* the count cap. Cheap no-op until the file grows past the cap, so callers can
|
|
70
86
|
* run it after every append.
|
|
71
87
|
*/
|
|
88
|
+
/**
|
|
89
|
+
* How far past the cap the file may grow before it is rewritten.
|
|
90
|
+
*
|
|
91
|
+
* Scaled to the cap rather than fixed: a fixed 500 was meaningless when the cap is
|
|
92
|
+
* small (a caller asking for three entries would never reach 503), and the slack is
|
|
93
|
+
* there to batch rewrites, not to overrule the caller's bound.
|
|
94
|
+
* @returns the number of extra entries tolerated before a rewrite.
|
|
95
|
+
*/
|
|
96
|
+
private slack;
|
|
72
97
|
prune(): void;
|
|
73
98
|
/** Parse the whole file, skipping blank and damaged lines. */
|
|
74
99
|
private readAll;
|
|
@@ -12,9 +12,10 @@
|
|
|
12
12
|
* instead of poisoning the whole file.
|
|
13
13
|
* @module dsh-browser/browser-electron/history-store
|
|
14
14
|
*/
|
|
15
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync
|
|
15
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
16
16
|
import { homedir } from 'node:os';
|
|
17
17
|
import { dirname, join } from 'node:path';
|
|
18
|
+
import { writeFileAtomic } from './settings-store.js';
|
|
18
19
|
/** Default entry cap — mirrors the documented retention rule. */
|
|
19
20
|
const DEFAULT_MAX_ENTRIES = 5_000;
|
|
20
21
|
/** Default age cap in days. */
|
|
@@ -37,6 +38,22 @@ export function visitedHistoryPath() {
|
|
|
37
38
|
*/
|
|
38
39
|
export class HistoryStore {
|
|
39
40
|
file;
|
|
41
|
+
/**
|
|
42
|
+
* File size at which the next rewrite happens.
|
|
43
|
+
*
|
|
44
|
+
* Advanced after each rewrite so the cost is paid once per PRUNE_SLACK appends
|
|
45
|
+
* rather than on every append: asking "is it over the cap?" was true from the first
|
|
46
|
+
* over-cap write onwards, which is what made every later navigation rewrite the file.
|
|
47
|
+
*/
|
|
48
|
+
pruneThreshold;
|
|
49
|
+
/**
|
|
50
|
+
* Appends since the last rewrite.
|
|
51
|
+
*
|
|
52
|
+
* prune() runs after every navigation, and the answer is almost always "nothing to
|
|
53
|
+
* do" — but working that out from the file costs a full parse. Counting instead makes
|
|
54
|
+
* the common case a comparison.
|
|
55
|
+
*/
|
|
56
|
+
appendedSincePrune = 0;
|
|
40
57
|
maxEntries;
|
|
41
58
|
maxAgeMs;
|
|
42
59
|
/**
|
|
@@ -46,6 +63,8 @@ export class HistoryStore {
|
|
|
46
63
|
constructor(file = visitedHistoryPath(), limits = {}) {
|
|
47
64
|
this.file = file;
|
|
48
65
|
this.maxEntries = limits.maxEntries ?? DEFAULT_MAX_ENTRIES;
|
|
66
|
+
// The first rewrite is due once the file has grown PRUNE_SLACK past the cap.
|
|
67
|
+
this.pruneThreshold = this.maxEntries + this.slack();
|
|
49
68
|
this.maxAgeMs = (limits.maxAgeDays ?? DEFAULT_MAX_AGE_DAYS) * 24 * 60 * 60 * 1000;
|
|
50
69
|
}
|
|
51
70
|
/** Whether the history file exists yet (diagnostics and tests). */
|
|
@@ -66,6 +85,7 @@ export class HistoryStore {
|
|
|
66
85
|
catch {
|
|
67
86
|
// Best-effort: history is not worth failing a navigation over.
|
|
68
87
|
}
|
|
88
|
+
this.appendedSincePrune++;
|
|
69
89
|
}
|
|
70
90
|
/**
|
|
71
91
|
* Read visits, newest first. Unparseable lines are skipped; entries older
|
|
@@ -108,7 +128,22 @@ export class HistoryStore {
|
|
|
108
128
|
* the count cap. Cheap no-op until the file grows past the cap, so callers can
|
|
109
129
|
* run it after every append.
|
|
110
130
|
*/
|
|
131
|
+
/**
|
|
132
|
+
* How far past the cap the file may grow before it is rewritten.
|
|
133
|
+
*
|
|
134
|
+
* Scaled to the cap rather than fixed: a fixed 500 was meaningless when the cap is
|
|
135
|
+
* small (a caller asking for three entries would never reach 503), and the slack is
|
|
136
|
+
* there to batch rewrites, not to overrule the caller's bound.
|
|
137
|
+
* @returns the number of extra entries tolerated before a rewrite.
|
|
138
|
+
*/
|
|
139
|
+
slack() {
|
|
140
|
+
return Math.min(PRUNE_SLACK, Math.max(1, Math.floor(this.maxEntries / 2)));
|
|
141
|
+
}
|
|
111
142
|
prune() {
|
|
143
|
+
// Cheap first: past the cap but without enough new entries to justify a rewrite.
|
|
144
|
+
// Reading the file to reach this conclusion was the remaining per-navigation cost.
|
|
145
|
+
if (this.appendedSincePrune === 0)
|
|
146
|
+
return;
|
|
112
147
|
const all = this.readAll();
|
|
113
148
|
const cutoff = Date.now() - this.maxAgeMs;
|
|
114
149
|
const fresh = all.filter(page => page.at >= cutoff);
|
|
@@ -117,14 +152,26 @@ export class HistoryStore {
|
|
|
117
152
|
// is worth a full rewrite.
|
|
118
153
|
if (kept.length === all.length)
|
|
119
154
|
return;
|
|
120
|
-
|
|
155
|
+
// Only rewrite once the file has grown PRUNE_SLACK past what we last wrote. Asking
|
|
156
|
+
// "is the file over the cap?" instead was true from the first over-cap append
|
|
157
|
+
// onwards, so every single navigation after that rewrote the whole file.
|
|
158
|
+
if (all.length < this.pruneThreshold)
|
|
121
159
|
return;
|
|
122
160
|
try {
|
|
123
161
|
const body = kept.map(page => `${JSON.stringify(page)}\n`).join('');
|
|
124
|
-
writeFileSync
|
|
162
|
+
// Atomic, like the settings document. A plain writeFileSync truncates first, and this is
|
|
163
|
+
// the whole history in one call: a TerminateProcess mid-write — the browser host is
|
|
164
|
+
// killed exactly that way on Windows — left a partial file, and the reader drops a
|
|
165
|
+
// half-written TAIL rather than the middle, so everything after the tear was lost.
|
|
166
|
+
writeFileAtomic(this.file, body);
|
|
167
|
+
// The file now holds only what was kept, so the next rewrite is due once
|
|
168
|
+
// PRUNE_SLACK more entries have been appended.
|
|
169
|
+
this.pruneThreshold = kept.length + this.slack();
|
|
170
|
+
this.appendedSincePrune = 0;
|
|
125
171
|
}
|
|
126
172
|
catch {
|
|
127
|
-
// Best-effort: an unpruned file still reads correctly
|
|
173
|
+
// Best-effort: an unpruned file still reads correctly, and the atomic write leaves the
|
|
174
|
+
// previous one in place when it fails.
|
|
128
175
|
}
|
|
129
176
|
}
|
|
130
177
|
/** Parse the whole file, skipping blank and damaged lines. */
|