dsh-builtin-browser 0.4.2 → 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 +63 -20
- package/README.md +60 -20
- package/client.js +418 -24
- package/desktop-bridge/install.mjs +66 -6
- package/desktop-bridge/plugin-browser-bridge.js +414 -163
- package/docs/architecture.md +4 -4
- package/docs/tool-reference.md +3 -3
- package/docs/user-guide.md +50 -2
- package/lib/browser/types.d.ts +9 -2
- package/lib/browser-electron/bridge-connection.d.ts +18 -21
- package/lib/browser-electron/bridge-connection.js +42 -1
- 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.js +73 -6
- package/lib/browser-electron/history-store.d.ts +11 -2
- package/lib/browser-electron/history-store.js +27 -5
- package/lib/browser-electron/host-main.js +11 -1
- package/lib/browser-electron/provider.d.ts +62 -2
- package/lib/browser-electron/provider.js +28 -12
- package/lib/browser-electron/remote-host.js +19 -6
- 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 +6 -6
- package/lib/browser-electron/settings-route.js +48 -8
- package/lib/browser-electron/settings-store.js +57 -16
- package/lib/browser-electron/system-browser.d.ts +31 -9
- package/lib/browser-electron/system-browser.js +116 -27
- 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 +26 -60
- package/package.json +6 -2
- package/src/browser/types.ts +9 -2
- package/src/browser-electron/bridge-connection.ts +37 -1
- package/src/browser-electron/desktop-bridge-host.ts +146 -84
- package/src/browser-electron/entry.ts +75 -7
- package/src/browser-electron/history-store.ts +32 -7
- package/src/browser-electron/host-main.ts +10 -1
- package/src/browser-electron/provider.ts +91 -18
- package/src/browser-electron/remote-host.ts +19 -6
- package/src/browser-electron/rpc-ops.ts +41 -0
- package/src/browser-electron/settings-route.ts +54 -13
- package/src/browser-electron/settings-store.ts +56 -17
- package/src/browser-electron/system-browser.ts +121 -28
- package/src/index.ts +3 -2
- package/src/tool-browser/element-fields.ts +167 -0
- package/src/tool-browser/index.ts +31 -60
|
@@ -67,8 +67,49 @@ export const Config: z<Config> = z.object({
|
|
|
67
67
|
contentMaxChars: z.number(),
|
|
68
68
|
})
|
|
69
69
|
|
|
70
|
+
/**
|
|
71
|
+
* This process's session id, read the way DSH's own desktop host reads it.
|
|
72
|
+
*
|
|
73
|
+
* `dsh-desktop-host/lib/index.js` does `ctx.get('agents')` then `agents.list()`, and uses
|
|
74
|
+
* `agent.id` directly as a sessionId. One conversation per process, so the list holds this one.
|
|
75
|
+
*
|
|
76
|
+
* Returns undefined rather than throwing when the service is absent — an older host, or a
|
|
77
|
+
* non-desktop composition. The caller falls back to a random id, which is what the bridge used
|
|
78
|
+
* before and is safe: it only means "this process", not "this conversation".
|
|
79
|
+
*
|
|
80
|
+
* @param ctx - the plugin context.
|
|
81
|
+
* @returns the session id, or undefined when it cannot be read.
|
|
82
|
+
*/
|
|
83
|
+
function readSessionId(ctx: unknown): string | undefined {
|
|
84
|
+
try {
|
|
85
|
+
const surface = ctx as { get?: (name: string) => unknown }
|
|
86
|
+
if (typeof surface.get !== 'function') return undefined
|
|
87
|
+
const agents = surface.get('agents') as { list?: () => unknown } | undefined
|
|
88
|
+
const list = typeof agents?.list === 'function' ? agents.list() : undefined
|
|
89
|
+
if (!Array.isArray(list) || list.length === 0) return undefined
|
|
90
|
+
const first = list[0] as { id?: unknown } | undefined
|
|
91
|
+
return typeof first?.id === 'string' && first.id !== '' ? first.id : undefined
|
|
92
|
+
} catch {
|
|
93
|
+
return undefined
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
70
97
|
/** Register the Electron browser provider with `ctx.browser`. */
|
|
71
98
|
export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config): void {
|
|
99
|
+
// Which session is this process serving?
|
|
100
|
+
//
|
|
101
|
+
// The desktop shell keeps ONE sidebar per conversation — measured: two `[class*=_tabStrip]`
|
|
102
|
+
// containers sat in the DOM at once, each carrying its own session id on the React fiber, and
|
|
103
|
+
// the invisible one's webview was unloaded. So the bridge CAN tell which sidebar belongs to
|
|
104
|
+
// whom; what it lacked was the other half, this process's own identity. Without it, the bridge
|
|
105
|
+
// drove "whatever sidebar is on screen" and — measured on the running shell — typed into another
|
|
106
|
+
// conversation's address bar and navigated that page.
|
|
107
|
+
//
|
|
108
|
+
// The identity is available here, and DSH's own desktop host shows how
|
|
109
|
+
// (`dsh-desktop-host/lib/index.js`: `ctx.get('agents').list()`, with `agent.id` used directly as
|
|
110
|
+
// a sessionId). One conversation per process, so the list holds this one.
|
|
111
|
+
const sessionId = readSessionId(ctx)
|
|
112
|
+
|
|
72
113
|
// One settings document per plugin instance: the settings panel writes it, the
|
|
73
114
|
// provider reads it live, and both ends agree on the same file.
|
|
74
115
|
const settings = new SettingsStore()
|
|
@@ -123,9 +164,22 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
|
|
|
123
164
|
// process, and dropping it on the floor is how a failed upgrade becomes a leak that only
|
|
124
165
|
// shows up as a stray Electron in the task manager.
|
|
125
166
|
try { host.dispose?.() } catch { /* the fallback below matters more than this */ }
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
167
|
+
// The fallback registration gets its own guard, because THIS is the path taken when the
|
|
168
|
+
// context is already gone — and then `ctx.browser` throws `cannot get required service
|
|
169
|
+
// "browser" in inactive context`. Unguarded, that second throw escaped the async retry
|
|
170
|
+
// that calls `adopt`, became an unhandled rejection, and killed the host process with
|
|
171
|
+
// exit code 1: a failed upgrade, which should only ever cost a warning, took the whole
|
|
172
|
+
// session down instead. Observed 2026-10-05 (crash-…-host.log, phase: running).
|
|
173
|
+
try {
|
|
174
|
+
unregister = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(
|
|
175
|
+
new RemoteElectronViewHost(defaultHostMainPath()), providerConfig))
|
|
176
|
+
ctx.logger?.warn?.(`dsh-builtin-browser: could not adopt ${note} (${String(error)})`)
|
|
177
|
+
} catch (fallbackError) {
|
|
178
|
+
ctx.logger?.warn?.(
|
|
179
|
+
`dsh-builtin-browser: could not adopt ${note} (${String(error)}), and the fallback `
|
|
180
|
+
+ `registration also failed (${String(fallbackError)}) — no browser provider is `
|
|
181
|
+
+ 'registered for this context')
|
|
182
|
+
}
|
|
129
183
|
}
|
|
130
184
|
}
|
|
131
185
|
|
|
@@ -143,8 +197,8 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
|
|
|
143
197
|
// that choice real — otherwise a user who picked Chrome would silently get the sidebar,
|
|
144
198
|
// and a user who picked a browser that is not installed would never see the explanation
|
|
145
199
|
// written for exactly that case, because the sidebar would quietly replace it.
|
|
146
|
-
const explicitChoice = channel !== 'bundled' && channel !== 'auto'
|
|
147
|
-
if (channel !== 'bundled') {
|
|
200
|
+
const explicitChoice: boolean = channel !== 'bundled' && channel !== 'auto' && channel !== 'sidebar'
|
|
201
|
+
if (channel !== 'bundled' && channel !== 'sidebar') {
|
|
148
202
|
const detected = detectBrowser(channel)
|
|
149
203
|
if (detected === undefined) {
|
|
150
204
|
// `auto` promises to take whatever is available, so falling back is what it
|
|
@@ -188,7 +242,10 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
|
|
|
188
242
|
|
|
189
243
|
// An explicit choice was already adopted above; discovering the sidebar here would
|
|
190
244
|
// replace it, which is exactly what this guard prevents.
|
|
191
|
-
|
|
245
|
+
//
|
|
246
|
+
// `sidebar` is explicit too, but it names the sidebar, so it is the one explicit choice that
|
|
247
|
+
// must FALL THROUGH to the discovery below instead of returning here.
|
|
248
|
+
if (explicitChoice && channel !== 'sidebar') return
|
|
192
249
|
|
|
193
250
|
// The shell may not be up yet. `dsh web` starts before — or entirely without — the desktop app
|
|
194
251
|
// that hosts the bridge, and a one-shot discovery meant a bridge that appeared a second later
|
|
@@ -202,7 +259,7 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
|
|
|
202
259
|
if (delay > 0) await new Promise(resolve => setTimeout(resolve, delay))
|
|
203
260
|
let sidebar: Awaited<ReturnType<typeof DesktopBridgeViewHost.discover>>
|
|
204
261
|
try {
|
|
205
|
-
sidebar = await DesktopBridgeViewHost.discover()
|
|
262
|
+
sidebar = await DesktopBridgeViewHost.discover(sessionId)
|
|
206
263
|
} catch {
|
|
207
264
|
sidebar = undefined // discovery is documented not to throw; never let it stop the retries
|
|
208
265
|
}
|
|
@@ -210,6 +267,17 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
|
|
|
210
267
|
adopt(sidebar, 'driving the desktop sidebar browser')
|
|
211
268
|
return
|
|
212
269
|
}
|
|
270
|
+
// The user explicitly asked for the sidebar, so silence would leave them with a setting
|
|
271
|
+
// that did nothing and no way to tell why. This is reachable on `dsh web`, and on a desktop
|
|
272
|
+
// whose shell never published a bridge endpoint. An automatic attempt stays quiet (the
|
|
273
|
+
// self-hosted window is a fine outcome); an explicit request does not.
|
|
274
|
+
if (channel === 'sidebar') {
|
|
275
|
+
ctx.logger?.warn?.(
|
|
276
|
+
'dsh-builtin-browser: the sidebar browser was requested but no desktop bridge was found — '
|
|
277
|
+
+ 'this DSH has no desktop shell, or its bridge never started. Falling back to the bundled '
|
|
278
|
+
+ 'browser; set browser.channel to another value to silence this.',
|
|
279
|
+
)
|
|
280
|
+
}
|
|
213
281
|
})()
|
|
214
282
|
}
|
|
215
283
|
|
|
@@ -68,8 +68,8 @@ export class HistoryStore {
|
|
|
68
68
|
* the common case a comparison.
|
|
69
69
|
*/
|
|
70
70
|
private appendedSincePrune = 0
|
|
71
|
-
private
|
|
72
|
-
private
|
|
71
|
+
private maxEntries: number
|
|
72
|
+
private maxAgeMs: number
|
|
73
73
|
|
|
74
74
|
/**
|
|
75
75
|
* @param file - absolute path of the JSONL history file.
|
|
@@ -80,9 +80,24 @@ export class HistoryStore {
|
|
|
80
80
|
limits: HistoryLimits = {},
|
|
81
81
|
) {
|
|
82
82
|
this.maxEntries = limits.maxEntries ?? DEFAULT_MAX_ENTRIES
|
|
83
|
-
|
|
83
|
+
this.maxAgeMs = (limits.maxAgeDays ?? DEFAULT_MAX_AGE_DAYS) * 24 * 60 * 60 * 1000
|
|
84
|
+
// The first rewrite is due once the file has grown the slack past the cap.
|
|
84
85
|
this.pruneThreshold = this.maxEntries + this.slack()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Re-read the retention limits.
|
|
90
|
+
*
|
|
91
|
+
* They are settings, and settings change while the plugin runs — a panel that let the
|
|
92
|
+
* operator edit them while nothing read the new values made the control a lie. Entry count
|
|
93
|
+
* and age are the pair the interface presents, so they are updated together.
|
|
94
|
+
* @param limits - the current retention overrides.
|
|
95
|
+
*/
|
|
96
|
+
updateLimits(limits: HistoryLimits): void {
|
|
97
|
+
this.maxEntries = limits.maxEntries ?? DEFAULT_MAX_ENTRIES
|
|
85
98
|
this.maxAgeMs = (limits.maxAgeDays ?? DEFAULT_MAX_AGE_DAYS) * 24 * 60 * 60 * 1000
|
|
99
|
+
// Recomputed rather than left alone: a smaller cap means the next rewrite is sooner.
|
|
100
|
+
this.pruneThreshold = Math.max(this.pruneThreshold, this.maxEntries + this.slack())
|
|
86
101
|
}
|
|
87
102
|
|
|
88
103
|
/** Whether the history file exists yet (diagnostics and tests). */
|
|
@@ -163,13 +178,23 @@ export class HistoryStore {
|
|
|
163
178
|
const cutoff = Date.now() - this.maxAgeMs
|
|
164
179
|
const fresh = all.filter(page => page.at >= cutoff)
|
|
165
180
|
const kept = fresh.length > this.maxEntries ? fresh.slice(fresh.length - this.maxEntries) : fresh
|
|
166
|
-
//
|
|
167
|
-
// is
|
|
168
|
-
|
|
181
|
+
// Every early exit has to retire the count it just spent, or the gate above stops gating.
|
|
182
|
+
// It counted appends so that the common "nothing to do" case is a comparison; leaving the
|
|
183
|
+
// counter set on the two paths that DO read the file meant it was never zero again, so
|
|
184
|
+
// every navigation paid the full parse this was written to avoid.
|
|
185
|
+
if (kept.length === all.length) {
|
|
186
|
+
this.appendedSincePrune = 0
|
|
187
|
+
return
|
|
188
|
+
}
|
|
169
189
|
// Only rewrite once the file has grown PRUNE_SLACK past what we last wrote. Asking
|
|
170
190
|
// "is the file over the cap?" instead was true from the first over-cap append
|
|
171
191
|
// onwards, so every single navigation after that rewrote the whole file.
|
|
172
|
-
if (all.length < this.pruneThreshold)
|
|
192
|
+
if (all.length < this.pruneThreshold) {
|
|
193
|
+
// Deliberately NOT reset here: the count is what tracks progress toward the threshold,
|
|
194
|
+
// so clearing it would postpone the rewrite indefinitely. The read is the price of
|
|
195
|
+
// staying under the threshold, and the threshold is what bounds how often that happens.
|
|
196
|
+
return
|
|
197
|
+
}
|
|
173
198
|
try {
|
|
174
199
|
const body = kept.map(page => `${JSON.stringify(page)}\n`).join('')
|
|
175
200
|
// Atomic, like the settings document. A plain writeFileSync truncates first, and this is
|
|
@@ -808,6 +808,7 @@ async function handle(op: string, msg: { id: number; viewId?: string; windowId?:
|
|
|
808
808
|
try { win.window.restore() } catch { /* not minimized */ }
|
|
809
809
|
win.window.focus()
|
|
810
810
|
let base64 = ''
|
|
811
|
+
let renderedSize: { width: number; height: number } | undefined
|
|
811
812
|
try {
|
|
812
813
|
let image
|
|
813
814
|
try {
|
|
@@ -832,6 +833,9 @@ async function handle(op: string, msg: { id: number; viewId?: string; windowId?:
|
|
|
832
833
|
if (maxW > 0 && w > maxW) { h = Math.round(h * maxW / w); w = maxW }
|
|
833
834
|
if (maxH > 0 && h > maxH) { w = Math.round(w * maxH / h); h = maxH }
|
|
834
835
|
if (w > 0 && h > 0 && (w !== size.width || h !== size.height)) image = image.resize({ width: w, height: h })
|
|
836
|
+
// What the caller actually receives: after the downscale, not the captured size.
|
|
837
|
+
// Recorded here because this is the last point that knows both.
|
|
838
|
+
if (w > 0 && h > 0) renderedSize = { width: w, height: h }
|
|
835
839
|
const buf = wantFormat === 'jpeg' ? image.toJPEG(wantQuality) : image.toPNG()
|
|
836
840
|
if (wantFormat === 'jpeg') mime = 'image/jpeg'
|
|
837
841
|
if (buf.length > 0) base64 = buf.toString('base64')
|
|
@@ -876,7 +880,12 @@ async function handle(op: string, msg: { id: number; viewId?: string; windowId?:
|
|
|
876
880
|
if (base64 === '') {
|
|
877
881
|
throw new Error('capture produced no image (view not painted)')
|
|
878
882
|
}
|
|
879
|
-
|
|
883
|
+
// The measured size travels with the image. Without it the native path — which is the
|
|
884
|
+
// default self-hosted one — could never answer `browser_screenshot`'s width/height,
|
|
885
|
+
// because the seam's only channel was {base64, mime} and the provider had nothing to
|
|
886
|
+
// forward. The caller's `maxWidth`/`maxHeight` are applied here, so this is the only
|
|
887
|
+
// place that knows what came back.
|
|
888
|
+
reply(msg.id, { ok: true, result: { base64, mime, width: renderedSize?.width, height: renderedSize?.height } })
|
|
880
889
|
return
|
|
881
890
|
}
|
|
882
891
|
case 'download': {
|
|
@@ -118,8 +118,13 @@ export interface ElectronBrowserViewHost {
|
|
|
118
118
|
* Create a new browser view and return a handle to its webContents-like
|
|
119
119
|
* surface. The host owns windowing (adding the view to the window, sizing,
|
|
120
120
|
* removal); the provider owns CDP-driven behavior.
|
|
121
|
+
* @param owner - the conversation this view serves. A host shared by several conversations
|
|
122
|
+
* needs it to keep one conversation's page out of another's; a host with one conversation per
|
|
123
|
+
* process may ignore it. It is per VIEW and not per host on purpose: the desktop's host is a
|
|
124
|
+
* single instance that every conversation runs through, so an owner held there would be one
|
|
125
|
+
* conversation's id applied to all of them.
|
|
121
126
|
*/
|
|
122
|
-
createView(): ElectronViewHandle
|
|
127
|
+
createView(owner?: string): ElectronViewHandle
|
|
123
128
|
/**
|
|
124
129
|
* Destroy a view created by this host. Called on session close; idempotent
|
|
125
130
|
* for an already-destroyed view.
|
|
@@ -159,6 +164,18 @@ export interface ElectronBrowserViewHost {
|
|
|
159
164
|
* of failing only at first use.
|
|
160
165
|
*/
|
|
161
166
|
available?(): boolean
|
|
167
|
+
/**
|
|
168
|
+
* Tell the human that a toolbar action the agent could not complete has failed.
|
|
169
|
+
*
|
|
170
|
+
* Optional. Declared rather than reached through a cast because this is what issue #16 was:
|
|
171
|
+
* the call used to be unbound, so `this` inside the host's implementation was undefined and
|
|
172
|
+
* its first statement threw — inside an async catch, which turned it into an unhandled
|
|
173
|
+
* rejection that took the whole DSH process down. A declared member is checkable; a cast is
|
|
174
|
+
* not.
|
|
175
|
+
* @param windowId - the window the action came from.
|
|
176
|
+
* @param message - what to show the human.
|
|
177
|
+
*/
|
|
178
|
+
notifyUserActionError?(windowId: string, message: string): void
|
|
162
179
|
/**
|
|
163
180
|
* Optional: associate a view with a window group. Views grouped under the
|
|
164
181
|
* same `windowId` share one window (one window per browser session); a host
|
|
@@ -194,7 +211,12 @@ export interface ElectronBrowserViewHost {
|
|
|
194
211
|
* (settings: ui.closeWithSession) has to be asked for explicitly there.
|
|
195
212
|
* @returns a promise that settles once the release was attempted.
|
|
196
213
|
*/
|
|
197
|
-
|
|
214
|
+
/**
|
|
215
|
+
* Release this carrier's browser pages for one conversation.
|
|
216
|
+
* @param owner - the conversation whose pages to close. Omit to close every conversation's,
|
|
217
|
+
* which is only correct from a teardown that is itself global.
|
|
218
|
+
*/
|
|
219
|
+
releasePage?(owner?: string): Promise<void>
|
|
198
220
|
/**
|
|
199
221
|
* Fold the carrier's presentation away while keeping the page alive.
|
|
200
222
|
*
|
|
@@ -257,6 +279,37 @@ export interface ElectronViewHandle {
|
|
|
257
279
|
* then behaves exactly as before.
|
|
258
280
|
*/
|
|
259
281
|
focus?(): Promise<void>
|
|
282
|
+
/**
|
|
283
|
+
* Save a URL straight to disk, when the backing view can do that itself.
|
|
284
|
+
*
|
|
285
|
+
* Optional. Declared here rather than reached through a structural cast at the call site:
|
|
286
|
+
* a cast tells the compiler nothing, so a host that implements the interface without this
|
|
287
|
+
* method compiles and then silently does nothing.
|
|
288
|
+
* @param url - the URL to fetch.
|
|
289
|
+
* @param savePath - where to write it.
|
|
290
|
+
*/
|
|
291
|
+
download?(url: string, savePath: string): Promise<void>
|
|
292
|
+
/**
|
|
293
|
+
* Export this view's cookies. Optional; see {@link download} for why it is declared.
|
|
294
|
+
* @returns the cookies, scoped to the view's own page.
|
|
295
|
+
*/
|
|
296
|
+
flushAuth?(): Promise<readonly ExportedCookie[]>
|
|
297
|
+
/**
|
|
298
|
+
* Import cookies into this view. Optional.
|
|
299
|
+
* @param cookies - the cookies to write.
|
|
300
|
+
* @returns how many were accepted.
|
|
301
|
+
*/
|
|
302
|
+
restoreAuth?(cookies: readonly ExportedCookie[]): Promise<number>
|
|
303
|
+
/**
|
|
304
|
+
* Capture this view natively, returning the image and its measured size.
|
|
305
|
+
*
|
|
306
|
+
* Optional. The size is part of the contract because the caller's own tool schema declares
|
|
307
|
+
* `width`/`height`; a host that can measure the image should say so here rather than leaving
|
|
308
|
+
* the provider to guess.
|
|
309
|
+
* @param opts - format, quality and downscale bounds.
|
|
310
|
+
* @returns the encoded image and the pixel size the caller will receive.
|
|
311
|
+
*/
|
|
312
|
+
capture?(opts?: ScreenshotOptions): Promise<{ base64: string; mime: string; width?: number; height?: number }>
|
|
260
313
|
}
|
|
261
314
|
|
|
262
315
|
/** One tab inside a session: its view plus a stable id. */
|
|
@@ -655,7 +708,10 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
655
708
|
* in the window title so a human can tell which task's page is visible.
|
|
656
709
|
*/
|
|
657
710
|
open(label?: string): Promise<BrowserSessionId> {
|
|
658
|
-
|
|
711
|
+
// The label is the calling DSH task, and it is handed to the view as its owner: a host shared
|
|
712
|
+
// by several conversations (the desktop's) keeps one conversation's page out of another's on
|
|
713
|
+
// the strength of it, so it is not decoration.
|
|
714
|
+
const handle = this.host.createView(label)
|
|
659
715
|
const id = `browser:${randomUUID()}`
|
|
660
716
|
// Group the session's views under its own window (one window per
|
|
661
717
|
// session), carrying the label for the window title. Hosts without
|
|
@@ -2595,7 +2651,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
2595
2651
|
// savePath admission: the shared gate — absolute, inside `downloadDir`,
|
|
2596
2652
|
// and never an existing file. Screenshots use the very same one.
|
|
2597
2653
|
const savePath = this.admitSavePath(request.savePath, 'download')
|
|
2598
|
-
const downloadable = handle
|
|
2654
|
+
const downloadable = handle
|
|
2599
2655
|
if (typeof downloadable.download !== 'function') {
|
|
2600
2656
|
throw new BrowserError('browser: download is only available on the self-hosted browser', 'BROWSER_DOWNLOAD_UNSUPPORTED')
|
|
2601
2657
|
}
|
|
@@ -2632,7 +2688,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
2632
2688
|
const timeoutMs = 30_000
|
|
2633
2689
|
// The self-hosted carrier has a native path (the child reads its own session), which
|
|
2634
2690
|
// is worth preferring: it reports exactly what the browser persisted.
|
|
2635
|
-
const native = handle
|
|
2691
|
+
const native = handle
|
|
2636
2692
|
if (typeof native.flushAuth === 'function') {
|
|
2637
2693
|
const cookies = await withTimeout(native.flushAuth(), timeoutMs, undefined, `browser: auth export timed out after ${timeoutMs}ms`)
|
|
2638
2694
|
this.record(s, 'flushAuth', {}, true, { result: `${cookies.length} cookies` })
|
|
@@ -2684,7 +2740,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
2684
2740
|
const s = this.session(session)
|
|
2685
2741
|
const { handle } = this.activeTab(s)
|
|
2686
2742
|
const timeoutMs = 30_000
|
|
2687
|
-
const native = handle
|
|
2743
|
+
const native = handle
|
|
2688
2744
|
if (typeof native.restoreAuth === 'function') {
|
|
2689
2745
|
const restored = await withTimeout(native.restoreAuth(cookies), timeoutMs, undefined, `browser: auth restore timed out after ${timeoutMs}ms`)
|
|
2690
2746
|
this.record(s, 'restoreAuth', { count: cookies.length }, true, { result: `${restored} cookies` })
|
|
@@ -2723,7 +2779,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
2723
2779
|
session: BrowserSessionId,
|
|
2724
2780
|
request?: BrowserScreenshotRequest,
|
|
2725
2781
|
signal?: AbortSignal,
|
|
2726
|
-
): Promise<{ readonly dataUrl: string; readonly path?: string }> {
|
|
2782
|
+
): Promise<{ readonly dataUrl: string; readonly path?: string; readonly width?: number; readonly height?: number }> {
|
|
2727
2783
|
const s = this.session(session)
|
|
2728
2784
|
const { handle } = this.activeTab(s)
|
|
2729
2785
|
signal?.throwIfAborted()
|
|
@@ -2734,7 +2790,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
2734
2790
|
// immediately (empty) for hidden ones. JPEG and downscaling are encoded
|
|
2735
2791
|
// in the child from the NativeImage, so this path is the only one that
|
|
2736
2792
|
// can produce JPEG (CDP JPEG hangs on Electron 43).
|
|
2737
|
-
const capturable = handle
|
|
2793
|
+
const capturable = handle
|
|
2738
2794
|
if (request?.fullPage !== true && typeof capturable.capture === 'function') {
|
|
2739
2795
|
// Ensure the target view is the visible one before capturing.
|
|
2740
2796
|
this.showActive(s)
|
|
@@ -2752,7 +2808,17 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
2752
2808
|
if (shot.base64 === '') {
|
|
2753
2809
|
throw new BrowserError('browser: capture returned an empty image (view not painted); retry shortly', 'BROWSER_SCREENSHOT_FAILED')
|
|
2754
2810
|
}
|
|
2755
|
-
return this.saveScreenshot(
|
|
2811
|
+
return this.saveScreenshot(
|
|
2812
|
+
shot.base64,
|
|
2813
|
+
request?.savePath,
|
|
2814
|
+
shot.mime ?? 'image/png',
|
|
2815
|
+
// The child measured these after applying any downscale, and they describe the image
|
|
2816
|
+
// the caller receives. Without them this path — the default self-hosted one — could
|
|
2817
|
+
// never answer the width/height its own tool schema declares.
|
|
2818
|
+
shot.width !== undefined && shot.height !== undefined
|
|
2819
|
+
? { width: shot.width, height: shot.height }
|
|
2820
|
+
: undefined,
|
|
2821
|
+
)
|
|
2756
2822
|
}
|
|
2757
2823
|
// Fallback: a handle without a native capture() (the sidebar, or an installed Chrome
|
|
2758
2824
|
// or Edge) uses CDP; full-page needs `captureBeyondViewport`, which capturePage lacks.
|
|
@@ -2887,8 +2953,14 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
2887
2953
|
const store = this.historyStore
|
|
2888
2954
|
if (store === undefined) return
|
|
2889
2955
|
// The settings panel can switch recording off at runtime; the static config
|
|
2890
|
-
// only supplies the startup default.
|
|
2891
|
-
|
|
2956
|
+
// only supplies the startup default. The RETENTION limits are read here too: they were
|
|
2957
|
+
// parsed and stored but never handed to the store, so editing maxEntries or maxAgeDays in
|
|
2958
|
+
// the file changed nothing — the switch worked and the numbers beside it did not.
|
|
2959
|
+
if (this.settingsSource !== undefined) {
|
|
2960
|
+
const live = this.settingsSource().history
|
|
2961
|
+
if (!live.enabled) return
|
|
2962
|
+
store.updateLimits({ maxEntries: live.maxEntries, maxAgeDays: live.maxAgeDays })
|
|
2963
|
+
}
|
|
2892
2964
|
void handleSendEvaluate(handle, 'location.href + "\\u0000" + (document.title || "")')
|
|
2893
2965
|
.then(result => {
|
|
2894
2966
|
if (!result.ok || typeof result.value !== 'string') return
|
|
@@ -3015,7 +3087,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
3015
3087
|
// the views above; the desktop's sidebar does not, so it is asked explicitly
|
|
3016
3088
|
// — and its cookies plus the browsing history outlive the page either way.
|
|
3017
3089
|
if (this.settingsSource !== undefined && this.settingsSource().ui.closeWithSession) {
|
|
3018
|
-
|
|
3090
|
+
// Named, because one host serves every conversation: an unnamed release would end the
|
|
3091
|
+
// pages of conversations that are still running.
|
|
3092
|
+
void this.host.releasePage?.(existing.label).catch(() => undefined)
|
|
3019
3093
|
}
|
|
3020
3094
|
}
|
|
3021
3095
|
return Promise.resolve()
|
|
@@ -3039,7 +3113,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
3039
3113
|
|
|
3040
3114
|
/** Append a fresh tab and make it active. */
|
|
3041
3115
|
private newTab(s: Session): void {
|
|
3042
|
-
const handle = this.host.createView()
|
|
3116
|
+
const handle = this.host.createView(s.label)
|
|
3043
3117
|
// Same window as the session's other tabs.
|
|
3044
3118
|
this.host.groupView?.(handle, s.id, s.label)
|
|
3045
3119
|
s.tabs.push({ id: `tab:${randomUUID()}`, handle })
|
|
@@ -3144,11 +3218,10 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
3144
3218
|
const message = `action ${action.type} failed: ${String(error)}`
|
|
3145
3219
|
let reported = false
|
|
3146
3220
|
try {
|
|
3147
|
-
const
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
notify.call(host, action.windowId, message)
|
|
3221
|
+
const notify = this.host.notifyUserActionError?.bind(this.host)
|
|
3222
|
+
if (notify !== undefined) {
|
|
3223
|
+
// Bound at the call: an unbound call here kills the host process (issue #16).
|
|
3224
|
+
notify(action.windowId, message)
|
|
3152
3225
|
reported = true
|
|
3153
3226
|
}
|
|
3154
3227
|
} catch (notifyError) {
|
|
@@ -531,9 +531,11 @@ class ElectronChildClient {
|
|
|
531
531
|
/**
|
|
532
532
|
* Drop the escalation kill once there is nothing left for it to target.
|
|
533
533
|
*
|
|
534
|
-
*
|
|
535
|
-
*
|
|
536
|
-
*
|
|
534
|
+
* Called on the paths where the child is known to be gone — after an observed exit, and on
|
|
535
|
+
* release. It is deliberately NOT called from `fail()`: a dead socket says nothing about the
|
|
536
|
+
* process on the other end, and the timer exists for exactly the case where the child ignores
|
|
537
|
+
* the socket closing. Clearing it there disarmed the only escalation there was, which is how
|
|
538
|
+
* a child that ignored its socket could outlive dispose() and keep holding the profile.
|
|
537
539
|
*/
|
|
538
540
|
private clearKillGrace(): void {
|
|
539
541
|
if (this.killGraceTimer === undefined) return
|
|
@@ -558,7 +560,11 @@ class ElectronChildClient {
|
|
|
558
560
|
// kept a live timer until it fired (harmless, but it is a dangling timer in a process
|
|
559
561
|
// that may be shutting down).
|
|
560
562
|
this.clearHelloTimer()
|
|
561
|
-
|
|
563
|
+
// NOT clearKillGrace(): this path runs when the SOCKET died, which says nothing about
|
|
564
|
+
// whether the process did. Clearing the escalation here disarmed it for exactly the case it
|
|
565
|
+
// exists for — a child that ignores its socket closing — so dispose() could return with the
|
|
566
|
+
// child still running and still holding the profile. The timer clears itself when the child
|
|
567
|
+
// exits, and `kill()` replaces it on an explicit release.
|
|
562
568
|
// Tag every failure as "host gone" so the view layer can tell a dead host
|
|
563
569
|
// apart from a page-level error and rebuild against a fresh child.
|
|
564
570
|
const wrapped = err instanceof HostGoneError ? err : new HostGoneError(err.message)
|
|
@@ -1297,10 +1303,17 @@ class DeferredRemoteView implements ElectronViewHandle {
|
|
|
1297
1303
|
private materializeOnce(): Promise<RemoteView> {
|
|
1298
1304
|
if (this.materialized === undefined) {
|
|
1299
1305
|
const pending = this.materialize()
|
|
1300
|
-
|
|
1301
|
-
|
|
1306
|
+
// Compare against the promise actually stored — the derived one — not against `pending`.
|
|
1307
|
+
// Storing `pending.catch(...)` and comparing `this.materialized === pending` compared two
|
|
1308
|
+
// different objects, so the test was never true and the reset never ran: a materialization
|
|
1309
|
+
// that failed with anything other than a HostGoneError (the child alive but not answering
|
|
1310
|
+
// a ping, say) poisoned the handle for good, and the retry the comment below promises was
|
|
1311
|
+
// unreachable.
|
|
1312
|
+
const guarded = pending.catch(error => {
|
|
1313
|
+
if (this.materialized === guarded) this.materialized = undefined
|
|
1302
1314
|
throw error
|
|
1303
1315
|
})
|
|
1316
|
+
this.materialized = guarded
|
|
1304
1317
|
}
|
|
1305
1318
|
return this.materialized
|
|
1306
1319
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The RPC operation vocabulary, in one place.
|
|
3
|
+
*
|
|
4
|
+
* The child (`host-main.ts`) switches on these names and the parent (`remote-host.ts`) sends
|
|
5
|
+
* them. Both spelled them out independently and nothing connected the two, so a rename on one
|
|
6
|
+
* side compiled fine and failed at run time as "unknown op" — and this pair has already
|
|
7
|
+
* produced one silent failure of that shape (`noteStartFailure`'s dedupe comparison, which was
|
|
8
|
+
* never true and meant a log line that never appeared).
|
|
9
|
+
*
|
|
10
|
+
* The lists are the source of truth for a test that reads both switch statements and asserts
|
|
11
|
+
* they agree, which is what makes a one-sided rename fail loudly instead of at run time.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Operations the parent asks the child to perform. */
|
|
15
|
+
export const RPC_OPS = [
|
|
16
|
+
'ping',
|
|
17
|
+
'createView',
|
|
18
|
+
'destroyView',
|
|
19
|
+
'showView',
|
|
20
|
+
'groupView',
|
|
21
|
+
'focus',
|
|
22
|
+
'command',
|
|
23
|
+
'capture',
|
|
24
|
+
'download',
|
|
25
|
+
'flushAuth',
|
|
26
|
+
'restoreAuth',
|
|
27
|
+
'navigate',
|
|
28
|
+
'new-tab',
|
|
29
|
+
'activate',
|
|
30
|
+
'close',
|
|
31
|
+
'back',
|
|
32
|
+
'forward',
|
|
33
|
+
'reload',
|
|
34
|
+
'userActionError',
|
|
35
|
+
] as const
|
|
36
|
+
|
|
37
|
+
/** Notifications the child sends the parent, which carry no reply. */
|
|
38
|
+
export const RPC_NOTIFICATIONS = ['userAction', 'viewClosed'] as const
|
|
39
|
+
|
|
40
|
+
/** One operation name. */
|
|
41
|
+
export type RpcOp = (typeof RPC_OPS)[number]
|
|
@@ -9,17 +9,34 @@
|
|
|
9
9
|
* It is NOT authentication, and cannot be. The plugin has no credential to check: DSH hands
|
|
10
10
|
* plugin routes the raw request (`packages/host/webserver/src/index.ts` registers handlers with
|
|
11
11
|
* no gate of its own), so the strongest same-origin evidence available is what a browser
|
|
12
|
-
* reliably sends
|
|
12
|
+
* reliably sends.
|
|
13
13
|
*
|
|
14
14
|
* What that buys and what it does not:
|
|
15
|
-
* - a plain HTTP client (curl, a script, another process) cannot WRITE
|
|
16
|
-
*
|
|
17
|
-
* - a page of THIS origin
|
|
18
|
-
*
|
|
19
|
-
*
|
|
15
|
+
* - a plain HTTP client (curl, a script, another process) cannot WRITE, because it sends no
|
|
16
|
+
* `user-agent` and no `accept-language`;
|
|
17
|
+
* - a page of THIS origin can, because a browser sending a same-origin write looks exactly
|
|
18
|
+
* like the panel. That is inherent to exposing an HTTP endpoint at all, and it is why the
|
|
19
|
+
* settings panel's own text must not promise a lock that does not exist.
|
|
20
20
|
*/
|
|
21
21
|
import type { IncomingMessage } from 'node:http'
|
|
22
22
|
|
|
23
|
+
/** The shell's own URL schemes. Only the shell can mint a document on one of these. */
|
|
24
|
+
const APP_SCHEMES = ['dsh-app:', 'dsh-desktop:']
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The scheme of a header value, when it names a URL.
|
|
28
|
+
* @param value - a raw header value.
|
|
29
|
+
* @returns the scheme, or undefined when the value is absent, opaque or unparseable.
|
|
30
|
+
*/
|
|
31
|
+
function schemeOf(value: string): string | undefined {
|
|
32
|
+
if (value === '' || value === 'null') return undefined
|
|
33
|
+
try {
|
|
34
|
+
return new URL(value).protocol
|
|
35
|
+
} catch {
|
|
36
|
+
return undefined
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
23
40
|
/**
|
|
24
41
|
* Whether a request may touch the settings document.
|
|
25
42
|
* @param request - the incoming request.
|
|
@@ -27,19 +44,43 @@ import type { IncomingMessage } from 'node:http'
|
|
|
27
44
|
* @returns whether the request is admitted.
|
|
28
45
|
*/
|
|
29
46
|
export function sameOrigin(request: IncomingMessage, writing = false): boolean {
|
|
47
|
+
const origin = String(request.headers.origin ?? '').trim()
|
|
30
48
|
const site = String(request.headers['sec-fetch-site'] ?? '').toLowerCase()
|
|
49
|
+
|
|
50
|
+
// A shell URL, in either the Origin or the referrer: the strongest evidence there is.
|
|
51
|
+
if (APP_SCHEMES.includes(schemeOf(origin) ?? '')) return true
|
|
52
|
+
if (APP_SCHEMES.includes(schemeOf(String(request.headers.referer ?? '')) ?? '')) return true
|
|
53
|
+
|
|
54
|
+
// MEASURED on the running desktop, and what three earlier attempts each guessed wrong: a
|
|
55
|
+
// request from the shell's own settings panel carries NO origin, NO sec-fetch-site and NO
|
|
56
|
+
// referer. It is not "dsh-app://app" and it is not "null" — the headers are simply absent,
|
|
57
|
+
// because Chromium attaches no cross-origin metadata to a request leaving a custom scheme.
|
|
58
|
+
//
|
|
59
|
+
// So the rule for that shape cannot be "a request without an Origin is a script", which is
|
|
60
|
+
// what refused every desktop save, and it cannot lean on `Sec-Fetch-Site` either. It has to
|
|
61
|
+
// test what a hand-rolled client does not send:
|
|
62
|
+
// - a renderer sends a full Mozilla-compatible `user-agent` AND an `accept-language`;
|
|
63
|
+
// - curl sends neither unless told to, and a local process that forges both can also read
|
|
64
|
+
// the settings document directly — it sits readable on disk, as the panel's own text
|
|
65
|
+
// says. This was never the boundary it looked like.
|
|
66
|
+
if (origin === '') {
|
|
67
|
+
const agent = String(request.headers['user-agent'] ?? '')
|
|
68
|
+
const language = String(request.headers['accept-language'] ?? '')
|
|
69
|
+
if (/^Mozilla\/5\.0/.test(agent) && language !== '') return true
|
|
70
|
+
// Reads stay open: they return the document the panel is already showing, and refusing them
|
|
71
|
+
// would only break hand-inspection of a file that sits readable on disk anyway.
|
|
72
|
+
return !writing
|
|
73
|
+
}
|
|
74
|
+
|
|
31
75
|
// `cross-site` is another origin entirely; `same-site` is another origin of the same site
|
|
32
76
|
// (a.example.com -> b.example.com), which is not this panel either. Both are refused.
|
|
33
77
|
if (site === 'cross-site' || site === 'same-site') return false
|
|
34
|
-
|
|
35
|
-
// `Origin: null` is what a sandboxed or opaque-origin document sends. Never this panel.
|
|
78
|
+
// `Origin: null` is a sandboxed or opaque-origin document. Never this panel.
|
|
36
79
|
if (origin === 'null') return false
|
|
37
|
-
// Reads are let through without an Origin: they return the document the panel is already
|
|
38
|
-
// showing, and refusing them would only break hand-inspection of a file that sits readable
|
|
39
|
-
// on disk anyway. Writes are not: a browser always sends `Origin` on one.
|
|
40
|
-
if (origin === '') return !writing
|
|
41
80
|
try {
|
|
42
|
-
|
|
81
|
+
const parsed = new URL(origin)
|
|
82
|
+
// The host comparison is for http(s), where a page and its route share one host.
|
|
83
|
+
return parsed.host.toLowerCase() === String(request.headers.host ?? '').trim().toLowerCase()
|
|
43
84
|
} catch {
|
|
44
85
|
return false
|
|
45
86
|
}
|