dsh-builtin-browser 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +50 -5
- package/README.md +62 -10
- package/client.js +44 -8
- package/desktop-bridge/install.mjs +98 -0
- package/desktop-bridge/plugin-browser-bridge.js +385 -0
- package/lib/browser-electron/bridge-connection.d.ts +58 -0
- package/lib/browser-electron/bridge-connection.js +133 -0
- package/lib/browser-electron/desktop-bridge-host.d.ts +124 -0
- package/lib/browser-electron/desktop-bridge-host.js +297 -0
- package/lib/browser-electron/entry.js +81 -13
- package/lib/browser-electron/history-store.d.ts +5 -1
- package/lib/browser-electron/history-store.js +12 -1
- package/lib/browser-electron/provider.d.ts +42 -1
- package/lib/browser-electron/provider.js +96 -24
- package/lib/browser-electron/remote-host.d.ts +9 -1
- package/lib/browser-electron/remote-host.js +17 -3
- package/lib/browser-electron/settings-store.d.ts +5 -0
- package/lib/browser-electron/settings-store.js +24 -1
- package/lib/browser-electron/system-browser.d.ts +132 -0
- package/lib/browser-electron/system-browser.js +380 -0
- package/lib/browser-electron/virtual-cursor.d.ts +27 -2
- package/lib/browser-electron/virtual-cursor.js +77 -7
- package/lib/tool-browser/index.js +51 -15
- package/package.json +2 -1
- package/src/browser-electron/bridge-connection.ts +145 -0
- package/src/browser-electron/desktop-bridge-host.ts +305 -0
- package/src/browser-electron/entry.ts +84 -13
- package/src/browser-electron/history-store.ts +11 -2
- package/src/browser-electron/provider.ts +124 -25
- package/src/browser-electron/remote-host.ts +17 -2
- package/src/browser-electron/settings-store.ts +30 -1
- package/src/browser-electron/system-browser.ts +371 -0
- package/src/browser-electron/virtual-cursor.ts +76 -6
- package/src/tool-browser/index.ts +51 -15
|
@@ -16,7 +16,12 @@ import z from '@deepseek-ai/schemastery'
|
|
|
16
16
|
import type { BrowserRuntime } from '../browser/runtime.js'
|
|
17
17
|
import { ElectronBrowserProvider } from './provider.js'
|
|
18
18
|
import type { ElectronBrowserViewHost } from './provider.js'
|
|
19
|
+
import { randomUUID } from 'node:crypto'
|
|
20
|
+
import { homedir } from 'node:os'
|
|
21
|
+
import { join } from 'node:path'
|
|
19
22
|
import { defaultHostMainPath, RemoteElectronViewHost } from './remote-host.js'
|
|
23
|
+
import { DesktopBridgeViewHost } from './desktop-bridge-host.js'
|
|
24
|
+
import { detectBrowser, SystemBrowserViewHost } from './system-browser.js'
|
|
20
25
|
import { SettingsStore } from './settings-store.js'
|
|
21
26
|
|
|
22
27
|
export {
|
|
@@ -61,30 +66,96 @@ export const Config: z<Config> = z.object({
|
|
|
61
66
|
|
|
62
67
|
/** Register the Electron browser provider with `ctx.browser`. */
|
|
63
68
|
export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config): void {
|
|
64
|
-
// External host (desktop shell) wins; otherwise self-host. The self-hosted
|
|
65
|
-
// child is disposed with the fiber, mirroring the shell's lifetime.
|
|
66
|
-
const host: ElectronBrowserViewHost = config.viewHost ?? new RemoteElectronViewHost(defaultHostMainPath())
|
|
67
69
|
// One settings document per plugin instance: the settings panel writes it, the
|
|
68
70
|
// provider reads it live, and both ends agree on the same file.
|
|
69
71
|
const settings = new SettingsStore()
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
// (BROWSER_DUPLICATE_PROVIDER) or leave a stale provider behind.
|
|
74
|
-
const unregister = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(host, {
|
|
72
|
+
installSettingsRoute(ctx, settings)
|
|
73
|
+
|
|
74
|
+
const providerConfig = {
|
|
75
75
|
httpOnly: config.httpOnly,
|
|
76
76
|
downloadDir: config.downloadDir,
|
|
77
77
|
snapshotMaxElements: config.snapshotMaxElements,
|
|
78
78
|
contentMaxChars: config.contentMaxChars,
|
|
79
79
|
settings: () => settings.get(),
|
|
80
|
-
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// A host supplied by the composition (the desktop shell's own seam) wins and is
|
|
83
|
+
// synchronous, so registration stays synchronous on that path.
|
|
84
|
+
if (config.viewHost !== undefined) {
|
|
85
|
+
const external = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(config.viewHost as ElectronBrowserViewHost, providerConfig))
|
|
86
|
+
ctx.effect(() => () => { external() })
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Otherwise start self-hosted — a working browser from the first call, on every
|
|
91
|
+
// surface — and upgrade to the desktop sidebar if this machine has one. The
|
|
92
|
+
// upgrade is deliberately a swap of the whole provider: the two carriers own
|
|
93
|
+
// different lifetimes (a spawned child vs. the shell's view), and pretending
|
|
94
|
+
// otherwise would leave a stray window behind.
|
|
95
|
+
const selfHosted = new RemoteElectronViewHost(defaultHostMainPath())
|
|
96
|
+
let unregister = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(selfHosted, providerConfig))
|
|
97
|
+
let upgraded = false
|
|
98
|
+
|
|
81
99
|
ctx.effect(() => () => {
|
|
82
100
|
unregister()
|
|
83
|
-
|
|
84
|
-
host.dispose()
|
|
85
|
-
}
|
|
101
|
+
selfHosted.dispose()
|
|
86
102
|
})
|
|
87
|
-
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Swap the provider to another carrier, releasing the previous one.
|
|
106
|
+
* @param host - the carrier to adopt.
|
|
107
|
+
* @param note - a one-line description for the log.
|
|
108
|
+
*/
|
|
109
|
+
const adopt = (host: ElectronBrowserViewHost, note: string): void => {
|
|
110
|
+
if (upgraded) { host.dispose?.(); return }
|
|
111
|
+
upgraded = true
|
|
112
|
+
try {
|
|
113
|
+
unregister()
|
|
114
|
+
unregister = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(host, providerConfig))
|
|
115
|
+
selfHosted.dispose()
|
|
116
|
+
ctx.logger?.info?.(`dsh-builtin-browser: ${note}`)
|
|
117
|
+
} catch (error) {
|
|
118
|
+
// Never leave the surface without a provider.
|
|
119
|
+
unregister = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(
|
|
120
|
+
new RemoteElectronViewHost(defaultHostMainPath()), providerConfig))
|
|
121
|
+
ctx.logger?.warn?.(`dsh-builtin-browser: could not adopt ${note} (${String(error)})`)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// A browser the user explicitly asked for outranks every automatic choice: the
|
|
126
|
+
// whole point of the setting is that they know which browser they want. It is
|
|
127
|
+
// launched with a plugin-owned profile, so their own windows are untouched.
|
|
128
|
+
//
|
|
129
|
+
// Registration is INERT: constructing the host starts nothing. The browser only
|
|
130
|
+
// launches when a command actually needs a page, so merely running DSH never
|
|
131
|
+
// spawns a browser window. (An earlier version launched it here, which meant a
|
|
132
|
+
// browser appeared the moment the plugin loaded — reported and fixed.)
|
|
133
|
+
const channel = settings.get().browser.channel
|
|
134
|
+
if (channel !== 'bundled') {
|
|
135
|
+
const detected = detectBrowser(channel)
|
|
136
|
+
if (detected === undefined) {
|
|
137
|
+
ctx.logger?.warn?.(`dsh-builtin-browser: ${channel} was requested but no installation was found; using the bundled browser`)
|
|
138
|
+
} else {
|
|
139
|
+
const home = process.env.DSH_HOME ?? homedir()
|
|
140
|
+
// Login state lives in the browser profile, so the cookies setting decides where
|
|
141
|
+
// that profile goes: a stable directory keeps the user signed in across restarts
|
|
142
|
+
// (the point of using their own browser), while turning persistence off gets a
|
|
143
|
+
// throwaway directory that is removed when the browser is released.
|
|
144
|
+
const persist = settings.get().cookies.persist
|
|
145
|
+
const profileDir = persist
|
|
146
|
+
? join(home, 'dsh-builtin-browser-host', `${detected.kind}-profile`)
|
|
147
|
+
: join(home, 'dsh-builtin-browser-host', `${detected.kind}-profile-ephemeral-${randomUUID()}`)
|
|
148
|
+
adopt(new SystemBrowserViewHost(detected, profileDir, [], persist ? undefined : profileDir),
|
|
149
|
+
`will drive the installed ${detected.kind} (${detected.path}${persist ? '' : ', ephemeral profile'})`)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
void DesktopBridgeViewHost.discover().then(sidebar => {
|
|
154
|
+
// No bridge (plain `dsh web`, an older desktop build, or a shell that already
|
|
155
|
+
// exited): keep self-hosting, which is what every surface has always done.
|
|
156
|
+
if (sidebar === undefined) return
|
|
157
|
+
adopt(sidebar, 'driving the desktop sidebar browser')
|
|
158
|
+
}).catch(() => { /* discovery never throws; keep self-hosting */ })
|
|
88
159
|
}
|
|
89
160
|
|
|
90
161
|
/** Route the settings panel reads and writes. */
|
|
@@ -89,15 +89,24 @@ export class HistoryStore {
|
|
|
89
89
|
/**
|
|
90
90
|
* Read visits, newest first. Unparseable lines are skipped; entries older
|
|
91
91
|
* than the age limit are filtered out but left on disk until the next prune.
|
|
92
|
-
* @param options - optional result cap
|
|
92
|
+
* @param options - optional result cap plus three filters: `domain` (hostname),
|
|
93
|
+
* `query` (substring of the URL or title) and `session` (the task that visited
|
|
94
|
+
* it). Filters compose, so "the pages THIS task opened on that host" is one call.
|
|
93
95
|
* @returns the matching visits, newest first.
|
|
94
96
|
*/
|
|
95
|
-
list(options: { readonly limit?: number; readonly domain?: string } = {}): VisitedPage[] {
|
|
97
|
+
list(options: { readonly limit?: number; readonly domain?: string; readonly query?: string; readonly session?: string } = {}): VisitedPage[] {
|
|
96
98
|
const all = this.readAll()
|
|
97
99
|
const cutoff = Date.now() - this.maxAgeMs
|
|
98
100
|
const domain = options.domain?.toLowerCase()
|
|
101
|
+
const query = options.query?.toLowerCase()
|
|
102
|
+
const session = options.session
|
|
99
103
|
const matches = all.filter(page => {
|
|
100
104
|
if (page.at < cutoff) return false
|
|
105
|
+
if (session !== undefined && page.session !== session) return false
|
|
106
|
+
if (query !== undefined) {
|
|
107
|
+
const haystack = `${page.url}\n${page.title ?? ''}`.toLowerCase()
|
|
108
|
+
if (!haystack.includes(query)) return false
|
|
109
|
+
}
|
|
101
110
|
if (domain === undefined) return true
|
|
102
111
|
try {
|
|
103
112
|
return new URL(page.url).hostname.toLowerCase().includes(domain)
|
|
@@ -13,7 +13,7 @@ import { homedir } from 'node:os'
|
|
|
13
13
|
import { dirname, isAbsolute, join, resolve, sep } from 'node:path'
|
|
14
14
|
import { HistoryStore, type VisitedPage } from './history-store.js'
|
|
15
15
|
import type { BrowserSettings } from './settings-store.js'
|
|
16
|
-
import { paintCursor, type CursorAction } from './virtual-cursor.js'
|
|
16
|
+
import { forgetCursor, paintCursor, type CursorAction } from './virtual-cursor.js'
|
|
17
17
|
import type {
|
|
18
18
|
BrowserA11yRequest,
|
|
19
19
|
BrowserA11yResult,
|
|
@@ -185,6 +185,31 @@ export interface ElectronBrowserViewHost {
|
|
|
185
185
|
* @param handler - called with the group (session) key; must not throw.
|
|
186
186
|
*/
|
|
187
187
|
onViewClosed?(handler: (windowId: string) => void): void
|
|
188
|
+
/**
|
|
189
|
+
* Release the service's page(s) without ending the plugin's own lifetime.
|
|
190
|
+
*
|
|
191
|
+
* Only carriers that outlive a browser session need this. A self-hosted window
|
|
192
|
+
* dies with its view, but the desktop shell's sidebar keeps running no matter
|
|
193
|
+
* what the plugin does — so "release the browser when the session ends"
|
|
194
|
+
* (settings: ui.closeWithSession) has to be asked for explicitly there.
|
|
195
|
+
* @returns a promise that settles once the release was attempted.
|
|
196
|
+
*/
|
|
197
|
+
releasePage?(): Promise<void>
|
|
198
|
+
/**
|
|
199
|
+
* Fold the carrier's presentation away while keeping the page alive.
|
|
200
|
+
*
|
|
201
|
+
* The desktop sidebar keeps running regardless of the plugin, so "do not take
|
|
202
|
+
* over the screen" (settings: ui.autoExpandOnce = false) can only be honoured by
|
|
203
|
+
* asking the shell to fold it. A self-hosted window has no such state and simply
|
|
204
|
+
* does not implement this.
|
|
205
|
+
* @returns a promise that settles once the fold was attempted.
|
|
206
|
+
*/
|
|
207
|
+
collapse?(): Promise<void>
|
|
208
|
+
/**
|
|
209
|
+
* Release everything the carrier owns (a spawned browser, a socket, a child).
|
|
210
|
+
* Called when the plugin stops using it, never while it is still in service.
|
|
211
|
+
*/
|
|
212
|
+
dispose?(): void
|
|
188
213
|
}
|
|
189
214
|
|
|
190
215
|
/**
|
|
@@ -670,6 +695,11 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
670
695
|
private async settleDocument(handle: ElectronViewHandle, before: string, signal?: AbortSignal): Promise<void> {
|
|
671
696
|
const started = Date.now()
|
|
672
697
|
const deadline = started + SETTLE_TIMEOUT_MS
|
|
698
|
+
// The document is being replaced, so the synthetic pointer drawn into the old one
|
|
699
|
+
// is gone. Dropping the remembered position is what lets it be painted again:
|
|
700
|
+
// without this, a paint at coincidentally identical coordinates would be skipped
|
|
701
|
+
// and the pointer would never come back after a navigation.
|
|
702
|
+
forgetCursor(handle)
|
|
673
703
|
for (;;) {
|
|
674
704
|
if (signal?.aborted === true) return
|
|
675
705
|
// A probe failure is expected while the navigation commits (the context is
|
|
@@ -1180,21 +1210,39 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1180
1210
|
// iframes stay opaque — the browser forbids reading them).
|
|
1181
1211
|
let content = ''
|
|
1182
1212
|
if (fmt === 'txt') {
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1213
|
+
// Prefer the browser's own rendered text.
|
|
1214
|
+
//
|
|
1215
|
+
// Walking text nodes and joining them is not equivalent: pages that wrap
|
|
1216
|
+
// every character in its own element — one-character spans for a
|
|
1217
|
+
// per-character animation, which is common — produce one text node per
|
|
1218
|
+
// letter, and joining those with spaces turns every word into a column of
|
|
1219
|
+
// letters. The rendered text reassembles them the way a reader sees them,
|
|
1220
|
+
// and it also honours visibility, so hidden text does not leak out.
|
|
1221
|
+
// (No backticks in this comment: it lives inside a template literal.)
|
|
1222
|
+
const rendered = typeof root.innerText === 'string' ? root.innerText : ''
|
|
1223
|
+
if (rendered.trim() !== '') {
|
|
1224
|
+
content = rendered
|
|
1225
|
+
} else {
|
|
1226
|
+
// Fallback for nodes without layout (a detached root, or a fragment):
|
|
1227
|
+
// collect text directly, still piercing shadow roots and same-origin
|
|
1228
|
+
// iframes, and join without inventing separators beyond what the markup
|
|
1229
|
+
// already implies.
|
|
1230
|
+
const parts = []
|
|
1231
|
+
const textWalk = (node) => {
|
|
1232
|
+
if (node.nodeType === Node.TEXT_NODE) { const t = (node.textContent || '').trim(); if (t) parts.push(t); return }
|
|
1233
|
+
if (node.nodeType !== Node.ELEMENT_NODE) return
|
|
1234
|
+
const tag = node.tagName.toLowerCase()
|
|
1235
|
+
if (tag === 'script' || tag === 'style' || tag === 'noscript') return
|
|
1236
|
+
if (tag === 'iframe') {
|
|
1237
|
+
try { const d = node.contentDocument; if (d && d.body) for (const c of d.body.childNodes) textWalk(c) } catch { /* cross-origin */ }
|
|
1238
|
+
return
|
|
1239
|
+
}
|
|
1240
|
+
if (node.shadowRoot) for (const c of node.shadowRoot.childNodes) textWalk(c)
|
|
1241
|
+
for (const child of node.childNodes) textWalk(child)
|
|
1192
1242
|
}
|
|
1193
|
-
|
|
1194
|
-
|
|
1243
|
+
textWalk(root)
|
|
1244
|
+
content = parts.join(' ')
|
|
1195
1245
|
}
|
|
1196
|
-
textWalk(root)
|
|
1197
|
-
content = parts.join(' ')
|
|
1198
1246
|
}
|
|
1199
1247
|
else if (fmt === 'html') content = root.outerHTML || ''
|
|
1200
1248
|
else if (fmt === 'json') content = JSON.stringify(root)
|
|
@@ -1270,6 +1318,18 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1270
1318
|
x = Number(out.x)
|
|
1271
1319
|
y = Number(out.y)
|
|
1272
1320
|
} else {
|
|
1321
|
+
// A coordinate only exists because somebody read a picture. Under the
|
|
1322
|
+
// non-visual strategy the plugin must not accept one: a model that cannot
|
|
1323
|
+
// see cannot have produced it, so the coordinates are either copied from an
|
|
1324
|
+
// earlier vision pass or invented, and honouring them turns a silent
|
|
1325
|
+
// mis-click into a mystery. Refusing here, with a message that says what to
|
|
1326
|
+
// do instead, converts that into a usable instruction.
|
|
1327
|
+
if (this.settingsSource !== undefined && this.settingsSource().vision.strategy === 'nonVisual') {
|
|
1328
|
+
throw new BrowserError(
|
|
1329
|
+
'browser: the configured non-visual strategy refuses coordinate clicks — pass a semantic target instead, e.g. target { by: "text", value: "Sign in" }, which locates the element from the DOM and clicks its centre',
|
|
1330
|
+
'BROWSER_NON_VISUAL_COORDINATES',
|
|
1331
|
+
)
|
|
1332
|
+
}
|
|
1273
1333
|
x = request.x
|
|
1274
1334
|
y = request.y
|
|
1275
1335
|
}
|
|
@@ -1298,7 +1358,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1298
1358
|
}
|
|
1299
1359
|
// Paint before the press so the ripple is already on screen when the click
|
|
1300
1360
|
// lands — the cursor exists to show the human WHERE the agent acts.
|
|
1301
|
-
|
|
1361
|
+
// Name the operation in the overlay: the pointer is the "agent is driving this
|
|
1362
|
+
// tab" signal, so the bubble is what lets a human follow along without the log.
|
|
1363
|
+
this.showCursor(handle, x, y, 'click', 'click', true)
|
|
1302
1364
|
const release = (): void => {
|
|
1303
1365
|
void handle
|
|
1304
1366
|
.sendCommand('Input.dispatchMouseEvent', {
|
|
@@ -1351,7 +1413,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1351
1413
|
const locateTab = this.activeTab(s)
|
|
1352
1414
|
const out = await this.runTargetScript(locateTab, script, locateMs, signal, 'BROWSER_TYPE_FAILED', 'browser: type')
|
|
1353
1415
|
const cursorPoint = scriptPoint(out)
|
|
1354
|
-
if (cursorPoint !== undefined) this.showCursor(locateTab.handle, cursorPoint.x, cursorPoint.y, 'click')
|
|
1416
|
+
if (cursorPoint !== undefined) this.showCursor(locateTab.handle, cursorPoint.x, cursorPoint.y, 'click', 'type', true)
|
|
1355
1417
|
}
|
|
1356
1418
|
const text = 'text' in request ? request.text : ''
|
|
1357
1419
|
const timeoutMs = 30_000
|
|
@@ -2274,7 +2336,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
2274
2336
|
* @param options - result cap and an optional hostname filter.
|
|
2275
2337
|
* @returns the matching visits, or an empty list when history is disabled.
|
|
2276
2338
|
*/
|
|
2277
|
-
visited(options: { readonly limit?: number; readonly domain?: string } = {}): readonly VisitedPage[] {
|
|
2339
|
+
visited(options: { readonly limit?: number; readonly domain?: string; readonly query?: string; readonly session?: string } = {}): readonly VisitedPage[] {
|
|
2278
2340
|
return this.historyStore?.list(options) ?? []
|
|
2279
2341
|
}
|
|
2280
2342
|
|
|
@@ -2287,10 +2349,13 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
2287
2349
|
* @param x - viewport x in CSS pixels.
|
|
2288
2350
|
* @param y - viewport y in CSS pixels.
|
|
2289
2351
|
* @param action - click pulses a ripple; move only relocates.
|
|
2352
|
+
* @param label - short description of the operation, shown beside the pointer.
|
|
2353
|
+
* @param force - paint even when the pointer is already there (a click ripple must
|
|
2354
|
+
* always play; a bare move need not repeat).
|
|
2290
2355
|
*/
|
|
2291
|
-
private showCursor(handle: ElectronViewHandle, x: number, y: number, action: CursorAction): void {
|
|
2356
|
+
private showCursor(handle: ElectronViewHandle, x: number, y: number, action: CursorAction, label?: string, force = false): void {
|
|
2292
2357
|
if (this.settingsSource !== undefined && !this.settingsSource().ui.virtualCursor) return
|
|
2293
|
-
paintCursor(handle, x, y, action, (target, expression) => handleSendEvaluate(target, expression))
|
|
2358
|
+
paintCursor(handle, x, y, action, (target, expression) => handleSendEvaluate(target, expression), label, force)
|
|
2294
2359
|
}
|
|
2295
2360
|
|
|
2296
2361
|
/**
|
|
@@ -2370,6 +2435,13 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
2370
2435
|
if (existing !== undefined) {
|
|
2371
2436
|
this.sessions.delete(session)
|
|
2372
2437
|
for (const tab of existing.tabs) this.host.destroyView(tab.handle)
|
|
2438
|
+
// Releasing the browser at session end is a setting, not fixed behaviour
|
|
2439
|
+
// (requirements §3). A self-hosted carrier already tore its window down with
|
|
2440
|
+
// the views above; the desktop's sidebar does not, so it is asked explicitly
|
|
2441
|
+
// — and its cookies plus the browsing history outlive the page either way.
|
|
2442
|
+
if (this.settingsSource !== undefined && this.settingsSource().ui.closeWithSession) {
|
|
2443
|
+
void this.host.releasePage?.().catch(() => undefined)
|
|
2444
|
+
}
|
|
2373
2445
|
}
|
|
2374
2446
|
return Promise.resolve()
|
|
2375
2447
|
}
|
|
@@ -2481,19 +2553,46 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
2481
2553
|
}
|
|
2482
2554
|
}
|
|
2483
2555
|
|
|
2484
|
-
/**
|
|
2556
|
+
/**
|
|
2557
|
+
* Report a failed user action to the host UI (toolbar), when supported.
|
|
2558
|
+
*
|
|
2559
|
+
* Two properties matter here, because this runs inside the catch of an async
|
|
2560
|
+
* handler (issue #16):
|
|
2561
|
+
* - the receiver must be preserved. Reading the method off the host and calling
|
|
2562
|
+
* it unbound runs the host's implementation with `this === undefined`, so its
|
|
2563
|
+
* very first statement (`void this.ready()`) throws, the throw escapes the
|
|
2564
|
+
* async catch as an unhandled rejection, and the whole DSH host exits;
|
|
2565
|
+
* - this method must be incapable of throwing. Reporting a failed action is
|
|
2566
|
+
* diagnostics: it can never be allowed to become the failure.
|
|
2567
|
+
*/
|
|
2485
2568
|
private notifyUserActionError(action: BrowserUserAction, error: unknown): void {
|
|
2486
|
-
const notify = (this.host as { notifyUserActionError?(windowId: string, message: string): void }).notifyUserActionError
|
|
2487
2569
|
const message = `action ${action.type} failed: ${String(error)}`
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2570
|
+
let reported = false
|
|
2571
|
+
try {
|
|
2572
|
+
const host = this.host as { notifyUserActionError?(windowId: string, message: string): void }
|
|
2573
|
+
const notify = host.notifyUserActionError
|
|
2574
|
+
if (typeof notify === 'function') {
|
|
2575
|
+
// Called on its owner: an unbound call here kills the host process.
|
|
2576
|
+
notify.call(host, action.windowId, message)
|
|
2577
|
+
reported = true
|
|
2578
|
+
}
|
|
2579
|
+
} catch (notifyError) {
|
|
2580
|
+
// Contained on purpose; the log line below still carries the original cause.
|
|
2581
|
+
process.stderr.write(`[dsh-browser] host notify failed: ${String(notifyError)}\n`)
|
|
2492
2582
|
}
|
|
2583
|
+
if (!reported) process.stderr.write(`[dsh-browser] user action failed: ${message}\n`)
|
|
2493
2584
|
}
|
|
2494
2585
|
|
|
2495
2586
|
/** Ask the host to show the active tab's view, carrying the session label. */
|
|
2496
2587
|
private showActive(s: Session): void {
|
|
2588
|
+
// §3: whether the agent's page takes over the screen is the user's choice, not
|
|
2589
|
+
// a fixed behaviour. With auto-expand off the page still exists and is still
|
|
2590
|
+
// driven — it is simply folded away, so the agent works without claiming the
|
|
2591
|
+
// sidebar. Carriers that own no presentation (self-hosted) ignore the call.
|
|
2592
|
+
if (this.settingsSource !== undefined && !this.settingsSource().ui.autoExpandOnce) {
|
|
2593
|
+
void this.host.collapse?.().catch(() => undefined)
|
|
2594
|
+
return
|
|
2595
|
+
}
|
|
2497
2596
|
this.host.showView?.(this.activeTab(s).handle, s.label)
|
|
2498
2597
|
}
|
|
2499
2598
|
|
|
@@ -799,12 +799,27 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
|
|
|
799
799
|
return this.electronAvailable
|
|
800
800
|
}
|
|
801
801
|
|
|
802
|
-
/**
|
|
802
|
+
/**
|
|
803
|
+
* Ensure the child is up and ready (lazy on first use; restarts after a crash).
|
|
804
|
+
*
|
|
805
|
+
* Never throws synchronously. Callers are fire-and-forget
|
|
806
|
+
* (`void this.ready().then(…).catch(…)`), and a synchronous throw escapes their
|
|
807
|
+
* `.catch` entirely — the shape that let a failed toolbar report take the whole
|
|
808
|
+
* DSH host down (issue #16). A disposed host is reported as a rejected promise
|
|
809
|
+
* instead, which those callers already handle.
|
|
810
|
+
*/
|
|
803
811
|
private ready(): Promise<void> {
|
|
804
812
|
// Fail fast on a disposed host: a recovery path can re-enter ready() after
|
|
805
813
|
// dispose() cleared its state, and starting fresh here would spawn a child
|
|
806
814
|
// nobody will ever kill.
|
|
807
|
-
if (this.disposed)
|
|
815
|
+
if (this.disposed) {
|
|
816
|
+
const gone = Promise.reject(new HostGoneError('browser host disposed'))
|
|
817
|
+
// Keep the rejection observable to callers while marking it handled here:
|
|
818
|
+
// a fire-and-forget caller that forgot to attach a handler must not turn
|
|
819
|
+
// this into an unhandled rejection either.
|
|
820
|
+
gone.catch(() => undefined)
|
|
821
|
+
return gone
|
|
822
|
+
}
|
|
808
823
|
if (this.readyPromise !== undefined) return this.readyPromise
|
|
809
824
|
const started = this.start()
|
|
810
825
|
const wrapped = started.catch(error => {
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
|
13
13
|
import { homedir } from 'node:os'
|
|
14
14
|
import { dirname, join } from 'node:path'
|
|
15
|
+
import type { BrowserChannel } from './system-browser.js'
|
|
15
16
|
|
|
16
17
|
/** Vision/operation strategy the provider prefers when the model can see. */
|
|
17
18
|
export type VisionStrategy = 'auto' | 'nonVisual'
|
|
@@ -41,6 +42,10 @@ export interface BrowserSettings {
|
|
|
41
42
|
readonly vision: {
|
|
42
43
|
readonly strategy: VisionStrategy
|
|
43
44
|
}
|
|
45
|
+
/** Which browser binary carries the agent's pages. */
|
|
46
|
+
readonly browser: {
|
|
47
|
+
readonly channel: BrowserChannel
|
|
48
|
+
}
|
|
44
49
|
/** Whether the agent may read cookies / export login state. */
|
|
45
50
|
readonly credentials: {
|
|
46
51
|
readonly allowRead: boolean
|
|
@@ -53,6 +58,10 @@ export const DEFAULT_SETTINGS: BrowserSettings = {
|
|
|
53
58
|
cookies: { persist: true },
|
|
54
59
|
ui: { autoExpandOnce: true, closeWithSession: false, virtualCursor: true },
|
|
55
60
|
vision: { strategy: 'auto' },
|
|
61
|
+
// Bundled by default: it needs nothing installed, and on the desktop the carrier
|
|
62
|
+
// is the sidebar anyway. Choosing a system browser is an explicit opt-in because
|
|
63
|
+
// it starts a real Chrome/Edge with its own profile.
|
|
64
|
+
browser: { channel: 'bundled' },
|
|
56
65
|
credentials: { allowRead: true },
|
|
57
66
|
}
|
|
58
67
|
|
|
@@ -71,6 +80,18 @@ function bool(value: unknown, fallback: boolean): boolean {
|
|
|
71
80
|
return typeof value === 'boolean' ? value : fallback
|
|
72
81
|
}
|
|
73
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Coerce the browser channel, keeping the default when it is not one of the
|
|
85
|
+
* known choices. A hand-edited typo must not silently select a different browser.
|
|
86
|
+
* @param value - the raw value from the settings document.
|
|
87
|
+
* @returns a valid channel.
|
|
88
|
+
*/
|
|
89
|
+
function resolveChannel(value: unknown): BrowserChannel {
|
|
90
|
+
return value === 'chrome' || value === 'edge' || value === 'auto' || value === 'bundled'
|
|
91
|
+
? value
|
|
92
|
+
: DEFAULT_SETTINGS.browser.channel
|
|
93
|
+
}
|
|
94
|
+
|
|
74
95
|
/** Coerce one positive-integer field, keeping the default when invalid. */
|
|
75
96
|
function count(value: unknown, fallback: number): number {
|
|
76
97
|
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback
|
|
@@ -96,6 +117,7 @@ export function resolveSettings(raw: unknown): BrowserSettings {
|
|
|
96
117
|
const ui = section(source, 'ui')
|
|
97
118
|
const vision = section(source, 'vision')
|
|
98
119
|
const credentials = section(source, 'credentials')
|
|
120
|
+
const browser = section(source, 'browser')
|
|
99
121
|
return {
|
|
100
122
|
history: {
|
|
101
123
|
enabled: bool(history.enabled, DEFAULT_SETTINGS.history.enabled),
|
|
@@ -109,6 +131,7 @@ export function resolveSettings(raw: unknown): BrowserSettings {
|
|
|
109
131
|
virtualCursor: bool(ui.virtualCursor, DEFAULT_SETTINGS.ui.virtualCursor),
|
|
110
132
|
},
|
|
111
133
|
vision: { strategy: vision.strategy === 'nonVisual' ? 'nonVisual' : DEFAULT_SETTINGS.vision.strategy },
|
|
134
|
+
browser: { channel: resolveChannel(browser.channel) },
|
|
112
135
|
credentials: { allowRead: bool(credentials.allowRead, DEFAULT_SETTINGS.credentials.allowRead) },
|
|
113
136
|
}
|
|
114
137
|
}
|
|
@@ -153,7 +176,13 @@ export class SettingsStore {
|
|
|
153
176
|
}
|
|
154
177
|
if (this.cached !== undefined && mtimeMs === this.cachedMtimeMs) return this.cached
|
|
155
178
|
try {
|
|
156
|
-
|
|
179
|
+
// A leading BOM is what ordinary Windows editors (Notepad, PowerShell's
|
|
180
|
+
// `Set-Content -Encoding utf8`) leave behind, and `JSON.parse` rejects it.
|
|
181
|
+
// Without this strip, hand-editing the settings file silently discarded every
|
|
182
|
+
// value in it: the document looked correct, and the plugin quietly ran on
|
|
183
|
+
// defaults — including switches the user had just turned off.
|
|
184
|
+
const body = text?.replace(/^\uFEFF/, '')
|
|
185
|
+
this.cached = body === undefined ? DEFAULT_SETTINGS : resolveSettings(JSON.parse(body))
|
|
157
186
|
} catch {
|
|
158
187
|
// Malformed JSON: behave as defaults rather than fail startup.
|
|
159
188
|
this.cached = DEFAULT_SETTINGS
|