dsh-builtin-browser 0.4.6 → 0.4.7
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 +48 -0
- 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.js +185 -102
- package/lib/browser-electron/provider.d.ts +24 -0
- package/lib/browser-electron/provider.js +87 -6
- package/lib/browser-electron/settings-store.d.ts +10 -2
- package/lib/browser-electron/settings-store.js +29 -9
- package/lib/tool-browser/index.js +36 -1
- package/package.json +1 -1
- 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 +99 -7
- package/src/browser-electron/provider.ts +124 -20
- package/src/browser-electron/settings-store.ts +28 -9
- package/src/tool-browser/index.ts +34 -1
|
@@ -68,16 +68,34 @@ const SETTINGS_WHEN_UNREADABLE = {
|
|
|
68
68
|
credentials: { allowRead: false },
|
|
69
69
|
actions: { allowExecute: false, allowDownload: false, allowCredentialWrite: false },
|
|
70
70
|
};
|
|
71
|
+
/**
|
|
72
|
+
* The channels a settings document may name.
|
|
73
|
+
*
|
|
74
|
+
* Exported so the log line and the error message can quote the same set the parser accepts: a
|
|
75
|
+
* message that lists a value the code rejects (or omits one it takes) is worse than no message.
|
|
76
|
+
*/
|
|
77
|
+
export const BROWSER_CHANNELS = ['chrome', 'edge', 'auto', 'bundled', 'sidebar'];
|
|
71
78
|
/**
|
|
72
79
|
* Coerce the browser channel, keeping the default when it is not one of the
|
|
73
80
|
* known choices. A hand-edited typo must not silently select a different browser.
|
|
81
|
+
*
|
|
82
|
+
* Coercing is right; coercing QUIETLY is not. A typo and "the setting did nothing" look exactly
|
|
83
|
+
* the same to whoever typed it, which is why every discarded value is reported.
|
|
74
84
|
* @param value - the raw value from the settings document.
|
|
85
|
+
* @param warn - where to report a discarded value, when the caller has somewhere to put it.
|
|
75
86
|
* @returns a valid channel.
|
|
76
87
|
*/
|
|
77
|
-
function resolveChannel(value) {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
88
|
+
function resolveChannel(value, warn) {
|
|
89
|
+
// Absent is the ordinary case, not a mistake: a document that never mentioned the channel gets
|
|
90
|
+
// the default without a word.
|
|
91
|
+
if (value === undefined)
|
|
92
|
+
return DEFAULT_SETTINGS.browser.channel;
|
|
93
|
+
if (BROWSER_CHANNELS.includes(value))
|
|
94
|
+
return value;
|
|
95
|
+
warn?.(`dsh-builtin-browser: browser.channel ${JSON.stringify(value)} is not a known channel, so the `
|
|
96
|
+
+ `default "${DEFAULT_SETTINGS.browser.channel}" was used instead. Expected one of: `
|
|
97
|
+
+ `${BROWSER_CHANNELS.join(', ')}.`);
|
|
98
|
+
return DEFAULT_SETTINGS.browser.channel;
|
|
81
99
|
}
|
|
82
100
|
/** Coerce one positive-integer field, keeping the default when invalid. */
|
|
83
101
|
function count(value, fallback) {
|
|
@@ -119,7 +137,7 @@ function section(source, key) {
|
|
|
119
137
|
* @param raw - the parsed file contents.
|
|
120
138
|
* @returns the resolved settings.
|
|
121
139
|
*/
|
|
122
|
-
export function resolveSettings(raw) {
|
|
140
|
+
export function resolveSettings(raw, warn) {
|
|
123
141
|
const source = typeof raw === 'object' && raw !== null ? raw : {};
|
|
124
142
|
const history = section(source, 'history');
|
|
125
143
|
const cookies = section(source, 'cookies');
|
|
@@ -146,7 +164,7 @@ export function resolveSettings(raw) {
|
|
|
146
164
|
virtualCursor: bool(ui?.virtualCursor, DEFAULT_SETTINGS.ui.virtualCursor),
|
|
147
165
|
},
|
|
148
166
|
vision: { strategy: vision?.strategy === 'nonVisual' ? 'nonVisual' : DEFAULT_SETTINGS.vision.strategy },
|
|
149
|
-
browser: { channel: resolveChannel(browser?.channel) },
|
|
167
|
+
browser: { channel: resolveChannel(browser?.channel, warn) },
|
|
150
168
|
credentials: {
|
|
151
169
|
// `gate` returns false for anything present-and-not-a-boolean, so an unreadable section
|
|
152
170
|
// is refused here for free: the section itself is the "present but wrong" value.
|
|
@@ -175,14 +193,16 @@ export function resolveSettings(raw) {
|
|
|
175
193
|
*/
|
|
176
194
|
export class SettingsStore {
|
|
177
195
|
file;
|
|
196
|
+
log;
|
|
178
197
|
cached;
|
|
179
198
|
/** Last observed mtime, used to invalidate the cache on external edits. */
|
|
180
199
|
cachedMtimeMs = -1;
|
|
181
200
|
/**
|
|
182
201
|
* @param file - settings file path (defaults beside the browser profile).
|
|
183
202
|
*/
|
|
184
|
-
constructor(file = settingsPath()) {
|
|
203
|
+
constructor(file = settingsPath(), log) {
|
|
185
204
|
this.file = file;
|
|
205
|
+
this.log = log;
|
|
186
206
|
}
|
|
187
207
|
/** Path this store reads and writes. */
|
|
188
208
|
path() {
|
|
@@ -230,7 +250,7 @@ export class SettingsStore {
|
|
|
230
250
|
// value in it: the document looked correct, and the plugin quietly ran on
|
|
231
251
|
// defaults — including switches the user had just turned off.
|
|
232
252
|
const body = text?.replace(/^\uFEFF/, '');
|
|
233
|
-
this.cached = body === undefined ? DEFAULT_SETTINGS : resolveSettings(JSON.parse(body));
|
|
253
|
+
this.cached = body === undefined ? DEFAULT_SETTINGS : resolveSettings(JSON.parse(body), m => this.log?.(m));
|
|
234
254
|
}
|
|
235
255
|
catch {
|
|
236
256
|
// A document that EXISTS but cannot be parsed is not the same as no document at all: the
|
|
@@ -250,7 +270,7 @@ export class SettingsStore {
|
|
|
250
270
|
*/
|
|
251
271
|
update(patch) {
|
|
252
272
|
const current = this.get();
|
|
253
|
-
const merged = resolveSettings(deepMerge(current, patch));
|
|
273
|
+
const merged = resolveSettings(deepMerge(current, patch), m => this.log?.(m));
|
|
254
274
|
try {
|
|
255
275
|
mkdirSync(dirname(this.file), { recursive: true });
|
|
256
276
|
writeFileAtomic(this.file, `${JSON.stringify(merged, null, 2)}\n`);
|
|
@@ -98,6 +98,40 @@ function assertAllowed(state, toolName, task) {
|
|
|
98
98
|
function agentOf(exec) {
|
|
99
99
|
return exec?.agent;
|
|
100
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* Refuse the bare DSH web root, which answers 401 and looks like a real page.
|
|
103
|
+
*
|
|
104
|
+
* The address comes from `DSH_WEB_URL`, which the harness exports to its children — NOT from a
|
|
105
|
+
* hardcoded port. This machine's GUI has run on 3080 and on 19387, so a number would stop matching
|
|
106
|
+
* exactly when the guard is needed, and the trap is not a port anyway: it is the SHAPE. A loopback
|
|
107
|
+
* address at its root with no token renders as an unauthenticated 401 page, which an agent reads
|
|
108
|
+
* as the site it meant to open.
|
|
109
|
+
* @param rawUrl - the address a caller asked to open.
|
|
110
|
+
*/
|
|
111
|
+
function assertNotBareDshWebUrl(rawUrl) {
|
|
112
|
+
const advertised = process.env.DSH_WEB_URL;
|
|
113
|
+
if (typeof advertised !== 'string' || advertised === '')
|
|
114
|
+
return;
|
|
115
|
+
let target;
|
|
116
|
+
let dsh;
|
|
117
|
+
try {
|
|
118
|
+
target = new URL(rawUrl);
|
|
119
|
+
dsh = new URL(advertised);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return; // not a URL this guard can judge; the provider's own admission is the next gate
|
|
123
|
+
}
|
|
124
|
+
if (target.origin !== dsh.origin)
|
|
125
|
+
return;
|
|
126
|
+
if (target.pathname !== '/')
|
|
127
|
+
return;
|
|
128
|
+
// The authenticated URL printed by `dsh web` carries a token; the bare root does not.
|
|
129
|
+
if (target.searchParams.has('token'))
|
|
130
|
+
return;
|
|
131
|
+
throw new Error('browser_open: refusing the bare DSH web root (' + dsh.origin + '/) because it carries no '
|
|
132
|
+
+ 'authentication token, so it answers with a 401 page that reads like a real site. Open the '
|
|
133
|
+
+ 'target site instead; to open DSH itself, use the authenticated URL printed by `dsh web`.');
|
|
134
|
+
}
|
|
101
135
|
/**
|
|
102
136
|
* Read a target's `index`, refusing the values that cannot mean anything.
|
|
103
137
|
*
|
|
@@ -263,7 +297,7 @@ export function apply(ctx, config = {}) {
|
|
|
263
297
|
});
|
|
264
298
|
ctx.tools.register(defineTool({
|
|
265
299
|
name: 'browser_open',
|
|
266
|
-
description: 'Open a URL in the shared browser window. Opens this task\'s browser session on first use; optionally opens in a new tab. Returns the resulting page snapshot.',
|
|
300
|
+
description: 'Open a URL in the shared browser window. Opens this task\'s browser session on first use; optionally opens in a new tab. Returns the resulting page snapshot. A fresh session starts on a blank, uncommitted tab: call this with the REAL target URL before reading, because reading that tab returns an empty page instead of waiting. Never treat the bare DSH host (e.g. http://127.0.0.1:3080/) as the target website — it requires the authenticated URL printed by `dsh web`, and the bare root is refused outright.',
|
|
267
301
|
parameters: {
|
|
268
302
|
url: { type: 'string', required: true, description: 'The URL to open (HTTP/HTTPS).' },
|
|
269
303
|
newTab: { type: 'boolean', description: 'Open in a new tab instead of the active one.' },
|
|
@@ -302,6 +336,7 @@ export function apply(ctx, config = {}) {
|
|
|
302
336
|
isConcurrencySafe: () => false, // opens tabs / navigates; exclusive within a task
|
|
303
337
|
async execute(args, exec) {
|
|
304
338
|
assertAllowed(state, 'browser_open', taskKey(exec));
|
|
339
|
+
assertNotBareDshWebUrl(args.url);
|
|
305
340
|
const browser = ctx.get('browser');
|
|
306
341
|
if (browser === undefined)
|
|
307
342
|
throw new Error('tool-browser: browser service unavailable');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-builtin-browser",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.7",
|
|
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 (browser_auth), CAPTCHA detection, batch form fill (browser_fill), authenticated download, history/replay, action restriction",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -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
|
/**
|
|
@@ -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
|
}
|