dsh-builtin-browser 0.4.1 → 0.4.5
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/CHANGELOG.md +1699 -0
- package/README.en.md +114 -33
- package/README.md +108 -32
- package/client.js +478 -29
- package/cordis.patch.yml +21 -0
- package/desktop-bridge/install.mjs +66 -6
- package/desktop-bridge/plugin-browser-bridge.js +426 -167
- package/docs/architecture.md +14 -4
- package/docs/tool-reference.md +13 -13
- package/docs/user-guide.md +58 -3
- package/docs/why-browser.md +1 -1
- package/lib/browser/types.d.ts +34 -3
- package/lib/browser-electron/bridge-connection.d.ts +49 -26
- package/lib/browser-electron/bridge-connection.js +185 -22
- package/lib/browser-electron/desktop-bridge-host.d.ts +69 -36
- package/lib/browser-electron/desktop-bridge-host.js +151 -86
- package/lib/browser-electron/entry.d.ts +1 -1
- package/lib/browser-electron/entry.js +133 -40
- 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 +36 -2
- package/lib/browser-electron/history-store.js +76 -7
- package/lib/browser-electron/host-main.js +86 -28
- package/lib/browser-electron/provider.d.ts +106 -3
- package/lib/browser-electron/provider.js +407 -60
- package/lib/browser-electron/remote-host.d.ts +52 -3
- package/lib/browser-electron/remote-host.js +259 -25
- package/lib/browser-electron/rpc-ops.d.ts +18 -0
- package/lib/browser-electron/rpc-ops.js +36 -0
- package/lib/browser-electron/settings-route.d.ts +28 -0
- package/lib/browser-electron/settings-route.js +68 -0
- package/lib/browser-electron/settings-store.d.ts +40 -0
- package/lib/browser-electron/settings-store.js +160 -23
- package/lib/browser-electron/system-browser.d.ts +102 -12
- package/lib/browser-electron/system-browser.js +411 -51
- package/lib/index.js +0 -1
- package/lib/tool-browser/element-fields.d.ts +145 -0
- package/lib/tool-browser/element-fields.js +152 -0
- package/lib/tool-browser/index.js +149 -74
- package/package.json +10 -4
- package/scripts/verify-host-compat.mjs +195 -0
- package/src/browser/types.ts +34 -3
- package/src/browser-electron/bridge-connection.ts +179 -22
- package/src/browser-electron/desktop-bridge-host.ts +146 -84
- package/src/browser-electron/entry.ts +132 -39
- package/src/browser-electron/ephemeral-profile.ts +152 -0
- package/src/browser-electron/history-store.ts +81 -9
- package/src/browser-electron/host-main.ts +115 -36
- package/src/browser-electron/provider.ts +492 -69
- package/src/browser-electron/remote-host.ts +276 -38
- package/src/browser-electron/rpc-ops.ts +41 -0
- package/src/browser-electron/settings-route.ts +87 -0
- package/src/browser-electron/settings-store.ts +173 -24
- package/src/browser-electron/system-browser.ts +408 -39
- package/src/index.ts +3 -2
- package/src/tool-browser/element-fields.ts +167 -0
- package/src/tool-browser/index.ts +155 -76
|
@@ -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,13 +34,38 @@ export declare function visitedHistoryPath(): string;
|
|
|
34
34
|
*/
|
|
35
35
|
export declare class HistoryStore {
|
|
36
36
|
private readonly file;
|
|
37
|
-
|
|
38
|
-
|
|
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;
|
|
53
|
+
private maxEntries;
|
|
54
|
+
private maxAgeMs;
|
|
39
55
|
/**
|
|
40
56
|
* @param file - absolute path of the JSONL history file.
|
|
41
57
|
* @param limits - retention overrides (entry count and age).
|
|
42
58
|
*/
|
|
43
59
|
constructor(file?: string, limits?: HistoryLimits);
|
|
60
|
+
/**
|
|
61
|
+
* Re-read the retention limits.
|
|
62
|
+
*
|
|
63
|
+
* They are settings, and settings change while the plugin runs — a panel that let the
|
|
64
|
+
* operator edit them while nothing read the new values made the control a lie. Entry count
|
|
65
|
+
* and age are the pair the interface presents, so they are updated together.
|
|
66
|
+
* @param limits - the current retention overrides.
|
|
67
|
+
*/
|
|
68
|
+
updateLimits(limits: HistoryLimits): void;
|
|
44
69
|
/** Whether the history file exists yet (diagnostics and tests). */
|
|
45
70
|
exists(): boolean;
|
|
46
71
|
/**
|
|
@@ -69,6 +94,15 @@ export declare class HistoryStore {
|
|
|
69
94
|
* the count cap. Cheap no-op until the file grows past the cap, so callers can
|
|
70
95
|
* run it after every append.
|
|
71
96
|
*/
|
|
97
|
+
/**
|
|
98
|
+
* How far past the cap the file may grow before it is rewritten.
|
|
99
|
+
*
|
|
100
|
+
* Scaled to the cap rather than fixed: a fixed 500 was meaningless when the cap is
|
|
101
|
+
* small (a caller asking for three entries would never reach 503), and the slack is
|
|
102
|
+
* there to batch rewrites, not to overrule the caller's bound.
|
|
103
|
+
* @returns the number of extra entries tolerated before a rewrite.
|
|
104
|
+
*/
|
|
105
|
+
private slack;
|
|
72
106
|
prune(): void;
|
|
73
107
|
/** Parse the whole file, skipping blank and damaged lines. */
|
|
74
108
|
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
|
/**
|
|
@@ -47,6 +64,22 @@ export class HistoryStore {
|
|
|
47
64
|
this.file = file;
|
|
48
65
|
this.maxEntries = limits.maxEntries ?? DEFAULT_MAX_ENTRIES;
|
|
49
66
|
this.maxAgeMs = (limits.maxAgeDays ?? DEFAULT_MAX_AGE_DAYS) * 24 * 60 * 60 * 1000;
|
|
67
|
+
// The first rewrite is due once the file has grown the slack past the cap.
|
|
68
|
+
this.pruneThreshold = this.maxEntries + this.slack();
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Re-read the retention limits.
|
|
72
|
+
*
|
|
73
|
+
* They are settings, and settings change while the plugin runs — a panel that let the
|
|
74
|
+
* operator edit them while nothing read the new values made the control a lie. Entry count
|
|
75
|
+
* and age are the pair the interface presents, so they are updated together.
|
|
76
|
+
* @param limits - the current retention overrides.
|
|
77
|
+
*/
|
|
78
|
+
updateLimits(limits) {
|
|
79
|
+
this.maxEntries = limits.maxEntries ?? DEFAULT_MAX_ENTRIES;
|
|
80
|
+
this.maxAgeMs = (limits.maxAgeDays ?? DEFAULT_MAX_AGE_DAYS) * 24 * 60 * 60 * 1000;
|
|
81
|
+
// Recomputed rather than left alone: a smaller cap means the next rewrite is sooner.
|
|
82
|
+
this.pruneThreshold = Math.max(this.pruneThreshold, this.maxEntries + this.slack());
|
|
50
83
|
}
|
|
51
84
|
/** Whether the history file exists yet (diagnostics and tests). */
|
|
52
85
|
exists() {
|
|
@@ -66,6 +99,7 @@ export class HistoryStore {
|
|
|
66
99
|
catch {
|
|
67
100
|
// Best-effort: history is not worth failing a navigation over.
|
|
68
101
|
}
|
|
102
|
+
this.appendedSincePrune++;
|
|
69
103
|
}
|
|
70
104
|
/**
|
|
71
105
|
* Read visits, newest first. Unparseable lines are skipped; entries older
|
|
@@ -108,23 +142,58 @@ export class HistoryStore {
|
|
|
108
142
|
* the count cap. Cheap no-op until the file grows past the cap, so callers can
|
|
109
143
|
* run it after every append.
|
|
110
144
|
*/
|
|
145
|
+
/**
|
|
146
|
+
* How far past the cap the file may grow before it is rewritten.
|
|
147
|
+
*
|
|
148
|
+
* Scaled to the cap rather than fixed: a fixed 500 was meaningless when the cap is
|
|
149
|
+
* small (a caller asking for three entries would never reach 503), and the slack is
|
|
150
|
+
* there to batch rewrites, not to overrule the caller's bound.
|
|
151
|
+
* @returns the number of extra entries tolerated before a rewrite.
|
|
152
|
+
*/
|
|
153
|
+
slack() {
|
|
154
|
+
return Math.min(PRUNE_SLACK, Math.max(1, Math.floor(this.maxEntries / 2)));
|
|
155
|
+
}
|
|
111
156
|
prune() {
|
|
157
|
+
// Cheap first: past the cap but without enough new entries to justify a rewrite.
|
|
158
|
+
// Reading the file to reach this conclusion was the remaining per-navigation cost.
|
|
159
|
+
if (this.appendedSincePrune === 0)
|
|
160
|
+
return;
|
|
112
161
|
const all = this.readAll();
|
|
113
162
|
const cutoff = Date.now() - this.maxAgeMs;
|
|
114
163
|
const fresh = all.filter(page => page.at >= cutoff);
|
|
115
164
|
const kept = fresh.length > this.maxEntries ? fresh.slice(fresh.length - this.maxEntries) : fresh;
|
|
116
|
-
//
|
|
117
|
-
// is
|
|
118
|
-
|
|
165
|
+
// Every early exit has to retire the count it just spent, or the gate above stops gating.
|
|
166
|
+
// It counted appends so that the common "nothing to do" case is a comparison; leaving the
|
|
167
|
+
// counter set on the two paths that DO read the file meant it was never zero again, so
|
|
168
|
+
// every navigation paid the full parse this was written to avoid.
|
|
169
|
+
if (kept.length === all.length) {
|
|
170
|
+
this.appendedSincePrune = 0;
|
|
119
171
|
return;
|
|
120
|
-
|
|
172
|
+
}
|
|
173
|
+
// Only rewrite once the file has grown PRUNE_SLACK past what we last wrote. Asking
|
|
174
|
+
// "is the file over the cap?" instead was true from the first over-cap append
|
|
175
|
+
// onwards, so every single navigation after that rewrote the whole file.
|
|
176
|
+
if (all.length < this.pruneThreshold) {
|
|
177
|
+
// Deliberately NOT reset here: the count is what tracks progress toward the threshold,
|
|
178
|
+
// so clearing it would postpone the rewrite indefinitely. The read is the price of
|
|
179
|
+
// staying under the threshold, and the threshold is what bounds how often that happens.
|
|
121
180
|
return;
|
|
181
|
+
}
|
|
122
182
|
try {
|
|
123
183
|
const body = kept.map(page => `${JSON.stringify(page)}\n`).join('');
|
|
124
|
-
writeFileSync
|
|
184
|
+
// Atomic, like the settings document. A plain writeFileSync truncates first, and this is
|
|
185
|
+
// the whole history in one call: a TerminateProcess mid-write — the browser host is
|
|
186
|
+
// killed exactly that way on Windows — left a partial file, and the reader drops a
|
|
187
|
+
// half-written TAIL rather than the middle, so everything after the tear was lost.
|
|
188
|
+
writeFileAtomic(this.file, body);
|
|
189
|
+
// The file now holds only what was kept, so the next rewrite is due once
|
|
190
|
+
// PRUNE_SLACK more entries have been appended.
|
|
191
|
+
this.pruneThreshold = kept.length + this.slack();
|
|
192
|
+
this.appendedSincePrune = 0;
|
|
125
193
|
}
|
|
126
194
|
catch {
|
|
127
|
-
// Best-effort: an unpruned file still reads correctly
|
|
195
|
+
// Best-effort: an unpruned file still reads correctly, and the atomic write leaves the
|
|
196
|
+
// previous one in place when it fails.
|
|
128
197
|
}
|
|
129
198
|
}
|
|
130
199
|
/** Parse the whole file, skipping blank and damaged lines. */
|
|
@@ -47,26 +47,42 @@ catch (error) {
|
|
|
47
47
|
process.stderr.write(`[dsh-browser host] userData setup failed: ${String(error)}\n`);
|
|
48
48
|
}
|
|
49
49
|
/**
|
|
50
|
-
* Read the spawn token. The parent sends it two ways
|
|
51
|
-
* the first that yields a value:
|
|
50
|
+
* Read the spawn token. The parent sends it two ways:
|
|
52
51
|
* 1. stdin (first line) — the secure channel on Unix/macOS;
|
|
53
52
|
* 2. DSH_BROWSER_RPC_TOKEN env — the fallback on Windows, where Electron is
|
|
54
53
|
* a GUI-subsystem process and never receives piped stdin (the parent
|
|
55
54
|
* sets it in the spawn env for exactly this reason).
|
|
56
|
-
*
|
|
55
|
+
*
|
|
56
|
+
* stdin WINS over env, which is what the ordering here has to express. It used to read env
|
|
57
|
+
* first and only consult stdin when that came up empty, so the stdin branch never ran while
|
|
58
|
+
* env was set — every spawn was told both ways, so the documented preference was silently
|
|
59
|
+
* the other way round. The env value is kept as the default for the common case where stdin
|
|
60
|
+
* delivers nothing; the interface below overwrites it when a line arrives.
|
|
57
61
|
*/
|
|
58
62
|
let RPC_TOKEN = process.env.DSH_BROWSER_RPC_TOKEN ?? '';
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
63
|
+
/** True once a line actually arrived on stdin (as opposed to env being present). */
|
|
64
|
+
let tokenFromStdin = false;
|
|
65
|
+
/**
|
|
66
|
+
* The one and only stdin reader.
|
|
67
|
+
*
|
|
68
|
+
* It is created here and reused by the hello path inside `whenReady` below: two
|
|
69
|
+
* `createInterface` calls on the same stream both consume its lines, so the second one saw
|
|
70
|
+
* nothing at all. A single reader means whoever needs the token from stdin waits on it.
|
|
71
|
+
*/
|
|
72
|
+
const stdinLines = process.stdin !== null && process.stdin.readable
|
|
73
|
+
? createInterface({ input: process.stdin, terminal: false })
|
|
74
|
+
: undefined;
|
|
75
|
+
if (stdinLines !== undefined) {
|
|
62
76
|
stdinLines.on('line', (line) => {
|
|
63
|
-
if (
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
77
|
+
if (tokenFromStdin)
|
|
78
|
+
return;
|
|
79
|
+
tokenFromStdin = true;
|
|
80
|
+
const token = line.trim();
|
|
81
|
+
if (token !== '')
|
|
82
|
+
RPC_TOKEN = token;
|
|
67
83
|
});
|
|
68
84
|
stdinLines.on('close', () => {
|
|
69
|
-
if (RPC_TOKEN === ''
|
|
85
|
+
if (RPC_TOKEN === '') {
|
|
70
86
|
process.stderr.write('[dsh-browser host] warning: no token received on stdin or env\n');
|
|
71
87
|
}
|
|
72
88
|
});
|
|
@@ -816,15 +832,16 @@ async function handle(op, msg) {
|
|
|
816
832
|
catch { /* not minimized */ }
|
|
817
833
|
win.window.focus();
|
|
818
834
|
let base64 = '';
|
|
835
|
+
let renderedSize;
|
|
819
836
|
try {
|
|
820
837
|
let image;
|
|
821
838
|
try {
|
|
822
|
-
image = await entry.webContentsView.webContents.capturePage();
|
|
839
|
+
image = await withTimeout(entry.webContentsView.webContents.capturePage(), COMMAND_TIMEOUT_MS, 'capturePage');
|
|
823
840
|
}
|
|
824
841
|
catch (error) {
|
|
825
842
|
process.stderr.write(`[dsh-browser host] capturePage failed: ${String(error)}\n`);
|
|
826
843
|
await new Promise(resolve => setTimeout(resolve, 400));
|
|
827
|
-
image = await entry.webContentsView.webContents.capturePage();
|
|
844
|
+
image = await withTimeout(entry.webContentsView.webContents.capturePage(), COMMAND_TIMEOUT_MS, 'capturePage');
|
|
828
845
|
}
|
|
829
846
|
// Downscale to fit the requested box, preserving aspect ratio.
|
|
830
847
|
const size = image.getSize();
|
|
@@ -840,6 +857,10 @@ async function handle(op, msg) {
|
|
|
840
857
|
}
|
|
841
858
|
if (w > 0 && h > 0 && (w !== size.width || h !== size.height))
|
|
842
859
|
image = image.resize({ width: w, height: h });
|
|
860
|
+
// What the caller actually receives: after the downscale, not the captured size.
|
|
861
|
+
// Recorded here because this is the last point that knows both.
|
|
862
|
+
if (w > 0 && h > 0)
|
|
863
|
+
renderedSize = { width: w, height: h };
|
|
843
864
|
const buf = wantFormat === 'jpeg' ? image.toJPEG(wantQuality) : image.toPNG();
|
|
844
865
|
if (wantFormat === 'jpeg')
|
|
845
866
|
mime = 'image/jpeg';
|
|
@@ -870,7 +891,7 @@ async function handle(op, msg) {
|
|
|
870
891
|
catch { /* already gone */ }
|
|
871
892
|
}
|
|
872
893
|
try {
|
|
873
|
-
const shot = await entry.webContentsView.webContents.debugger.sendCommand('Page.captureScreenshot', {});
|
|
894
|
+
const shot = await withTimeout(entry.webContentsView.webContents.debugger.sendCommand('Page.captureScreenshot', {}), COMMAND_TIMEOUT_MS, 'Page.captureScreenshot');
|
|
874
895
|
const data = shot.data;
|
|
875
896
|
if (typeof data === 'string' && data.length > 0)
|
|
876
897
|
base64 = data;
|
|
@@ -898,7 +919,12 @@ async function handle(op, msg) {
|
|
|
898
919
|
if (base64 === '') {
|
|
899
920
|
throw new Error('capture produced no image (view not painted)');
|
|
900
921
|
}
|
|
901
|
-
|
|
922
|
+
// The measured size travels with the image. Without it the native path — which is the
|
|
923
|
+
// default self-hosted one — could never answer `browser_screenshot`'s width/height,
|
|
924
|
+
// because the seam's only channel was {base64, mime} and the provider had nothing to
|
|
925
|
+
// forward. The caller's `maxWidth`/`maxHeight` are applied here, so this is the only
|
|
926
|
+
// place that knows what came back.
|
|
927
|
+
reply(msg.id, { ok: true, result: { base64, mime, width: renderedSize?.width, height: renderedSize?.height } });
|
|
902
928
|
return;
|
|
903
929
|
}
|
|
904
930
|
case 'download': {
|
|
@@ -918,7 +944,11 @@ async function handle(op, msg) {
|
|
|
918
944
|
// the parent's memory or hit the single-line cap. This also avoids
|
|
919
945
|
// Electron's download pipeline entirely (CDP debugger attach can
|
|
920
946
|
// interfere with will-download).
|
|
921
|
-
|
|
947
|
+
// Bounded like every other op. The evaluate awaits a page-side fetch, so a slow or
|
|
948
|
+
// unresponsive server held the serial queue open forever: handle() never settled,
|
|
949
|
+
// every later op queued behind it, and the parent's plain timeout does not restart
|
|
950
|
+
// the host.
|
|
951
|
+
const result = await withTimeout(entry.webContentsView.webContents.debugger.sendCommand('Runtime.evaluate', {
|
|
922
952
|
// Stream the body through a reader so the size cap is enforced as
|
|
923
953
|
// bytes arrive — never buffer an unbounded download into memory
|
|
924
954
|
// before checking the limit (a huge URL would otherwise OOM the
|
|
@@ -959,7 +989,7 @@ async function handle(op, msg) {
|
|
|
959
989
|
})()`,
|
|
960
990
|
awaitPromise: true,
|
|
961
991
|
returnByValue: true,
|
|
962
|
-
});
|
|
992
|
+
}), COMMAND_TIMEOUT_MS, 'download evaluate');
|
|
963
993
|
const value = result.result?.value;
|
|
964
994
|
if (typeof value !== 'string') {
|
|
965
995
|
const detail = result.exceptionDetails;
|
|
@@ -994,7 +1024,21 @@ async function handle(op, msg) {
|
|
|
994
1024
|
throw new Error(`flushAuth: unknown view ${viewId}`);
|
|
995
1025
|
// Export the session's cookies so login state can be saved/restored
|
|
996
1026
|
// across browser hosts (or shared with another machine).
|
|
997
|
-
|
|
1027
|
+
//
|
|
1028
|
+
// Scoped to this view's own page. The jar is SHARED on purpose: views here are built
|
|
1029
|
+
// without a partition and every window drives the same browser, so signing in once
|
|
1030
|
+
// works everywhere. What must not be shared is the HANDING OVER — an unfiltered read
|
|
1031
|
+
// would put every domain every task has visited, plus whatever the human is signed
|
|
1032
|
+
// into, in front of one task's caller. Least exposure, not isolation. An unknown page
|
|
1033
|
+
// refuses rather than widening.
|
|
1034
|
+
//
|
|
1035
|
+
// Bounded like every other op: a renderer that stops answering here would hold the
|
|
1036
|
+
// serial queue forever, and the parent's plain timeout does not restart the host.
|
|
1037
|
+
const pageUrl = entry.webContentsView.webContents.getURL();
|
|
1038
|
+
if (pageUrl === '' || pageUrl === 'about:blank') {
|
|
1039
|
+
throw new Error('flushAuth: this view has no page loaded, so the cookie scope is unknown — reading everything would expose other sessions');
|
|
1040
|
+
}
|
|
1041
|
+
const cookies = await withTimeout(entry.webContentsView.webContents.session.cookies.get({ url: pageUrl }), COMMAND_TIMEOUT_MS, 'cookies.get');
|
|
998
1042
|
const exported = cookies.map(c => {
|
|
999
1043
|
// Electron types the cookie domain as optional; a missing domain
|
|
1000
1044
|
// cannot be exported meaningfully, so default to '' (matches the
|
|
@@ -1032,7 +1076,9 @@ async function handle(op, msg) {
|
|
|
1032
1076
|
for (const c of cookies) {
|
|
1033
1077
|
if (typeof c.url !== 'string' || typeof c.name !== 'string' || typeof c.value !== 'string')
|
|
1034
1078
|
continue;
|
|
1035
|
-
|
|
1079
|
+
// Bounded per cookie: this runs once for every cookie, so a single hang held the
|
|
1080
|
+
// serial queue open forever with no path to recovery.
|
|
1081
|
+
await withTimeout(entry.webContentsView.webContents.session.cookies.set({
|
|
1036
1082
|
url: c.url,
|
|
1037
1083
|
name: c.name,
|
|
1038
1084
|
value: c.value,
|
|
@@ -1041,7 +1087,7 @@ async function handle(op, msg) {
|
|
|
1041
1087
|
...typeof c.secure === 'boolean' ? { secure: c.secure } : {},
|
|
1042
1088
|
...typeof c.httpOnly === 'boolean' ? { httpOnly: c.httpOnly } : {},
|
|
1043
1089
|
...typeof c.expirationDate === 'number' ? { expirationDate: c.expirationDate } : {},
|
|
1044
|
-
});
|
|
1090
|
+
}), COMMAND_TIMEOUT_MS, 'cookies.set');
|
|
1045
1091
|
restored++;
|
|
1046
1092
|
}
|
|
1047
1093
|
reply(msg.id, { ok: true, result: { restored } });
|
|
@@ -1110,17 +1156,19 @@ void app.whenReady().then(() => {
|
|
|
1110
1156
|
socket.write(JSON.stringify({ id: 0, op: 'hello', token: RPC_TOKEN }) + '\n');
|
|
1111
1157
|
};
|
|
1112
1158
|
if (RPC_TOKEN !== '') {
|
|
1159
|
+
// Either stdin already delivered a line (it wins) or env supplied the fallback.
|
|
1113
1160
|
writeHello();
|
|
1114
1161
|
}
|
|
1115
1162
|
else {
|
|
1116
|
-
//
|
|
1117
|
-
//
|
|
1118
|
-
//
|
|
1163
|
+
// Neither yet: wait for stdin's first line with a bounded fallback, then send whatever
|
|
1164
|
+
// we have — an empty hello is refused by the parent, which then fails cleanly. The
|
|
1165
|
+
// reader is the module-level one, NOT a second createInterface on the same stream: two
|
|
1166
|
+
// readers of one stdin both consume its lines, so the second one never saw the token.
|
|
1119
1167
|
const fallback = setTimeout(() => writeHello(), 2_000);
|
|
1120
|
-
if (
|
|
1121
|
-
const
|
|
1122
|
-
stdinLines.once('line',
|
|
1123
|
-
stdinLines.once('close',
|
|
1168
|
+
if (stdinLines !== undefined) {
|
|
1169
|
+
const send = () => { clearTimeout(fallback); writeHello(); };
|
|
1170
|
+
stdinLines.once('line', send);
|
|
1171
|
+
stdinLines.once('close', send);
|
|
1124
1172
|
}
|
|
1125
1173
|
}
|
|
1126
1174
|
const rl = createInterface({ input: socket });
|
|
@@ -1156,7 +1204,17 @@ void app.whenReady().then(() => {
|
|
|
1156
1204
|
// without cleanup, exit so no zombie Electron window is left behind.
|
|
1157
1205
|
socket.on('close', () => {
|
|
1158
1206
|
process.stderr.write('[dsh-browser host] parent connection closed, exiting\n');
|
|
1159
|
-
app.exit(0)
|
|
1207
|
+
// A normal quit, not `app.exit(0)`. exit() skips before-quit/will-quit and does not wait
|
|
1208
|
+
// for Chromium to finish writing the profile — the leveldb files holding cookies and site
|
|
1209
|
+
// data. This path runs on every dispose, and a session that has just logged in is exactly
|
|
1210
|
+
// when the profile holds unwritten state, so a hard exit could leave a half-written leveldb
|
|
1211
|
+
// behind, which is what makes a browser profile fail to open on the next launch.
|
|
1212
|
+
app.quit();
|
|
1213
|
+
// Belt and braces: the parent is already gone, so there is nothing left to serve. If a
|
|
1214
|
+
// listener or a window blocks the quit, forcing the exit after a grace period is still
|
|
1215
|
+
// strictly better than an orphaned Electron window the user has to close by hand.
|
|
1216
|
+
const force = setTimeout(() => app.exit(0), 5000);
|
|
1217
|
+
force.unref();
|
|
1160
1218
|
});
|
|
1161
1219
|
// Keep the process alive until the parent closes the socket or kills us.
|
|
1162
1220
|
});
|