dsh-builtin-browser 0.4.5 → 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 +102 -0
- package/client.js +19 -4
- package/desktop-bridge/plugin-browser-bridge.js +17 -6
- 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 +20 -0
- package/lib/browser-electron/entry.js +221 -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 +137 -8
- 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
|
@@ -12,8 +12,9 @@
|
|
|
12
12
|
import z from '@deepseek-ai/schemastery';
|
|
13
13
|
import { ElectronBrowserProvider } from './provider.js';
|
|
14
14
|
import { randomUUID } from 'node:crypto';
|
|
15
|
+
import { existsSync, mkdirSync, renameSync } from 'node:fs';
|
|
15
16
|
import { homedir } from 'node:os';
|
|
16
|
-
import { join } from 'node:path';
|
|
17
|
+
import { dirname, join } from 'node:path';
|
|
17
18
|
import { defaultHostMainPath, RemoteElectronViewHost } from './remote-host.js';
|
|
18
19
|
import { DesktopBridgeViewHost } from './desktop-bridge-host.js';
|
|
19
20
|
import { detectBrowser, searchSummary, SystemBrowserViewHost } from './system-browser.js';
|
|
@@ -80,9 +81,10 @@ export function apply(ctx, config) {
|
|
|
80
81
|
// a sessionId). One conversation per process, so the list holds this one.
|
|
81
82
|
const sessionId = readSessionId(ctx);
|
|
82
83
|
// One settings document per plugin instance: the settings panel writes it, the
|
|
83
|
-
// provider reads it live, and both ends agree on the same file.
|
|
84
|
-
|
|
85
|
-
|
|
84
|
+
// provider reads it live, and both ends agree on the same file. The logger is passed so a
|
|
85
|
+
// discarded value (a mistyped channel, most of all) is reported instead of being silently
|
|
86
|
+
// replaced by the default — "my setting did nothing" and "I misspelled it" must not look alike.
|
|
87
|
+
const settings = new SettingsStore(undefined, message => ctx.logger?.warn?.(message));
|
|
86
88
|
const providerConfig = {
|
|
87
89
|
httpOnly: config.httpOnly,
|
|
88
90
|
downloadDir: config.downloadDir,
|
|
@@ -95,6 +97,15 @@ export function apply(ctx, config) {
|
|
|
95
97
|
if (config.viewHost !== undefined) {
|
|
96
98
|
const external = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(config.viewHost, providerConfig));
|
|
97
99
|
ctx.effect(() => () => { external(); });
|
|
100
|
+
// The composition supplied the carrier, so `browser.channel` is not ours to honour on this
|
|
101
|
+
// surface. Saying so is the difference between a setting that cannot apply and a setting that
|
|
102
|
+
// appears not to have worked — the second is what sends people looking for a bug elsewhere.
|
|
103
|
+
const asked = settings.get().browser.channel;
|
|
104
|
+
if (asked !== 'sidebar' && asked !== 'bundled') {
|
|
105
|
+
ctx.logger?.warn?.(`dsh-builtin-browser: browser.channel is "${asked}", but this surface's carrier was `
|
|
106
|
+
+ 'supplied by its composition (the desktop shell), which outranks the setting. Set it to '
|
|
107
|
+
+ '"sidebar" to record the intent, or run on a surface that picks its own carrier.');
|
|
108
|
+
}
|
|
98
109
|
return;
|
|
99
110
|
}
|
|
100
111
|
// Otherwise start self-hosted — a working browser from the first call, on every
|
|
@@ -105,26 +116,69 @@ export function apply(ctx, config) {
|
|
|
105
116
|
const selfHosted = new RemoteElectronViewHost(defaultHostMainPath());
|
|
106
117
|
let unregister = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(selfHosted, providerConfig));
|
|
107
118
|
let upgraded = false;
|
|
119
|
+
/** The carrier an `adopt` installed, so a later choice can release it. */
|
|
120
|
+
let adoptedCarrier;
|
|
108
121
|
ctx.effect(() => () => {
|
|
109
122
|
unregister();
|
|
110
123
|
selfHosted.dispose();
|
|
111
124
|
});
|
|
125
|
+
/**
|
|
126
|
+
* Put the self-hosted browser back after an adopted carrier reports that it is gone.
|
|
127
|
+
*
|
|
128
|
+
* This is the half the startup retries never covered: a bridge that was alive at apply time and
|
|
129
|
+
* exited later left every subsequent call dialling a port nobody owns, because the swap happened
|
|
130
|
+
* once and only in one direction. Reaching here means the shell is gone, so the choice made at
|
|
131
|
+
* startup is no longer a choice — the alternative to falling back is a browser surface that does
|
|
132
|
+
* nothing until DSH is restarted.
|
|
133
|
+
* @param adopted - the carrier that reported the loss, disposed once its replacement is in place.
|
|
134
|
+
*/
|
|
135
|
+
const restoreSelfHosted = (adopted) => {
|
|
136
|
+
try {
|
|
137
|
+
const restored = new RemoteElectronViewHost(defaultHostMainPath());
|
|
138
|
+
unregister();
|
|
139
|
+
unregister = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(restored, providerConfig));
|
|
140
|
+
ctx.logger?.warn?.('dsh-builtin-browser: the desktop shell backed the sidebar browser and has exited, so the '
|
|
141
|
+
+ 'browser this plugin hosts itself takes over. Pages from the sidebar are gone with it; '
|
|
142
|
+
+ 'open the page again.');
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
// Nothing better is available: the adopted carrier is gone and a provider that throws is
|
|
146
|
+
// worse than one that explains.
|
|
147
|
+
ctx.logger?.warn?.(`dsh-builtin-browser: could not fall back to the self-hosted browser: ${String(error)}`);
|
|
148
|
+
}
|
|
149
|
+
finally {
|
|
150
|
+
adopted.dispose?.();
|
|
151
|
+
}
|
|
152
|
+
};
|
|
112
153
|
/**
|
|
113
154
|
* Swap the provider to another carrier, releasing the previous one.
|
|
114
155
|
* @param host - the carrier to adopt.
|
|
115
156
|
* @param note - a one-line description for the log.
|
|
116
157
|
*/
|
|
117
158
|
const adopt = (host, note) => {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
159
|
+
// This guard used to make the FIRST choice final, disposing anything that arrived later. That
|
|
160
|
+
// is right against two carriers racing in, and exactly wrong for a user changing the setting:
|
|
161
|
+
// `chooseCarrier` runs again on purpose, and a refusal here is indistinguishable from the
|
|
162
|
+
// write having done nothing. The previous carrier is now released by the replacement instead.
|
|
122
163
|
upgraded = true;
|
|
123
164
|
try {
|
|
124
165
|
unregister();
|
|
125
166
|
unregister = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(host, providerConfig));
|
|
126
167
|
selfHosted.dispose();
|
|
127
168
|
ctx.logger?.info?.(`dsh-builtin-browser: ${note}`);
|
|
169
|
+
// A borrowed carrier can lose the process behind it, and this is the only notification that
|
|
170
|
+
// will ever arrive. Registering AFTER the swap succeeded means a listener can never fire
|
|
171
|
+
// against a provider that was never installed.
|
|
172
|
+
host.onLost?.(() => { restoreSelfHosted(host); });
|
|
173
|
+
const previous = adoptedCarrier;
|
|
174
|
+
adoptedCarrier = host;
|
|
175
|
+
// Released AFTER the replacement is installed, so the surface is never without a provider.
|
|
176
|
+
if (previous !== undefined && previous !== host) {
|
|
177
|
+
try {
|
|
178
|
+
previous.dispose?.();
|
|
179
|
+
}
|
|
180
|
+
catch { /* the replacement is already serving */ }
|
|
181
|
+
}
|
|
128
182
|
}
|
|
129
183
|
catch (error) {
|
|
130
184
|
// Never leave the surface without a provider. The half-built host is disposed FIRST: an
|
|
@@ -152,102 +206,119 @@ export function apply(ctx, config) {
|
|
|
152
206
|
}
|
|
153
207
|
}
|
|
154
208
|
};
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
209
|
+
/**
|
|
210
|
+
* Choose and install the carrier that `browser.channel` names RIGHT NOW.
|
|
211
|
+
*
|
|
212
|
+
* A function rather than straight-line code because the setting is read live everywhere else:
|
|
213
|
+
* the settings route writes the file and the provider picks the change up on its next call, so a
|
|
214
|
+
* channel that could only be honoured at apply time was the one setting whose write silently did
|
|
215
|
+
* nothing. Re-entrant by design — changing the channel means replacing the carrier, which is
|
|
216
|
+
* exactly what a second call does.
|
|
217
|
+
*/
|
|
218
|
+
const chooseCarrier = () => {
|
|
219
|
+
// A browser the user explicitly asked for outranks every automatic choice: the
|
|
220
|
+
// whole point of the setting is that they know which browser they want. It is
|
|
221
|
+
// launched with a plugin-owned profile, so their own windows are untouched.
|
|
222
|
+
//
|
|
223
|
+
// Registration is INERT: constructing the host starts nothing. The browser only
|
|
224
|
+
// launches when a command actually needs a page, so merely running DSH never
|
|
225
|
+
// spawns a browser window. (An earlier version launched it here, which meant a
|
|
226
|
+
// browser appeared the moment the plugin loaded — reported and fixed.)
|
|
227
|
+
const channel = settings.get().browser.channel;
|
|
228
|
+
// Naming a browser is a decision about which one to use, so it also decides whether the
|
|
229
|
+
// desktop sidebar may take over. Skipping discovery for an explicit choice is what makes
|
|
230
|
+
// that choice real — otherwise a user who picked Chrome would silently get the sidebar,
|
|
231
|
+
// and a user who picked a browser that is not installed would never see the explanation
|
|
232
|
+
// written for exactly that case, because the sidebar would quietly replace it.
|
|
233
|
+
const explicitChoice = channel !== 'bundled' && channel !== 'auto' && channel !== 'sidebar';
|
|
234
|
+
if (channel !== 'bundled' && channel !== 'sidebar') {
|
|
235
|
+
const detected = detectBrowser(channel);
|
|
236
|
+
if (detected === undefined) {
|
|
237
|
+
// `auto` promises to take whatever is available, so falling back is what it
|
|
238
|
+
// asked for. Naming a browser is a specific request, and quietly satisfying it
|
|
239
|
+
// with a different one turns the real problem ("no Chrome here") into a
|
|
240
|
+
// confusing error about Electron later on. Report it instead.
|
|
241
|
+
if (channel === 'auto') {
|
|
242
|
+
ctx.logger?.warn?.('dsh-builtin-browser: no installed browser was found; using the bundled browser');
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
adopt(new MissingSystemBrowserHost(channel, searchSummary(channel)), `the selected ${channel} is not installed — commands will explain how to fix it`);
|
|
246
|
+
}
|
|
179
247
|
}
|
|
180
248
|
else {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
claimEphemeralProfile(profileDir);
|
|
249
|
+
// Same profile root as settings-store, history-store and desktop-bridge-host: the
|
|
250
|
+
// `?.dsh` component used to be missing HERE only, so a desktop launched from a
|
|
251
|
+
// shortcut (no DSH_HOME in the environment) put its browser profile in
|
|
252
|
+
// `C:\Users\<user>\dsh-builtin-browser-host\` while its settings and history lived in
|
|
253
|
+
// `~/.dsh/dsh-builtin-browser-host/` — two half-profiles that never met, and a
|
|
254
|
+
// settings panel whose switches appeared not to apply.
|
|
255
|
+
const home = process.env.DSH_HOME ?? join(homedir(), '.dsh');
|
|
256
|
+
// Login state lives in the browser profile, so the cookies setting decides where
|
|
257
|
+
// that profile goes: a stable directory keeps the user signed in across restarts
|
|
258
|
+
// (the point of using their own browser), while turning persistence off gets a
|
|
259
|
+
// throwaway directory that is removed when the browser is released.
|
|
260
|
+
const persist = settings.get().cookies.persist;
|
|
261
|
+
const profileRoot = join(home, BROWSER_PROFILE_DIR);
|
|
262
|
+
adoptLegacyProfileRoot(profileRoot);
|
|
263
|
+
const profileDir = persist
|
|
264
|
+
? join(profileRoot, `${detected.kind}-profile`)
|
|
265
|
+
: join(profileRoot, ephemeralProfileName(detected.kind, randomUUID()));
|
|
266
|
+
if (!persist) {
|
|
267
|
+
// A process killed outright never runs its own cleanup (see ephemeral-profile.ts),
|
|
268
|
+
// so the sweep happens HERE — at the only moment that can still act on a profile
|
|
269
|
+
// whose owner is gone — and before this run claims a directory of its own.
|
|
270
|
+
sweepAbandonedEphemeralProfiles(profileRoot, detected.kind);
|
|
271
|
+
claimEphemeralProfile(profileDir);
|
|
272
|
+
}
|
|
273
|
+
adopt(new SystemBrowserViewHost(detected, profileDir, [], persist ? undefined : profileDir), `will drive the installed ${detected.kind} (${detected.path}${persist ? '' : ', ephemeral profile'})`);
|
|
207
274
|
}
|
|
208
|
-
adopt(new SystemBrowserViewHost(detected, profileDir, [], persist ? undefined : profileDir), `will drive the installed ${detected.kind} (${detected.path}${persist ? '' : ', ephemeral profile'})`);
|
|
209
275
|
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
276
|
+
// An explicit choice was already adopted above; discovering the sidebar here would
|
|
277
|
+
// replace it, which is exactly what this guard prevents.
|
|
278
|
+
//
|
|
279
|
+
// `sidebar` is explicit too, but it names the sidebar, so it is the one explicit choice that
|
|
280
|
+
// must FALL THROUGH to the discovery below instead of returning here.
|
|
281
|
+
if (explicitChoice && channel !== 'sidebar')
|
|
282
|
+
return;
|
|
283
|
+
// The shell may not be up yet. `dsh web` starts before — or entirely without — the desktop app
|
|
284
|
+
// that hosts the bridge, and a one-shot discovery meant a bridge that appeared a second later
|
|
285
|
+
// was never adopted for the rest of the process's life. Retry a handful of times over roughly
|
|
286
|
+
// half a minute, then stop: this is a convenience, not a promise, and the session the user is
|
|
287
|
+
// already driving in the self-hosted window is not worth replacing minutes into their work.
|
|
288
|
+
// `adopt` is itself one-shot (its `upgraded` guard), so the only possible effect of a retry
|
|
289
|
+
// that lands late is adopting a bridge that showed up just after startup.
|
|
290
|
+
void (async () => {
|
|
291
|
+
for (const delay of [0, 2_000, 5_000, 15_000]) {
|
|
292
|
+
if (delay > 0)
|
|
293
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
294
|
+
let sidebar;
|
|
295
|
+
try {
|
|
296
|
+
sidebar = await DesktopBridgeViewHost.discover(sessionId);
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
sidebar = undefined; // discovery is documented not to throw; never let it stop the retries
|
|
300
|
+
}
|
|
301
|
+
if (sidebar === undefined)
|
|
302
|
+
continue;
|
|
303
|
+
adopt(sidebar, 'driving the desktop sidebar browser');
|
|
304
|
+
return;
|
|
232
305
|
}
|
|
233
|
-
|
|
234
|
-
|
|
306
|
+
// The user explicitly asked for the sidebar, so silence would leave them with a setting
|
|
307
|
+
// that did nothing and no way to tell why. This is reachable on `dsh web`, and on a desktop
|
|
308
|
+
// whose shell never published a bridge endpoint. An automatic attempt stays quiet (the
|
|
309
|
+
// self-hosted window is a fine outcome); an explicit request does not.
|
|
310
|
+
if (channel === 'sidebar') {
|
|
311
|
+
ctx.logger?.warn?.('dsh-builtin-browser: the sidebar browser was requested but no desktop bridge was found — '
|
|
312
|
+
+ 'this DSH has no desktop shell, or its bridge never started. Falling back to the bundled '
|
|
313
|
+
+ 'browser; set browser.channel to another value to silence this.');
|
|
235
314
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
// whose shell never published a bridge endpoint. An automatic attempt stays quiet (the
|
|
244
|
-
// self-hosted window is a fine outcome); an explicit request does not.
|
|
245
|
-
if (channel === 'sidebar') {
|
|
246
|
-
ctx.logger?.warn?.('dsh-builtin-browser: the sidebar browser was requested but no desktop bridge was found — '
|
|
247
|
-
+ 'this DSH has no desktop shell, or its bridge never started. Falling back to the bundled '
|
|
248
|
-
+ 'browser; set browser.channel to another value to silence this.');
|
|
249
|
-
}
|
|
250
|
-
})();
|
|
315
|
+
})();
|
|
316
|
+
};
|
|
317
|
+
chooseCarrier();
|
|
318
|
+
// Wired AFTER the chooser exists, because the handler re-runs it: a settings write that changes
|
|
319
|
+
// `browser.channel` is a request to switch carriers, and answering `ok` without switching is the
|
|
320
|
+
// one thing a caller cannot tell apart from a write that worked.
|
|
321
|
+
installSettingsRoute(ctx, settings, () => { chooseCarrier(); });
|
|
251
322
|
}
|
|
252
323
|
/** Route the settings panel reads and writes. */
|
|
253
324
|
const SETTINGS_ROUTE = '/dsh-builtin-browser/settings';
|
|
@@ -259,6 +330,40 @@ const SETTINGS_ROUTE = '/dsh-builtin-browser/settings';
|
|
|
259
330
|
* root than the settings document is a pair that silently never meets.
|
|
260
331
|
*/
|
|
261
332
|
const BROWSER_PROFILE_DIR = 'dsh-builtin-browser-host';
|
|
333
|
+
/**
|
|
334
|
+
* Move a browser profile left behind by the old layout into the directory the plugin uses now.
|
|
335
|
+
*
|
|
336
|
+
* Before the `?.dsh` component was added here, a desktop launched from a shortcut (no `DSH_HOME`
|
|
337
|
+
* in the environment) put its browser profile in `<homedir>/dsh-builtin-browser-host/` while its
|
|
338
|
+
* settings and history lived in `<homedir>/.dsh/dsh-builtin-browser-host/`. Login state lives
|
|
339
|
+
* INSIDE the browser profile, so leaving the old directory behind silently signs the user out of
|
|
340
|
+
* everything they had signed into — reported after the fix shipped, as a one-time cost of it.
|
|
341
|
+
*
|
|
342
|
+
* That cost does not have to be paid. The move happens only when the new location is absent, so a
|
|
343
|
+
* profile in use is never overwritten, and every failure — a cross-device rename, a permission
|
|
344
|
+
* problem, a profile another process holds — falls through to an empty profile, which is exactly
|
|
345
|
+
* what would have happened without this function. The old directory is then left untouched rather
|
|
346
|
+
* than deleted, because a half-moved profile is worse than a stale one.
|
|
347
|
+
*
|
|
348
|
+
* @param profileRoot - the root the plugin resolves today (`<DSH_HOME>/dsh-builtin-browser-host`).
|
|
349
|
+
* @param legacyRoot - the pre-`DSH_HOME` layout's root. Injected so the move can be exercised
|
|
350
|
+
* without touching the real home directory.
|
|
351
|
+
*/
|
|
352
|
+
export function adoptLegacyProfileRoot(profileRoot, legacyRoot = join(homedir(), BROWSER_PROFILE_DIR)) {
|
|
353
|
+
try {
|
|
354
|
+
if (legacyRoot === profileRoot)
|
|
355
|
+
return;
|
|
356
|
+
if (!existsSync(legacyRoot) || existsSync(profileRoot))
|
|
357
|
+
return;
|
|
358
|
+
// `renameSync` needs the destination's PARENT to exist, and on a fresh install it does not:
|
|
359
|
+
// the old layout never created `.dsh` at all. Creating it is harmless if it is already there.
|
|
360
|
+
mkdirSync(dirname(profileRoot), { recursive: true });
|
|
361
|
+
renameSync(legacyRoot, profileRoot);
|
|
362
|
+
}
|
|
363
|
+
catch {
|
|
364
|
+
// Nothing here is worth failing an apply over; the fallback is the pre-existing behaviour.
|
|
365
|
+
}
|
|
366
|
+
}
|
|
262
367
|
/**
|
|
263
368
|
* Expose the settings document over the host's web server, which is how the
|
|
264
369
|
* settings panel reads and writes it. A host without that service (headless /
|
|
@@ -267,7 +372,7 @@ const BROWSER_PROFILE_DIR = 'dsh-builtin-browser-host';
|
|
|
267
372
|
* @param ctx - the plugin context.
|
|
268
373
|
* @param settings - the settings document to expose.
|
|
269
374
|
*/
|
|
270
|
-
function installSettingsRoute(ctx, settings) {
|
|
375
|
+
function installSettingsRoute(ctx, settings, onChannelChange) {
|
|
271
376
|
const inject = ctx.inject;
|
|
272
377
|
if (typeof inject !== 'function')
|
|
273
378
|
return;
|
|
@@ -276,7 +381,7 @@ function installSettingsRoute(ctx, settings) {
|
|
|
276
381
|
host.effect(() => host.webServer.register({
|
|
277
382
|
kind: 'exact',
|
|
278
383
|
path: SETTINGS_ROUTE,
|
|
279
|
-
handler: (request, response) => handleSettingsRequest(request, response, settings),
|
|
384
|
+
handler: (request, response) => handleSettingsRequest(request, response, settings, onChannelChange),
|
|
280
385
|
}, 'dsh-builtin-browser: settings'));
|
|
281
386
|
});
|
|
282
387
|
}
|
|
@@ -290,8 +395,9 @@ function installSettingsRoute(ctx, settings) {
|
|
|
290
395
|
* @param request - the incoming request.
|
|
291
396
|
* @param response - the response to write.
|
|
292
397
|
* @param settings - the settings document.
|
|
398
|
+
* @param onChannelChange - re-runs the carrier choice when this write changed `browser.channel`.
|
|
293
399
|
*/
|
|
294
|
-
async function handleSettingsRequest(request, response, settings) {
|
|
400
|
+
async function handleSettingsRequest(request, response, settings, onChannelChange) {
|
|
295
401
|
const json = (status, body) => {
|
|
296
402
|
response.writeHead(status, {
|
|
297
403
|
'content-type': 'application/json; charset=utf-8',
|
|
@@ -316,7 +422,20 @@ async function handleSettingsRequest(request, response, settings) {
|
|
|
316
422
|
try {
|
|
317
423
|
const body = await readBody(request);
|
|
318
424
|
const patch = body.trim() === '' ? {} : JSON.parse(body);
|
|
319
|
-
|
|
425
|
+
const before = settings.get().browser.channel;
|
|
426
|
+
const updated = settings.update(patch);
|
|
427
|
+
// A changed `browser.channel` is a request to switch carriers, not just a value to store. The
|
|
428
|
+
// choice is re-run here so the write takes effect now — and the response says so, because a
|
|
429
|
+
// caller that cannot tell "applied" from "stored for later" has no way to know what it got,
|
|
430
|
+
// which is exactly how a write that did nothing read as a success.
|
|
431
|
+
const channelChanged = updated.browser.channel !== before;
|
|
432
|
+
if (channelChanged)
|
|
433
|
+
onChannelChange?.();
|
|
434
|
+
json(200, {
|
|
435
|
+
ok: true,
|
|
436
|
+
settings: updated,
|
|
437
|
+
...channelChanged ? { carrierRechosen: true } : {},
|
|
438
|
+
});
|
|
320
439
|
}
|
|
321
440
|
catch (error) {
|
|
322
441
|
json(400, { ok: false, error: `invalid settings patch: ${String(error)}` });
|
|
@@ -119,6 +119,17 @@ export interface ElectronBrowserViewHost {
|
|
|
119
119
|
* which is only correct from a teardown that is itself global.
|
|
120
120
|
*/
|
|
121
121
|
releasePage?(owner?: string): Promise<void>;
|
|
122
|
+
/**
|
|
123
|
+
* Report that this carrier can no longer serve anything, so its owner may replace it.
|
|
124
|
+
*
|
|
125
|
+
* A carrier that borrows an outside process can lose it: the desktop shell exits, and every call
|
|
126
|
+
* after that would dial a port nobody owns. Without this signal the swap happens only at
|
|
127
|
+
* startup, so the choice made then is final for the process's life — which is what a user
|
|
128
|
+
* experiences as "the browser stopped working until I restart DSH". Optional: a carrier that
|
|
129
|
+
* owns its own lifetime has nothing to report.
|
|
130
|
+
* @param listener - called once, when the carrier concludes it is gone.
|
|
131
|
+
*/
|
|
132
|
+
onLost?(listener: () => void): void;
|
|
122
133
|
/**
|
|
123
134
|
* Fold the carrier's presentation away while keeping the page alive.
|
|
124
135
|
*
|
|
@@ -705,6 +716,19 @@ export declare class ElectronBrowserProvider implements BrowserProvider {
|
|
|
705
716
|
private activeTab;
|
|
706
717
|
/** Append a fresh tab and make it active. */
|
|
707
718
|
private newTab;
|
|
719
|
+
/**
|
|
720
|
+
* Swap a wedged view for a fresh blank one, keeping the tab's public id.
|
|
721
|
+
*
|
|
722
|
+
* A view whose renderer never commits cannot be read again: every evaluate into it runs out its
|
|
723
|
+
* whole budget. Replacing it is what turns that from permanent into one bad call — the tab id
|
|
724
|
+
* callers hold stays valid, and the next `browser_open` with the real URL re-populates it.
|
|
725
|
+
*
|
|
726
|
+
* The old view is destroyed AFTER the new one is in place, so a failure here cannot leave the
|
|
727
|
+
* session holding a handle that is already gone.
|
|
728
|
+
* @param s - the session owning the tab.
|
|
729
|
+
* @param tab - the tab to reset.
|
|
730
|
+
*/
|
|
731
|
+
private replaceTabWithBlank;
|
|
708
732
|
/** Find a session's tab by its backing view id (toolbar actions carry view ids). */
|
|
709
733
|
private tabByViewId;
|
|
710
734
|
/**
|
|
@@ -352,7 +352,7 @@ export class ElectronBrowserProvider {
|
|
|
352
352
|
// session), carrying the label for the window title. Hosts without
|
|
353
353
|
// groupView keep the shared-window behavior.
|
|
354
354
|
this.host.groupView?.(handle, id, label);
|
|
355
|
-
this.sessions.set(id, { id, label, tabs: [{ id: `tab:${randomUUID()}`, handle }], activeIndex: 0, history: [], nextSeq: 1 });
|
|
355
|
+
this.sessions.set(id, { id, label, tabs: [{ id: `tab:${randomUUID()}`, handle, url: '' }], activeIndex: 0, history: [], nextSeq: 1 });
|
|
356
356
|
return Promise.resolve(id);
|
|
357
357
|
}
|
|
358
358
|
/** Open a URL in the active tab (default) or a new tab. */
|
|
@@ -371,9 +371,16 @@ export class ElectronBrowserProvider {
|
|
|
371
371
|
const tab = s.tabs[i];
|
|
372
372
|
if (tab === undefined)
|
|
373
373
|
continue; // defensive: array can shift under concurrency
|
|
374
|
+
// A tab we know has never navigated is NOT probed: its view has committed no document, so
|
|
375
|
+
// the round trip can only answer nothing — and it is the round trip that costs. A tab that
|
|
376
|
+
// HAS navigated is re-read, because the page may have moved itself since; a read that fails
|
|
377
|
+
// keeps the last accepted URL rather than reporting the tab as blank.
|
|
378
|
+
const liveUrl = tab.url === '' ? '' : await this.currentUrl(tab.handle).catch(() => tab.url);
|
|
379
|
+
if (liveUrl !== '')
|
|
380
|
+
tab.url = liveUrl;
|
|
374
381
|
result.push({
|
|
375
382
|
id: tab.id,
|
|
376
|
-
url:
|
|
383
|
+
url: liveUrl,
|
|
377
384
|
active: i === s.activeIndex,
|
|
378
385
|
});
|
|
379
386
|
}
|
|
@@ -455,7 +462,8 @@ export class ElectronBrowserProvider {
|
|
|
455
462
|
/** Navigate the active tab's view to a URL, honoring HTTP(S)-only admission. */
|
|
456
463
|
async navigate(session, request, signal) {
|
|
457
464
|
const s = this.session(session);
|
|
458
|
-
const
|
|
465
|
+
const tab = this.activeTab(s);
|
|
466
|
+
const { handle } = tab;
|
|
459
467
|
const url = request.url;
|
|
460
468
|
try {
|
|
461
469
|
if (this.httpOnly) {
|
|
@@ -496,6 +504,11 @@ export class ElectronBrowserProvider {
|
|
|
496
504
|
// the visit written to the persistent history for a navigation nobody awaits any more.
|
|
497
505
|
signal?.throwIfAborted();
|
|
498
506
|
this.record(s, 'navigate', { url }, true);
|
|
507
|
+
// The tab now holds a committed document, which is what the blank short-circuit in
|
|
508
|
+
// `snapshot`/`a11y` keys on. Recorded here rather than after the settle below: the
|
|
509
|
+
// navigation HAS committed, and a tab wrongly treated as blank would return empty results
|
|
510
|
+
// for a page that is really there.
|
|
511
|
+
tab.url = url;
|
|
499
512
|
await this.settleDocument(handle, before, signal);
|
|
500
513
|
this.recordVisit(s, handle);
|
|
501
514
|
this.showActive(s);
|
|
@@ -808,6 +821,11 @@ export class ElectronBrowserProvider {
|
|
|
808
821
|
const s = this.session(session);
|
|
809
822
|
const tab = this.activeTab(s);
|
|
810
823
|
signal?.throwIfAborted();
|
|
824
|
+
// A tab that has never navigated is a blank, uncommitted view. The collector cannot answer in
|
|
825
|
+
// it — there is no document to commit — so the call would sit out its whole budget before
|
|
826
|
+
// saying anything. Answering the empty page immediately is both faster and more truthful.
|
|
827
|
+
if (tab.url === '')
|
|
828
|
+
return { url: '', elements: [], truncated: false };
|
|
811
829
|
const script = `(() => {
|
|
812
830
|
const cap = ${String(this.snapshotMaxElements)}
|
|
813
831
|
const url = location.href
|
|
@@ -880,7 +898,22 @@ export class ElectronBrowserProvider {
|
|
|
880
898
|
// Same hang guard as execute: a renderer that has not committed after
|
|
881
899
|
// navigate would otherwise block snapshot forever.
|
|
882
900
|
const timeoutMs = 30_000;
|
|
883
|
-
|
|
901
|
+
let result;
|
|
902
|
+
try {
|
|
903
|
+
result = await withTimeout(handleSendEvaluate(tab.handle, script), timeoutMs, signal, `browser: snapshot timed out after ${timeoutMs}ms`, () => terminatePage(tab.handle));
|
|
904
|
+
}
|
|
905
|
+
catch (error) {
|
|
906
|
+
// A timeout leaves the wedged view in the session, so every later read would pay the same
|
|
907
|
+
// 30 seconds again — and `terminatePage` only interrupts the running script, it does not
|
|
908
|
+
// make the view readable. Replacing it is what turns a permanent loss into one bad call:
|
|
909
|
+
// the tab keeps the id the caller holds, and the next browser_open with the real URL
|
|
910
|
+
// repopulates it.
|
|
911
|
+
if (error instanceof Error && error.name === 'TimeoutError') {
|
|
912
|
+
this.replaceTabWithBlank(s, tab);
|
|
913
|
+
throw new BrowserError('browser: snapshot timed out; the stuck tab was reset to blank. Call browser_open with the real target URL before reading it again.', 'BROWSER_SNAPSHOT_TIMEOUT', { cause: error });
|
|
914
|
+
}
|
|
915
|
+
throw error;
|
|
916
|
+
}
|
|
884
917
|
if (!result.ok)
|
|
885
918
|
throw new BrowserError(`browser: snapshot evaluation failed: ${result.exception}`, 'BROWSER_SNAPSHOT_FAILED');
|
|
886
919
|
const value = result.value;
|
|
@@ -898,6 +931,10 @@ export class ElectronBrowserProvider {
|
|
|
898
931
|
const s = this.session(session);
|
|
899
932
|
const tab = this.activeTab(s);
|
|
900
933
|
signal?.throwIfAborted();
|
|
934
|
+
// Same short-circuit as `snapshot`: a tab that never navigated has no committed document, so
|
|
935
|
+
// the tree cannot be read and the call would spend its whole budget discovering that.
|
|
936
|
+
if (tab.url === '')
|
|
937
|
+
return { url: '', title: undefined, count: 0, nodes: [], truncated: false };
|
|
901
938
|
const includeHidden = request.includeHidden === true;
|
|
902
939
|
// 150 rather than 500. The cap fills on any real page, and 500 nodes measured 38,953
|
|
903
940
|
// characters — 10k-13k tokens for a single call. A caller that needs more asks for it.
|
|
@@ -1059,7 +1096,19 @@ export class ElectronBrowserProvider {
|
|
|
1059
1096
|
return { url: location.href, title: document.title || undefined, count: out.length, nodes: out, truncated: out.length >= maxNodes }
|
|
1060
1097
|
})()`;
|
|
1061
1098
|
const timeoutMs = 30_000;
|
|
1062
|
-
|
|
1099
|
+
let result;
|
|
1100
|
+
try {
|
|
1101
|
+
result = await withTimeout(handleSendEvaluate(tab.handle, script), timeoutMs, signal, `browser: a11y timed out after ${timeoutMs}ms`, () => terminatePage(tab.handle));
|
|
1102
|
+
}
|
|
1103
|
+
catch (error) {
|
|
1104
|
+
// As in `snapshot`: a timeout means the view is wedged, and leaving it in the session would
|
|
1105
|
+
// charge every later read the same 30 seconds. Replaced in place, so the tab id survives.
|
|
1106
|
+
if (error instanceof Error && error.name === 'TimeoutError') {
|
|
1107
|
+
this.replaceTabWithBlank(s, tab);
|
|
1108
|
+
throw new BrowserError('browser: a11y timed out; the stuck tab was reset to blank. Call browser_open with the real target URL before reading it again.', 'BROWSER_A11Y_TIMEOUT', { cause: error });
|
|
1109
|
+
}
|
|
1110
|
+
throw error;
|
|
1111
|
+
}
|
|
1063
1112
|
if (!result.ok)
|
|
1064
1113
|
throw new BrowserError(`browser: a11y evaluation failed: ${result.exception}`, 'BROWSER_A11Y_FAILED');
|
|
1065
1114
|
const value = result.value;
|
|
@@ -2579,10 +2628,42 @@ export class ElectronBrowserProvider {
|
|
|
2579
2628
|
const handle = this.host.createView(s.label);
|
|
2580
2629
|
// Same window as the session's other tabs.
|
|
2581
2630
|
this.host.groupView?.(handle, s.id, s.label);
|
|
2582
|
-
s.tabs.push({ id: `tab:${randomUUID()}`, handle });
|
|
2631
|
+
s.tabs.push({ id: `tab:${randomUUID()}`, handle, url: '' });
|
|
2583
2632
|
s.activeIndex = s.tabs.length - 1;
|
|
2584
2633
|
this.showActive(s);
|
|
2585
2634
|
}
|
|
2635
|
+
/**
|
|
2636
|
+
* Swap a wedged view for a fresh blank one, keeping the tab's public id.
|
|
2637
|
+
*
|
|
2638
|
+
* A view whose renderer never commits cannot be read again: every evaluate into it runs out its
|
|
2639
|
+
* whole budget. Replacing it is what turns that from permanent into one bad call — the tab id
|
|
2640
|
+
* callers hold stays valid, and the next `browser_open` with the real URL re-populates it.
|
|
2641
|
+
*
|
|
2642
|
+
* The old view is destroyed AFTER the new one is in place, so a failure here cannot leave the
|
|
2643
|
+
* session holding a handle that is already gone.
|
|
2644
|
+
* @param s - the session owning the tab.
|
|
2645
|
+
* @param tab - the tab to reset.
|
|
2646
|
+
*/
|
|
2647
|
+
replaceTabWithBlank(s, tab) {
|
|
2648
|
+
const index = s.tabs.indexOf(tab);
|
|
2649
|
+
if (index < 0)
|
|
2650
|
+
return;
|
|
2651
|
+
let handle;
|
|
2652
|
+
try {
|
|
2653
|
+
handle = this.host.createView(s.label);
|
|
2654
|
+
this.host.groupView?.(handle, s.id, s.label);
|
|
2655
|
+
}
|
|
2656
|
+
catch {
|
|
2657
|
+
// Creating the replacement failed: leaving the wedged tab in place is worse than nothing
|
|
2658
|
+
// only for the caller, who is about to be told the tab timed out either way.
|
|
2659
|
+
return;
|
|
2660
|
+
}
|
|
2661
|
+
const wedged = tab.handle;
|
|
2662
|
+
s.tabs[index] = { id: tab.id, handle, url: '' };
|
|
2663
|
+
if (index === s.activeIndex)
|
|
2664
|
+
this.showActive(s);
|
|
2665
|
+
this.host.destroyView(wedged);
|
|
2666
|
+
}
|
|
2586
2667
|
/** Find a session's tab by its backing view id (toolbar actions carry view ids). */
|
|
2587
2668
|
tabByViewId(s, viewId) {
|
|
2588
2669
|
return s.tabs.find(tab => tab.handle.id === viewId);
|