dsh-builtin-browser 0.4.6 → 0.4.8
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 +76 -0
- package/README.en.md +62 -491
- package/README.md +44 -470
- package/client.js +10 -10
- package/cordis.patch.yml +4 -4
- package/desktop-bridge/plugin-browser-bridge.js +1 -1
- package/docs/README.md +8 -4
- package/docs/architecture.md +82 -11
- package/docs/development.md +38 -0
- package/docs/limitations.md +49 -0
- package/docs/tool-reference.md +42 -42
- package/docs/user-guide.md +93 -37
- package/docs/why-browser.md +3 -3
- package/lib/browser/types.d.ts +2 -2
- package/lib/browser-electron/bridge-connection.d.ts +7 -1
- package/lib/browser-electron/bridge-connection.js +58 -3
- package/lib/browser-electron/desktop-bridge-host.d.ts +11 -0
- package/lib/browser-electron/desktop-bridge-host.js +21 -1
- package/lib/browser-electron/entry.d.ts +2 -2
- package/lib/browser-electron/entry.js +186 -103
- package/lib/browser-electron/history-store.d.ts +1 -1
- package/lib/browser-electron/history-store.js +1 -1
- package/lib/browser-electron/host-main.js +3 -3
- package/lib/browser-electron/provider.d.ts +32 -8
- package/lib/browser-electron/provider.js +97 -16
- package/lib/browser-electron/remote-host.js +1 -1
- package/lib/browser-electron/settings-store.d.ts +14 -6
- package/lib/browser-electron/settings-store.js +29 -9
- package/lib/index.d.ts +1 -1
- package/lib/index.js +1 -1
- package/lib/tool-browser/element-fields.d.ts +4 -4
- package/lib/tool-browser/element-fields.js +6 -6
- package/lib/tool-browser/index.d.ts +6 -6
- package/lib/tool-browser/index.js +196 -139
- package/package.json +2 -2
- package/scripts/verify-host-compat.mjs +6 -6
- package/src/browser/types.ts +2 -2
- package/src/browser-electron/bridge-connection.ts +61 -3
- package/src/browser-electron/desktop-bridge-host.ts +22 -1
- package/src/browser-electron/entry.ts +101 -9
- package/src/browser-electron/history-store.ts +1 -1
- package/src/browser-electron/host-main.ts +3 -3
- package/src/browser-electron/provider.ts +135 -31
- package/src/browser-electron/remote-host.ts +1 -1
- package/src/browser-electron/settings-store.ts +32 -13
- package/src/index.ts +1 -1
- package/src/tool-browser/element-fields.ts +6 -6
- package/src/tool-browser/index.ts +197 -140
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-builtin-browser",
|
|
3
|
-
"version": "0.4.
|
|
4
|
-
"description": "Shared real browser plugin for DeepSeek Harness: install-and-use — a visible native browser the human can take over, driven by the agent over CDP. DOM-level interaction (React/Vue safe), per-task session isolation, cookie persistence (
|
|
3
|
+
"version": "0.4.8",
|
|
4
|
+
"description": "Shared real browser plugin for DeepSeek Harness: install-and-use — a visible native browser the human can take over, driven by the agent over CDP. DOM-level interaction (React/Vue safe), per-task session isolation, cookie persistence (dsh_browser_auth), CAPTCHA detection, batch form fill (dsh_browser_fill), authenticated download, history/replay, action restriction",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/index.d.ts",
|
|
@@ -169,20 +169,20 @@ try {
|
|
|
169
169
|
|
|
170
170
|
const exec = { agent: { id: 'verify-host-compat' } }
|
|
171
171
|
try {
|
|
172
|
-
const value = await registered.get('
|
|
173
|
-
ok('
|
|
172
|
+
const value = await registered.get('dsh_browser_session').execute({}, exec)
|
|
173
|
+
ok('dsh_browser_session ran', value !== undefined, JSON.stringify(value).slice(0, 100))
|
|
174
174
|
} catch (error) {
|
|
175
|
-
ok('
|
|
175
|
+
ok('dsh_browser_session ran', false, String(error && error.message))
|
|
176
176
|
}
|
|
177
177
|
|
|
178
178
|
try {
|
|
179
|
-
const tool = registered.get('
|
|
179
|
+
const tool = registered.get('dsh_browser_a11y')
|
|
180
180
|
const value = await tool.execute({}, exec)
|
|
181
181
|
const text = JSON.stringify(tool.output.render({}, value))
|
|
182
|
-
ok('
|
|
182
|
+
ok('dsh_browser_a11y ran and rendered', value?.nodes?.length === 1, '')
|
|
183
183
|
ok('its renderer emits the element selector', text.includes('#probe'), text.slice(0, 120))
|
|
184
184
|
} catch (error) {
|
|
185
|
-
ok('
|
|
185
|
+
ok('dsh_browser_a11y ran', false, String(error && error.message))
|
|
186
186
|
}
|
|
187
187
|
|
|
188
188
|
console.log('')
|
package/src/browser/types.ts
CHANGED
|
@@ -65,7 +65,7 @@ export interface BrowserScrollRequest {
|
|
|
65
65
|
|
|
66
66
|
/** Press one named key (Enter, Tab, arrows, …). */
|
|
67
67
|
export interface BrowserKeyRequest {
|
|
68
|
-
/** Key name from the supported set (see the
|
|
68
|
+
/** Key name from the supported set (see the dsh_browser_key tool). */
|
|
69
69
|
readonly key: string
|
|
70
70
|
}
|
|
71
71
|
|
|
@@ -361,7 +361,7 @@ export interface BrowserSnapshotElement {
|
|
|
361
361
|
/**
|
|
362
362
|
* True when the element lives inside an (same-origin) iframe: coordinates
|
|
363
363
|
* are still top-document, but DOM selectors are frame-scoped — reach the
|
|
364
|
-
* element via `iframe.contentDocument` in
|
|
364
|
+
* element via `iframe.contentDocument` in dsh_browser_execute.
|
|
365
365
|
*/
|
|
366
366
|
readonly frame?: boolean
|
|
367
367
|
}
|
|
@@ -51,6 +51,16 @@ const MAX_BUFFER_BYTES = 128 * 1024 * 1024
|
|
|
51
51
|
*/
|
|
52
52
|
const REQUEST_ID_FIELD = 'bridgeRequestId'
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* How long the published endpoint may go un-refreshed before its shell is presumed gone.
|
|
56
|
+
*
|
|
57
|
+
* A live shell rewrites the file every fifteen seconds (its keep-alive), so four missed refreshes
|
|
58
|
+
* is not a slow shell — it is a shell that exited and left its last write behind. The distinction
|
|
59
|
+
* matters because the file is the ONLY thing that says where the bridge is: a tombstone names a
|
|
60
|
+
* port nobody owns, and a client that trusts it dials that port for the rest of its life.
|
|
61
|
+
*/
|
|
62
|
+
const ENDPOINT_TTL_MS = 60_000
|
|
63
|
+
|
|
54
64
|
/**
|
|
55
65
|
* A reusable connection to the bridge.
|
|
56
66
|
*
|
|
@@ -78,12 +88,19 @@ export class BridgeConnection {
|
|
|
78
88
|
private idLessBridge = false
|
|
79
89
|
/** True once this connection may only have one request in flight at a time. */
|
|
80
90
|
private serialisable = false
|
|
91
|
+
/** True once the shell behind this connection has been reported gone. */
|
|
92
|
+
private lostNotified = false
|
|
81
93
|
|
|
82
94
|
/**
|
|
83
95
|
* @param endpoint - the shell's published bridge endpoint.
|
|
84
96
|
* @param endpointPath - where that endpoint is republished, so a moved port can be followed.
|
|
97
|
+
* @param onLost - called once when this connection concludes the shell behind it is gone.
|
|
85
98
|
*/
|
|
86
|
-
constructor(
|
|
99
|
+
constructor(
|
|
100
|
+
private endpoint: BridgeEndpoint,
|
|
101
|
+
private readonly endpointPath?: string,
|
|
102
|
+
private readonly onLost?: () => void,
|
|
103
|
+
) {}
|
|
87
104
|
|
|
88
105
|
/**
|
|
89
106
|
* Re-read the published endpoint, and adopt it if the shell has moved.
|
|
@@ -103,6 +120,13 @@ export class BridgeConnection {
|
|
|
103
120
|
try {
|
|
104
121
|
const raw = JSON.parse(readFileSync(this.endpointPath, 'utf8')) as Partial<BridgeEndpoint>
|
|
105
122
|
if (typeof raw.port !== 'number' || typeof raw.token !== 'string') return false
|
|
123
|
+
// A shell that exited stops refreshing the file, and what it left names a port nobody owns.
|
|
124
|
+
// Trusting it is how a host ends up dialling a dead port for the rest of the process's life
|
|
125
|
+
// — reported as "the browser stopped working and only restarting DSH brought it back".
|
|
126
|
+
if (endpointExpired(raw.updatedAt)) {
|
|
127
|
+
this.notifyLost()
|
|
128
|
+
return false
|
|
129
|
+
}
|
|
106
130
|
if (raw.port === this.endpoint.port && raw.token === this.endpoint.token) return false
|
|
107
131
|
this.endpoint = {
|
|
108
132
|
port: raw.port,
|
|
@@ -117,6 +141,17 @@ export class BridgeConnection {
|
|
|
117
141
|
}
|
|
118
142
|
}
|
|
119
143
|
|
|
144
|
+
/** Report once that the shell is gone, so the surface can fall back instead of dialling it. */
|
|
145
|
+
private notifyLost(): void {
|
|
146
|
+
if (this.lostNotified) return
|
|
147
|
+
this.lostNotified = true
|
|
148
|
+
try {
|
|
149
|
+
this.onLost?.()
|
|
150
|
+
} catch {
|
|
151
|
+
// A listener must never break the transport: the caller's error is the one that matters.
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
120
155
|
/**
|
|
121
156
|
* Send one request, reusing the socket if it is still healthy.
|
|
122
157
|
*
|
|
@@ -138,6 +173,16 @@ export class BridgeConnection {
|
|
|
138
173
|
// when the condition could not clear, spun until the process ran out of heap.
|
|
139
174
|
// Follow the shell if its bridge moved to a new port (see refreshEndpoint).
|
|
140
175
|
this.refreshEndpoint()
|
|
176
|
+
// The shell behind this connection is gone, and the endpoint file says so. Refusing here is
|
|
177
|
+
// what turns a permanent ECONNREFUSED into one named error the surface can act on — by
|
|
178
|
+
// falling back to the browser it hosts itself, instead of dialling a dead port until DSH is
|
|
179
|
+
// restarted.
|
|
180
|
+
if (this.lostNotified) {
|
|
181
|
+
throw new Error(
|
|
182
|
+
'dsh-builtin-browser: the desktop shell that backed this browser has exited '
|
|
183
|
+
+ '(its published endpoint stopped being refreshed)',
|
|
184
|
+
)
|
|
185
|
+
}
|
|
141
186
|
let socket = this.ensureSocket()
|
|
142
187
|
if (this.serialisable && this.queue.length > 0) {
|
|
143
188
|
this.reset(new Error('dsh-builtin-browser: bridge connection reopened so replies stay attributable'))
|
|
@@ -288,8 +333,21 @@ export class BridgeConnection {
|
|
|
288
333
|
}
|
|
289
334
|
}
|
|
290
335
|
|
|
291
|
-
/**
|
|
292
|
-
|
|
336
|
+
/**
|
|
337
|
+
* Whether a published endpoint has gone un-refreshed long enough to be a tombstone.
|
|
338
|
+
*
|
|
339
|
+
* A missing or unparseable stamp is NOT expired: an older bridge that never wrote one must keep
|
|
340
|
+
* working, and guessing "stale" from absence would break every shell that predates the field.
|
|
341
|
+
* @param updatedAt - the file's own refresh stamp, when it carries one.
|
|
342
|
+
*/
|
|
343
|
+
function endpointExpired(updatedAt: unknown): boolean {
|
|
344
|
+
if (typeof updatedAt !== 'string') return false
|
|
345
|
+
const at = Date.parse(updatedAt)
|
|
346
|
+
if (!Number.isFinite(at)) return false
|
|
347
|
+
return Date.now() - at > ENDPOINT_TTL_MS
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** The request id a raw answer line echoes back, when it carries one. */function echoOf(line: string): number | undefined {
|
|
293
351
|
try {
|
|
294
352
|
const answer = JSON.parse(line) as Record<string, unknown>
|
|
295
353
|
const value = answer[REQUEST_ID_FIELD]
|
|
@@ -118,13 +118,34 @@ export class DesktopBridgeViewHost implements ElectronBrowserViewHost {
|
|
|
118
118
|
* the desktop sidebar is the carrier — by design, not by omission.
|
|
119
119
|
*/
|
|
120
120
|
private userActionHandler: ((action: BrowserUserAction) => void) | undefined
|
|
121
|
+
/** Told when the shell behind this bridge exits; see `onLost`. */
|
|
122
|
+
private lostListener: (() => void) | undefined
|
|
121
123
|
|
|
122
124
|
/**
|
|
123
125
|
* @param endpoint - the shell's published bridge endpoint.
|
|
124
126
|
*/
|
|
125
127
|
private constructor(endpoint: BridgeEndpoint, sessionId?: string) {
|
|
126
128
|
this.owner = sessionId !== undefined && sessionId !== String.fromCharCode(39,39) ? sessionId : randomUUID()
|
|
127
|
-
this.connection = new BridgeConnection(endpoint, bridgeEndpointPath())
|
|
129
|
+
this.connection = new BridgeConnection(endpoint, bridgeEndpointPath(), () => this.reportLost())
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Called by the transport once the shell behind it is gone.
|
|
134
|
+
*
|
|
135
|
+
* Forwarded rather than acted on here: only the entry point owns the swap back to the self-hosted
|
|
136
|
+
* browser, and a host that tried to replace itself would be reaching outside its own contract.
|
|
137
|
+
*/
|
|
138
|
+
private reportLost(): void {
|
|
139
|
+
try {
|
|
140
|
+
this.lostListener?.()
|
|
141
|
+
} catch {
|
|
142
|
+
// The listener's failure is not the transport's problem.
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Register the "this carrier is gone" listener (see the interface's `onLost`). */
|
|
147
|
+
onLost(listener: () => void): void {
|
|
148
|
+
this.lostListener = listener
|
|
128
149
|
}
|
|
129
150
|
|
|
130
151
|
/**
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* `ctx.electronViewHost`, that host is used (embedded, human-machine shared
|
|
6
6
|
* view). Otherwise the plugin self-hosts: it spawns its own Electron child
|
|
7
7
|
* (`host-main.js`) and drives it over a local TCP JSON-RPC socket, so
|
|
8
|
-
* installing the plugin is enough for `
|
|
8
|
+
* installing the plugin is enough for `dsh_browser_*` tools to work on any
|
|
9
9
|
* surface.
|
|
10
10
|
* @module dsh-browser/browser-electron
|
|
11
11
|
*/
|
|
@@ -48,7 +48,7 @@ export interface Config {
|
|
|
48
48
|
/** Allow navigation only to HTTP(S) URLs. Default true. */
|
|
49
49
|
readonly httpOnly?: boolean
|
|
50
50
|
/**
|
|
51
|
-
* Directory `
|
|
51
|
+
* Directory `dsh_browser_download` save paths must resolve inside (prevents a
|
|
52
52
|
* prompt-injected agent from writing arbitrary machine paths). Default:
|
|
53
53
|
* the user's Downloads folder; override to confine downloads elsewhere.
|
|
54
54
|
*/
|
|
@@ -112,9 +112,10 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
|
|
|
112
112
|
const sessionId = readSessionId(ctx)
|
|
113
113
|
|
|
114
114
|
// One settings document per plugin instance: the settings panel writes it, the
|
|
115
|
-
// provider reads it live, and both ends agree on the same file.
|
|
116
|
-
|
|
117
|
-
|
|
115
|
+
// provider reads it live, and both ends agree on the same file. The logger is passed so a
|
|
116
|
+
// discarded value (a mistyped channel, most of all) is reported instead of being silently
|
|
117
|
+
// replaced by the default — "my setting did nothing" and "I misspelled it" must not look alike.
|
|
118
|
+
const settings = new SettingsStore(undefined, message => ctx.logger?.warn?.(message))
|
|
118
119
|
|
|
119
120
|
const providerConfig = {
|
|
120
121
|
httpOnly: config.httpOnly,
|
|
@@ -129,6 +130,17 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
|
|
|
129
130
|
if (config.viewHost !== undefined) {
|
|
130
131
|
const external = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(config.viewHost as ElectronBrowserViewHost, providerConfig))
|
|
131
132
|
ctx.effect(() => () => { external() })
|
|
133
|
+
// The composition supplied the carrier, so `browser.channel` is not ours to honour on this
|
|
134
|
+
// surface. Saying so is the difference between a setting that cannot apply and a setting that
|
|
135
|
+
// appears not to have worked — the second is what sends people looking for a bug elsewhere.
|
|
136
|
+
const asked = settings.get().browser.channel
|
|
137
|
+
if (asked !== 'sidebar' && asked !== 'bundled') {
|
|
138
|
+
ctx.logger?.warn?.(
|
|
139
|
+
`dsh-builtin-browser: browser.channel is "${asked}", but this surface's carrier was `
|
|
140
|
+
+ 'supplied by its composition (the desktop shell), which outranks the setting. Set it to '
|
|
141
|
+
+ '"sidebar" to record the intent, or run on a surface that picks its own carrier.',
|
|
142
|
+
)
|
|
143
|
+
}
|
|
132
144
|
return
|
|
133
145
|
}
|
|
134
146
|
|
|
@@ -140,25 +152,69 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
|
|
|
140
152
|
const selfHosted = new RemoteElectronViewHost(defaultHostMainPath())
|
|
141
153
|
let unregister = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(selfHosted, providerConfig))
|
|
142
154
|
let upgraded = false
|
|
155
|
+
/** The carrier an `adopt` installed, so a later choice can release it. */
|
|
156
|
+
let adoptedCarrier: ElectronBrowserViewHost | undefined
|
|
143
157
|
|
|
144
158
|
ctx.effect(() => () => {
|
|
145
159
|
unregister()
|
|
146
160
|
selfHosted.dispose()
|
|
147
161
|
})
|
|
148
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Put the self-hosted browser back after an adopted carrier reports that it is gone.
|
|
165
|
+
*
|
|
166
|
+
* This is the half the startup retries never covered: a bridge that was alive at apply time and
|
|
167
|
+
* exited later left every subsequent call dialling a port nobody owns, because the swap happened
|
|
168
|
+
* once and only in one direction. Reaching here means the shell is gone, so the choice made at
|
|
169
|
+
* startup is no longer a choice — the alternative to falling back is a browser surface that does
|
|
170
|
+
* nothing until DSH is restarted.
|
|
171
|
+
* @param adopted - the carrier that reported the loss, disposed once its replacement is in place.
|
|
172
|
+
*/
|
|
173
|
+
const restoreSelfHosted = (adopted: ElectronBrowserViewHost): void => {
|
|
174
|
+
try {
|
|
175
|
+
const restored = new RemoteElectronViewHost(defaultHostMainPath())
|
|
176
|
+
unregister()
|
|
177
|
+
unregister = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(restored, providerConfig))
|
|
178
|
+
ctx.logger?.warn?.(
|
|
179
|
+
'dsh-builtin-browser: the desktop shell backed the sidebar browser and has exited, so the '
|
|
180
|
+
+ 'browser this plugin hosts itself takes over. Pages from the sidebar are gone with it; '
|
|
181
|
+
+ 'open the page again.',
|
|
182
|
+
)
|
|
183
|
+
} catch (error) {
|
|
184
|
+
// Nothing better is available: the adopted carrier is gone and a provider that throws is
|
|
185
|
+
// worse than one that explains.
|
|
186
|
+
ctx.logger?.warn?.(`dsh-builtin-browser: could not fall back to the self-hosted browser: ${String(error)}`)
|
|
187
|
+
} finally {
|
|
188
|
+
adopted.dispose?.()
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
149
192
|
/**
|
|
150
193
|
* Swap the provider to another carrier, releasing the previous one.
|
|
151
194
|
* @param host - the carrier to adopt.
|
|
152
195
|
* @param note - a one-line description for the log.
|
|
153
196
|
*/
|
|
154
197
|
const adopt = (host: ElectronBrowserViewHost, note: string): void => {
|
|
155
|
-
|
|
198
|
+
// This guard used to make the FIRST choice final, disposing anything that arrived later. That
|
|
199
|
+
// is right against two carriers racing in, and exactly wrong for a user changing the setting:
|
|
200
|
+
// `chooseCarrier` runs again on purpose, and a refusal here is indistinguishable from the
|
|
201
|
+
// write having done nothing. The previous carrier is now released by the replacement instead.
|
|
156
202
|
upgraded = true
|
|
157
203
|
try {
|
|
158
204
|
unregister()
|
|
159
205
|
unregister = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(host, providerConfig))
|
|
160
206
|
selfHosted.dispose()
|
|
161
207
|
ctx.logger?.info?.(`dsh-builtin-browser: ${note}`)
|
|
208
|
+
// A borrowed carrier can lose the process behind it, and this is the only notification that
|
|
209
|
+
// will ever arrive. Registering AFTER the swap succeeded means a listener can never fire
|
|
210
|
+
// against a provider that was never installed.
|
|
211
|
+
host.onLost?.(() => { restoreSelfHosted(host) })
|
|
212
|
+
const previous = adoptedCarrier
|
|
213
|
+
adoptedCarrier = host
|
|
214
|
+
// Released AFTER the replacement is installed, so the surface is never without a provider.
|
|
215
|
+
if (previous !== undefined && previous !== host) {
|
|
216
|
+
try { previous.dispose?.() } catch { /* the replacement is already serving */ }
|
|
217
|
+
}
|
|
162
218
|
} catch (error) {
|
|
163
219
|
// Never leave the surface without a provider. The half-built host is disposed FIRST: an
|
|
164
220
|
// adopt that got as far as constructing it may already hold a socket, a listener or a child
|
|
@@ -184,6 +240,16 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
|
|
|
184
240
|
}
|
|
185
241
|
}
|
|
186
242
|
|
|
243
|
+
/**
|
|
244
|
+
* Choose and install the carrier that `browser.channel` names RIGHT NOW.
|
|
245
|
+
*
|
|
246
|
+
* A function rather than straight-line code because the setting is read live everywhere else:
|
|
247
|
+
* the settings route writes the file and the provider picks the change up on its next call, so a
|
|
248
|
+
* channel that could only be honoured at apply time was the one setting whose write silently did
|
|
249
|
+
* nothing. Re-entrant by design — changing the channel means replacing the carrier, which is
|
|
250
|
+
* exactly what a second call does.
|
|
251
|
+
*/
|
|
252
|
+
const chooseCarrier = (): void => {
|
|
187
253
|
// A browser the user explicitly asked for outranks every automatic choice: the
|
|
188
254
|
// whole point of the setting is that they know which browser they want. It is
|
|
189
255
|
// launched with a plugin-owned profile, so their own windows are untouched.
|
|
@@ -281,6 +347,14 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
|
|
|
281
347
|
)
|
|
282
348
|
}
|
|
283
349
|
})()
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
chooseCarrier()
|
|
353
|
+
|
|
354
|
+
// Wired AFTER the chooser exists, because the handler re-runs it: a settings write that changes
|
|
355
|
+
// `browser.channel` is a request to switch carriers, and answering `ok` without switching is the
|
|
356
|
+
// one thing a caller cannot tell apart from a write that worked.
|
|
357
|
+
installSettingsRoute(ctx, settings, () => { chooseCarrier() })
|
|
284
358
|
}
|
|
285
359
|
|
|
286
360
|
/** Route the settings panel reads and writes. */
|
|
@@ -353,7 +427,11 @@ interface WebServerHost {
|
|
|
353
427
|
* @param ctx - the plugin context.
|
|
354
428
|
* @param settings - the settings document to expose.
|
|
355
429
|
*/
|
|
356
|
-
function installSettingsRoute(
|
|
430
|
+
function installSettingsRoute(
|
|
431
|
+
ctx: Context,
|
|
432
|
+
settings: SettingsStore,
|
|
433
|
+
onChannelChange?: () => void,
|
|
434
|
+
): void {
|
|
357
435
|
const inject = (ctx as unknown as {
|
|
358
436
|
inject?: (deps: readonly string[], callback: (host: WebServerHost) => void) => void
|
|
359
437
|
}).inject
|
|
@@ -363,7 +441,7 @@ function installSettingsRoute(ctx: Context, settings: SettingsStore): void {
|
|
|
363
441
|
host.effect(() => host.webServer.register({
|
|
364
442
|
kind: 'exact',
|
|
365
443
|
path: SETTINGS_ROUTE,
|
|
366
|
-
handler: (request, response) => handleSettingsRequest(request, response, settings),
|
|
444
|
+
handler: (request, response) => handleSettingsRequest(request, response, settings, onChannelChange),
|
|
367
445
|
}, 'dsh-builtin-browser: settings'))
|
|
368
446
|
})
|
|
369
447
|
} catch {
|
|
@@ -377,11 +455,13 @@ function installSettingsRoute(ctx: Context, settings: SettingsStore): void {
|
|
|
377
455
|
* @param request - the incoming request.
|
|
378
456
|
* @param response - the response to write.
|
|
379
457
|
* @param settings - the settings document.
|
|
458
|
+
* @param onChannelChange - re-runs the carrier choice when this write changed `browser.channel`.
|
|
380
459
|
*/
|
|
381
460
|
async function handleSettingsRequest(
|
|
382
461
|
request: IncomingMessage,
|
|
383
462
|
response: ServerResponse,
|
|
384
463
|
settings: SettingsStore,
|
|
464
|
+
onChannelChange?: () => void,
|
|
385
465
|
): Promise<void> {
|
|
386
466
|
const json = (status: number, body: unknown): void => {
|
|
387
467
|
response.writeHead(status, {
|
|
@@ -407,7 +487,19 @@ async function handleSettingsRequest(
|
|
|
407
487
|
try {
|
|
408
488
|
const body = await readBody(request)
|
|
409
489
|
const patch: unknown = body.trim() === '' ? {} : JSON.parse(body)
|
|
410
|
-
|
|
490
|
+
const before = settings.get().browser.channel
|
|
491
|
+
const updated = settings.update(patch)
|
|
492
|
+
// A changed `browser.channel` is a request to switch carriers, not just a value to store. The
|
|
493
|
+
// choice is re-run here so the write takes effect now — and the response says so, because a
|
|
494
|
+
// caller that cannot tell "applied" from "stored for later" has no way to know what it got,
|
|
495
|
+
// which is exactly how a write that did nothing read as a success.
|
|
496
|
+
const channelChanged = updated.browser.channel !== before
|
|
497
|
+
if (channelChanged) onChannelChange?.()
|
|
498
|
+
json(200, {
|
|
499
|
+
ok: true,
|
|
500
|
+
settings: updated,
|
|
501
|
+
...channelChanged ? { carrierRechosen: true } : {},
|
|
502
|
+
})
|
|
411
503
|
} catch (error) {
|
|
412
504
|
json(400, { ok: false, error: `invalid settings patch: ${String(error)}` })
|
|
413
505
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Persistent browsing history: which pages the shared browser actually visited,
|
|
3
|
-
* kept independently from any session's operation log (`
|
|
3
|
+
* kept independently from any session's operation log (`dsh_browser_history`) and
|
|
4
4
|
* from the browser process's lifetime.
|
|
5
5
|
*
|
|
6
6
|
* It lives beside the browser profile (`$DSH_HOME/dsh-builtin-browser-host/
|
|
@@ -41,7 +41,7 @@ import { dirname, join } from 'node:path'
|
|
|
41
41
|
// several Electron instances sharing Roaming\Electron fight over the GPU
|
|
42
42
|
// cache/session locks, which can leave the window without a display surface
|
|
43
43
|
// (capturePage then fails). A dedicated userData also persists cookies across
|
|
44
|
-
// host restarts (on top of
|
|
44
|
+
// host restarts (on top of dsh_browser_auth). Must run before app is ready.
|
|
45
45
|
try {
|
|
46
46
|
const base = process.env.DSH_HOME ?? app.getPath('appData')
|
|
47
47
|
app.setPath('userData', join(base, 'dsh-builtin-browser-host'))
|
|
@@ -769,7 +769,7 @@ async function handle(op: string, msg: { id: number; viewId?: string; windowId?:
|
|
|
769
769
|
const entry = win?.views.get(viewId)
|
|
770
770
|
if (win === undefined || entry === undefined) throw new Error(`focus: unknown view ${viewId}`)
|
|
771
771
|
// Keys go to the view that holds web focus. A view that was never
|
|
772
|
-
// clicked holds none, so the FIRST
|
|
772
|
+
// clicked holds none, so the FIRST dsh_browser_key of a session was dropped
|
|
773
773
|
// by the renderer while CDP still answered the dispatch with success —
|
|
774
774
|
// and `document.hasFocus()` inside the page even read true.
|
|
775
775
|
const contents = entry.webContentsView.webContents
|
|
@@ -881,7 +881,7 @@ async function handle(op: string, msg: { id: number; viewId?: string; windowId?:
|
|
|
881
881
|
throw new Error('capture produced no image (view not painted)')
|
|
882
882
|
}
|
|
883
883
|
// The measured size travels with the image. Without it the native path — which is the
|
|
884
|
-
// default self-hosted one — could never answer `
|
|
884
|
+
// default self-hosted one — could never answer `dsh_browser_screenshot`'s width/height,
|
|
885
885
|
// because the seam's only channel was {base64, mime} and the provider had nothing to
|
|
886
886
|
// forward. The caller's `maxWidth`/`maxHeight` are applied here, so this is the only
|
|
887
887
|
// place that knows what came back.
|