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
|
@@ -13,10 +13,11 @@
|
|
|
13
13
|
* @module dsh-browser/browser-electron/history-store
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync
|
|
16
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
|
|
17
17
|
import { homedir } from 'node:os'
|
|
18
18
|
import { dirname, join } from 'node:path'
|
|
19
19
|
import type { VisitedPage } from '../browser/types.js'
|
|
20
|
+
import { writeFileAtomic } from './settings-store.js'
|
|
20
21
|
|
|
21
22
|
export type { VisitedPage }
|
|
22
23
|
|
|
@@ -51,6 +52,22 @@ export function visitedHistoryPath(): string {
|
|
|
51
52
|
* a corrupted line must never hide the rest of the history.
|
|
52
53
|
*/
|
|
53
54
|
export class HistoryStore {
|
|
55
|
+
/**
|
|
56
|
+
* File size at which the next rewrite happens.
|
|
57
|
+
*
|
|
58
|
+
* Advanced after each rewrite so the cost is paid once per PRUNE_SLACK appends
|
|
59
|
+
* rather than on every append: asking "is it over the cap?" was true from the first
|
|
60
|
+
* over-cap write onwards, which is what made every later navigation rewrite the file.
|
|
61
|
+
*/
|
|
62
|
+
private pruneThreshold: number
|
|
63
|
+
/**
|
|
64
|
+
* Appends since the last rewrite.
|
|
65
|
+
*
|
|
66
|
+
* prune() runs after every navigation, and the answer is almost always "nothing to
|
|
67
|
+
* do" — but working that out from the file costs a full parse. Counting instead makes
|
|
68
|
+
* the common case a comparison.
|
|
69
|
+
*/
|
|
70
|
+
private appendedSincePrune = 0
|
|
54
71
|
private readonly maxEntries: number
|
|
55
72
|
private readonly maxAgeMs: number
|
|
56
73
|
|
|
@@ -63,6 +80,8 @@ export class HistoryStore {
|
|
|
63
80
|
limits: HistoryLimits = {},
|
|
64
81
|
) {
|
|
65
82
|
this.maxEntries = limits.maxEntries ?? DEFAULT_MAX_ENTRIES
|
|
83
|
+
// The first rewrite is due once the file has grown PRUNE_SLACK past the cap.
|
|
84
|
+
this.pruneThreshold = this.maxEntries + this.slack()
|
|
66
85
|
this.maxAgeMs = (limits.maxAgeDays ?? DEFAULT_MAX_AGE_DAYS) * 24 * 60 * 60 * 1000
|
|
67
86
|
}
|
|
68
87
|
|
|
@@ -84,6 +103,7 @@ export class HistoryStore {
|
|
|
84
103
|
} catch {
|
|
85
104
|
// Best-effort: history is not worth failing a navigation over.
|
|
86
105
|
}
|
|
106
|
+
this.appendedSincePrune++
|
|
87
107
|
}
|
|
88
108
|
|
|
89
109
|
/**
|
|
@@ -123,7 +143,22 @@ export class HistoryStore {
|
|
|
123
143
|
* the count cap. Cheap no-op until the file grows past the cap, so callers can
|
|
124
144
|
* run it after every append.
|
|
125
145
|
*/
|
|
146
|
+
/**
|
|
147
|
+
* How far past the cap the file may grow before it is rewritten.
|
|
148
|
+
*
|
|
149
|
+
* Scaled to the cap rather than fixed: a fixed 500 was meaningless when the cap is
|
|
150
|
+
* small (a caller asking for three entries would never reach 503), and the slack is
|
|
151
|
+
* there to batch rewrites, not to overrule the caller's bound.
|
|
152
|
+
* @returns the number of extra entries tolerated before a rewrite.
|
|
153
|
+
*/
|
|
154
|
+
private slack(): number {
|
|
155
|
+
return Math.min(PRUNE_SLACK, Math.max(1, Math.floor(this.maxEntries / 2)))
|
|
156
|
+
}
|
|
157
|
+
|
|
126
158
|
prune(): void {
|
|
159
|
+
// Cheap first: past the cap but without enough new entries to justify a rewrite.
|
|
160
|
+
// Reading the file to reach this conclusion was the remaining per-navigation cost.
|
|
161
|
+
if (this.appendedSincePrune === 0) return
|
|
127
162
|
const all = this.readAll()
|
|
128
163
|
const cutoff = Date.now() - this.maxAgeMs
|
|
129
164
|
const fresh = all.filter(page => page.at >= cutoff)
|
|
@@ -131,12 +166,24 @@ export class HistoryStore {
|
|
|
131
166
|
// Only rewrite when something actually fell off, and only once the excess
|
|
132
167
|
// is worth a full rewrite.
|
|
133
168
|
if (kept.length === all.length) return
|
|
134
|
-
|
|
169
|
+
// Only rewrite once the file has grown PRUNE_SLACK past what we last wrote. Asking
|
|
170
|
+
// "is the file over the cap?" instead was true from the first over-cap append
|
|
171
|
+
// onwards, so every single navigation after that rewrote the whole file.
|
|
172
|
+
if (all.length < this.pruneThreshold) return
|
|
135
173
|
try {
|
|
136
174
|
const body = kept.map(page => `${JSON.stringify(page)}\n`).join('')
|
|
137
|
-
writeFileSync
|
|
175
|
+
// Atomic, like the settings document. A plain writeFileSync truncates first, and this is
|
|
176
|
+
// the whole history in one call: a TerminateProcess mid-write — the browser host is
|
|
177
|
+
// killed exactly that way on Windows — left a partial file, and the reader drops a
|
|
178
|
+
// half-written TAIL rather than the middle, so everything after the tear was lost.
|
|
179
|
+
writeFileAtomic(this.file, body)
|
|
180
|
+
// The file now holds only what was kept, so the next rewrite is due once
|
|
181
|
+
// PRUNE_SLACK more entries have been appended.
|
|
182
|
+
this.pruneThreshold = kept.length + this.slack()
|
|
183
|
+
this.appendedSincePrune = 0
|
|
138
184
|
} catch {
|
|
139
|
-
// Best-effort: an unpruned file still reads correctly
|
|
185
|
+
// Best-effort: an unpruned file still reads correctly, and the atomic write leaves the
|
|
186
|
+
// previous one in place when it fails.
|
|
140
187
|
}
|
|
141
188
|
}
|
|
142
189
|
|
|
@@ -50,26 +50,40 @@ try {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
/**
|
|
53
|
-
* Read the spawn token. The parent sends it two ways
|
|
54
|
-
* the first that yields a value:
|
|
53
|
+
* Read the spawn token. The parent sends it two ways:
|
|
55
54
|
* 1. stdin (first line) — the secure channel on Unix/macOS;
|
|
56
55
|
* 2. DSH_BROWSER_RPC_TOKEN env — the fallback on Windows, where Electron is
|
|
57
56
|
* a GUI-subsystem process and never receives piped stdin (the parent
|
|
58
57
|
* sets it in the spawn env for exactly this reason).
|
|
59
|
-
*
|
|
58
|
+
*
|
|
59
|
+
* stdin WINS over env, which is what the ordering here has to express. It used to read env
|
|
60
|
+
* first and only consult stdin when that came up empty, so the stdin branch never ran while
|
|
61
|
+
* env was set — every spawn was told both ways, so the documented preference was silently
|
|
62
|
+
* the other way round. The env value is kept as the default for the common case where stdin
|
|
63
|
+
* delivers nothing; the interface below overwrites it when a line arrives.
|
|
60
64
|
*/
|
|
61
65
|
let RPC_TOKEN = process.env.DSH_BROWSER_RPC_TOKEN ?? ''
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
66
|
+
/** True once a line actually arrived on stdin (as opposed to env being present). */
|
|
67
|
+
let tokenFromStdin = false
|
|
68
|
+
/**
|
|
69
|
+
* The one and only stdin reader.
|
|
70
|
+
*
|
|
71
|
+
* It is created here and reused by the hello path inside `whenReady` below: two
|
|
72
|
+
* `createInterface` calls on the same stream both consume its lines, so the second one saw
|
|
73
|
+
* nothing at all. A single reader means whoever needs the token from stdin waits on it.
|
|
74
|
+
*/
|
|
75
|
+
const stdinLines = process.stdin !== null && process.stdin.readable
|
|
76
|
+
? createInterface({ input: process.stdin, terminal: false })
|
|
77
|
+
: undefined
|
|
78
|
+
if (stdinLines !== undefined) {
|
|
65
79
|
stdinLines.on('line', (line) => {
|
|
66
|
-
if (
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
80
|
+
if (tokenFromStdin) return
|
|
81
|
+
tokenFromStdin = true
|
|
82
|
+
const token = line.trim()
|
|
83
|
+
if (token !== '') RPC_TOKEN = token
|
|
70
84
|
})
|
|
71
85
|
stdinLines.on('close', () => {
|
|
72
|
-
if (RPC_TOKEN === ''
|
|
86
|
+
if (RPC_TOKEN === '') {
|
|
73
87
|
process.stderr.write('[dsh-browser host] warning: no token received on stdin or env\n')
|
|
74
88
|
}
|
|
75
89
|
})
|
|
@@ -797,11 +811,19 @@ async function handle(op: string, msg: { id: number; viewId?: string; windowId?:
|
|
|
797
811
|
try {
|
|
798
812
|
let image
|
|
799
813
|
try {
|
|
800
|
-
image = await
|
|
814
|
+
image = await withTimeout(
|
|
815
|
+
entry.webContentsView.webContents.capturePage(),
|
|
816
|
+
COMMAND_TIMEOUT_MS,
|
|
817
|
+
'capturePage',
|
|
818
|
+
)
|
|
801
819
|
} catch (error) {
|
|
802
820
|
process.stderr.write(`[dsh-browser host] capturePage failed: ${String(error)}\n`)
|
|
803
821
|
await new Promise(resolve => setTimeout(resolve, 400))
|
|
804
|
-
image = await
|
|
822
|
+
image = await withTimeout(
|
|
823
|
+
entry.webContentsView.webContents.capturePage(),
|
|
824
|
+
COMMAND_TIMEOUT_MS,
|
|
825
|
+
'capturePage',
|
|
826
|
+
)
|
|
805
827
|
}
|
|
806
828
|
// Downscale to fit the requested box, preserving aspect ratio.
|
|
807
829
|
const size = image.getSize()
|
|
@@ -831,7 +853,11 @@ async function handle(op: string, msg: { id: number; viewId?: string; windowId?:
|
|
|
831
853
|
try { win.window.contentView.removeChildView(toolbar) } catch { /* already gone */ }
|
|
832
854
|
}
|
|
833
855
|
try {
|
|
834
|
-
const shot = await
|
|
856
|
+
const shot = await withTimeout(
|
|
857
|
+
entry.webContentsView.webContents.debugger.sendCommand('Page.captureScreenshot', {}),
|
|
858
|
+
COMMAND_TIMEOUT_MS,
|
|
859
|
+
'Page.captureScreenshot',
|
|
860
|
+
)
|
|
835
861
|
const data = (shot as { data?: unknown }).data
|
|
836
862
|
if (typeof data === 'string' && data.length > 0) base64 = data
|
|
837
863
|
} finally {
|
|
@@ -869,7 +895,12 @@ async function handle(op: string, msg: { id: number; viewId?: string; windowId?:
|
|
|
869
895
|
// the parent's memory or hit the single-line cap. This also avoids
|
|
870
896
|
// Electron's download pipeline entirely (CDP debugger attach can
|
|
871
897
|
// interfere with will-download).
|
|
872
|
-
|
|
898
|
+
// Bounded like every other op. The evaluate awaits a page-side fetch, so a slow or
|
|
899
|
+
// unresponsive server held the serial queue open forever: handle() never settled,
|
|
900
|
+
// every later op queued behind it, and the parent's plain timeout does not restart
|
|
901
|
+
// the host.
|
|
902
|
+
const result = await withTimeout(
|
|
903
|
+
entry.webContentsView.webContents.debugger.sendCommand('Runtime.evaluate', {
|
|
873
904
|
// Stream the body through a reader so the size cap is enforced as
|
|
874
905
|
// bytes arrive — never buffer an unbounded download into memory
|
|
875
906
|
// before checking the limit (a huge URL would otherwise OOM the
|
|
@@ -910,7 +941,10 @@ async function handle(op: string, msg: { id: number; viewId?: string; windowId?:
|
|
|
910
941
|
})()`,
|
|
911
942
|
awaitPromise: true,
|
|
912
943
|
returnByValue: true,
|
|
913
|
-
|
|
944
|
+
}) as Promise<{ result?: { value?: unknown }; exceptionDetails?: { exception?: { description?: string } } }>,
|
|
945
|
+
COMMAND_TIMEOUT_MS,
|
|
946
|
+
'download evaluate',
|
|
947
|
+
)
|
|
914
948
|
const value = (result as { result?: { value?: unknown } }).result?.value
|
|
915
949
|
if (typeof value !== 'string') {
|
|
916
950
|
const detail = (result as { exceptionDetails?: { exception?: { description?: string } } }).exceptionDetails
|
|
@@ -939,7 +973,25 @@ async function handle(op: string, msg: { id: number; viewId?: string; windowId?:
|
|
|
939
973
|
if (win === undefined || entry === undefined) throw new Error(`flushAuth: unknown view ${viewId}`)
|
|
940
974
|
// Export the session's cookies so login state can be saved/restored
|
|
941
975
|
// across browser hosts (or shared with another machine).
|
|
942
|
-
|
|
976
|
+
//
|
|
977
|
+
// Scoped to this view's own page. The jar is SHARED on purpose: views here are built
|
|
978
|
+
// without a partition and every window drives the same browser, so signing in once
|
|
979
|
+
// works everywhere. What must not be shared is the HANDING OVER — an unfiltered read
|
|
980
|
+
// would put every domain every task has visited, plus whatever the human is signed
|
|
981
|
+
// into, in front of one task's caller. Least exposure, not isolation. An unknown page
|
|
982
|
+
// refuses rather than widening.
|
|
983
|
+
//
|
|
984
|
+
// Bounded like every other op: a renderer that stops answering here would hold the
|
|
985
|
+
// serial queue forever, and the parent's plain timeout does not restart the host.
|
|
986
|
+
const pageUrl = entry.webContentsView.webContents.getURL()
|
|
987
|
+
if (pageUrl === '' || pageUrl === 'about:blank') {
|
|
988
|
+
throw new Error('flushAuth: this view has no page loaded, so the cookie scope is unknown — reading everything would expose other sessions')
|
|
989
|
+
}
|
|
990
|
+
const cookies = await withTimeout(
|
|
991
|
+
entry.webContentsView.webContents.session.cookies.get({ url: pageUrl }),
|
|
992
|
+
COMMAND_TIMEOUT_MS,
|
|
993
|
+
'cookies.get',
|
|
994
|
+
)
|
|
943
995
|
const exported = cookies.map(c => {
|
|
944
996
|
// Electron types the cookie domain as optional; a missing domain
|
|
945
997
|
// cannot be exported meaningfully, so default to '' (matches the
|
|
@@ -973,16 +1025,22 @@ async function handle(op: string, msg: { id: number; viewId?: string; windowId?:
|
|
|
973
1025
|
let restored = 0
|
|
974
1026
|
for (const c of cookies as Array<{ url?: string; name?: string; value?: string; domain?: string; path?: string; secure?: boolean; httpOnly?: boolean; expirationDate?: number }>) {
|
|
975
1027
|
if (typeof c.url !== 'string' || typeof c.name !== 'string' || typeof c.value !== 'string') continue
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
1028
|
+
// Bounded per cookie: this runs once for every cookie, so a single hang held the
|
|
1029
|
+
// serial queue open forever with no path to recovery.
|
|
1030
|
+
await withTimeout(
|
|
1031
|
+
entry.webContentsView.webContents.session.cookies.set({
|
|
1032
|
+
url: c.url,
|
|
1033
|
+
name: c.name,
|
|
1034
|
+
value: c.value,
|
|
1035
|
+
...typeof c.domain === 'string' ? { domain: c.domain } : {},
|
|
1036
|
+
...typeof c.path === 'string' ? { path: c.path } : {},
|
|
1037
|
+
...typeof c.secure === 'boolean' ? { secure: c.secure } : {},
|
|
1038
|
+
...typeof c.httpOnly === 'boolean' ? { httpOnly: c.httpOnly } : {},
|
|
1039
|
+
...typeof c.expirationDate === 'number' ? { expirationDate: c.expirationDate } : {},
|
|
1040
|
+
}),
|
|
1041
|
+
COMMAND_TIMEOUT_MS,
|
|
1042
|
+
'cookies.set',
|
|
1043
|
+
)
|
|
986
1044
|
restored++
|
|
987
1045
|
}
|
|
988
1046
|
reply(msg.id, { ok: true, result: { restored } })
|
|
@@ -1050,16 +1108,18 @@ void app.whenReady().then(() => {
|
|
|
1050
1108
|
socket.write(JSON.stringify({ id: 0, op: 'hello', token: RPC_TOKEN }) + '\n')
|
|
1051
1109
|
}
|
|
1052
1110
|
if (RPC_TOKEN !== '') {
|
|
1111
|
+
// Either stdin already delivered a line (it wins) or env supplied the fallback.
|
|
1053
1112
|
writeHello()
|
|
1054
1113
|
} else {
|
|
1055
|
-
//
|
|
1056
|
-
//
|
|
1057
|
-
//
|
|
1114
|
+
// Neither yet: wait for stdin's first line with a bounded fallback, then send whatever
|
|
1115
|
+
// we have — an empty hello is refused by the parent, which then fails cleanly. The
|
|
1116
|
+
// reader is the module-level one, NOT a second createInterface on the same stream: two
|
|
1117
|
+
// readers of one stdin both consume its lines, so the second one never saw the token.
|
|
1058
1118
|
const fallback = setTimeout(() => writeHello(), 2_000)
|
|
1059
|
-
if (
|
|
1060
|
-
const
|
|
1061
|
-
stdinLines.once('line',
|
|
1062
|
-
stdinLines.once('close',
|
|
1119
|
+
if (stdinLines !== undefined) {
|
|
1120
|
+
const send = (): void => { clearTimeout(fallback); writeHello() }
|
|
1121
|
+
stdinLines.once('line', send)
|
|
1122
|
+
stdinLines.once('close', send)
|
|
1063
1123
|
}
|
|
1064
1124
|
}
|
|
1065
1125
|
const rl = createInterface({ input: socket })
|
|
@@ -1092,7 +1152,17 @@ void app.whenReady().then(() => {
|
|
|
1092
1152
|
// without cleanup, exit so no zombie Electron window is left behind.
|
|
1093
1153
|
socket.on('close', () => {
|
|
1094
1154
|
process.stderr.write('[dsh-browser host] parent connection closed, exiting\n')
|
|
1095
|
-
app.exit(0)
|
|
1155
|
+
// A normal quit, not `app.exit(0)`. exit() skips before-quit/will-quit and does not wait
|
|
1156
|
+
// for Chromium to finish writing the profile — the leveldb files holding cookies and site
|
|
1157
|
+
// data. This path runs on every dispose, and a session that has just logged in is exactly
|
|
1158
|
+
// when the profile holds unwritten state, so a hard exit could leave a half-written leveldb
|
|
1159
|
+
// behind, which is what makes a browser profile fail to open on the next launch.
|
|
1160
|
+
app.quit()
|
|
1161
|
+
// Belt and braces: the parent is already gone, so there is nothing left to serve. If a
|
|
1162
|
+
// listener or a window blocks the quit, forcing the exit after a grace period is still
|
|
1163
|
+
// strictly better than an orphaned Electron window the user has to close by hand.
|
|
1164
|
+
const force = setTimeout(() => app.exit(0), 5000)
|
|
1165
|
+
force.unref()
|
|
1096
1166
|
})
|
|
1097
1167
|
// Keep the process alive until the parent closes the socket or kills us.
|
|
1098
1168
|
})
|