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
|
@@ -69,6 +69,13 @@ export declare const DEFAULT_SETTINGS: BrowserSettings;
|
|
|
69
69
|
* @returns the settings file path.
|
|
70
70
|
*/
|
|
71
71
|
export declare function settingsPath(): string;
|
|
72
|
+
/**
|
|
73
|
+
* The channels a settings document may name.
|
|
74
|
+
*
|
|
75
|
+
* Exported so the log line and the error message can quote the same set the parser accepts: a
|
|
76
|
+
* message that lists a value the code rejects (or omits one it takes) is worse than no message.
|
|
77
|
+
*/
|
|
78
|
+
export declare const BROWSER_CHANNELS: readonly ["chrome", "edge", "auto", "bundled", "sidebar"];
|
|
72
79
|
/**
|
|
73
80
|
* Resolve a raw settings document (already parsed, shape unknown) into the full
|
|
74
81
|
* document, field by field. Unknown keys are dropped: the file is not a place to
|
|
@@ -76,7 +83,7 @@ export declare function settingsPath(): string;
|
|
|
76
83
|
* @param raw - the parsed file contents.
|
|
77
84
|
* @returns the resolved settings.
|
|
78
85
|
*/
|
|
79
|
-
export declare function resolveSettings(raw: unknown): BrowserSettings;
|
|
86
|
+
export declare function resolveSettings(raw: unknown, warn?: (message: string) => void): BrowserSettings;
|
|
80
87
|
/**
|
|
81
88
|
* File-backed settings with an in-memory snapshot. Reads re-stat the file so an
|
|
82
89
|
* edit from another process (or the settings panel writing through a second
|
|
@@ -85,13 +92,14 @@ export declare function resolveSettings(raw: unknown): BrowserSettings;
|
|
|
85
92
|
*/
|
|
86
93
|
export declare class SettingsStore {
|
|
87
94
|
private readonly file;
|
|
95
|
+
private readonly log?;
|
|
88
96
|
private cached;
|
|
89
97
|
/** Last observed mtime, used to invalidate the cache on external edits. */
|
|
90
98
|
private cachedMtimeMs;
|
|
91
99
|
/**
|
|
92
100
|
* @param file - settings file path (defaults beside the browser profile).
|
|
93
101
|
*/
|
|
94
|
-
constructor(file?: string);
|
|
102
|
+
constructor(file?: string, log?: ((message: string) => void) | undefined);
|
|
95
103
|
/** Path this store reads and writes. */
|
|
96
104
|
path(): string;
|
|
97
105
|
/**
|
|
@@ -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
|
/**
|
|
@@ -17,8 +17,9 @@ import type { BrowserRuntime } from '../browser/runtime.js'
|
|
|
17
17
|
import { ElectronBrowserProvider } from './provider.js'
|
|
18
18
|
import type { ElectronBrowserViewHost } from './provider.js'
|
|
19
19
|
import { randomUUID } from 'node:crypto'
|
|
20
|
+
import { existsSync, mkdirSync, renameSync } from 'node:fs'
|
|
20
21
|
import { homedir } from 'node:os'
|
|
21
|
-
import { join } from 'node:path'
|
|
22
|
+
import { dirname, join } from 'node:path'
|
|
22
23
|
import { defaultHostMainPath, RemoteElectronViewHost } from './remote-host.js'
|
|
23
24
|
import { DesktopBridgeViewHost } from './desktop-bridge-host.js'
|
|
24
25
|
import { detectBrowser, searchSummary, SystemBrowserViewHost } from './system-browser.js'
|
|
@@ -111,9 +112,10 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
|
|
|
111
112
|
const sessionId = readSessionId(ctx)
|
|
112
113
|
|
|
113
114
|
// One settings document per plugin instance: the settings panel writes it, the
|
|
114
|
-
// provider reads it live, and both ends agree on the same file.
|
|
115
|
-
|
|
116
|
-
|
|
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))
|
|
117
119
|
|
|
118
120
|
const providerConfig = {
|
|
119
121
|
httpOnly: config.httpOnly,
|
|
@@ -128,6 +130,17 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
|
|
|
128
130
|
if (config.viewHost !== undefined) {
|
|
129
131
|
const external = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(config.viewHost as ElectronBrowserViewHost, providerConfig))
|
|
130
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
|
+
}
|
|
131
144
|
return
|
|
132
145
|
}
|
|
133
146
|
|
|
@@ -139,25 +152,69 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
|
|
|
139
152
|
const selfHosted = new RemoteElectronViewHost(defaultHostMainPath())
|
|
140
153
|
let unregister = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(selfHosted, providerConfig))
|
|
141
154
|
let upgraded = false
|
|
155
|
+
/** The carrier an `adopt` installed, so a later choice can release it. */
|
|
156
|
+
let adoptedCarrier: ElectronBrowserViewHost | undefined
|
|
142
157
|
|
|
143
158
|
ctx.effect(() => () => {
|
|
144
159
|
unregister()
|
|
145
160
|
selfHosted.dispose()
|
|
146
161
|
})
|
|
147
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
|
+
|
|
148
192
|
/**
|
|
149
193
|
* Swap the provider to another carrier, releasing the previous one.
|
|
150
194
|
* @param host - the carrier to adopt.
|
|
151
195
|
* @param note - a one-line description for the log.
|
|
152
196
|
*/
|
|
153
197
|
const adopt = (host: ElectronBrowserViewHost, note: string): void => {
|
|
154
|
-
|
|
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.
|
|
155
202
|
upgraded = true
|
|
156
203
|
try {
|
|
157
204
|
unregister()
|
|
158
205
|
unregister = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(host, providerConfig))
|
|
159
206
|
selfHosted.dispose()
|
|
160
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
|
+
}
|
|
161
218
|
} catch (error) {
|
|
162
219
|
// Never leave the surface without a provider. The half-built host is disposed FIRST: an
|
|
163
220
|
// adopt that got as far as constructing it may already hold a socket, a listener or a child
|
|
@@ -183,6 +240,16 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
|
|
|
183
240
|
}
|
|
184
241
|
}
|
|
185
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 => {
|
|
186
253
|
// A browser the user explicitly asked for outranks every automatic choice: the
|
|
187
254
|
// whole point of the setting is that they know which browser they want. It is
|
|
188
255
|
// launched with a plugin-owned profile, so their own windows are untouched.
|
|
@@ -225,6 +292,7 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
|
|
|
225
292
|
// throwaway directory that is removed when the browser is released.
|
|
226
293
|
const persist = settings.get().cookies.persist
|
|
227
294
|
const profileRoot = join(home, BROWSER_PROFILE_DIR)
|
|
295
|
+
adoptLegacyProfileRoot(profileRoot)
|
|
228
296
|
const profileDir = persist
|
|
229
297
|
? join(profileRoot, `${detected.kind}-profile`)
|
|
230
298
|
: join(profileRoot, ephemeralProfileName(detected.kind, randomUUID()))
|
|
@@ -279,6 +347,14 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
|
|
|
279
347
|
)
|
|
280
348
|
}
|
|
281
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() })
|
|
282
358
|
}
|
|
283
359
|
|
|
284
360
|
/** Route the settings panel reads and writes. */
|
|
@@ -293,6 +369,41 @@ const SETTINGS_ROUTE = '/dsh-builtin-browser/settings'
|
|
|
293
369
|
*/
|
|
294
370
|
const BROWSER_PROFILE_DIR = 'dsh-builtin-browser-host'
|
|
295
371
|
|
|
372
|
+
/**
|
|
373
|
+
* Move a browser profile left behind by the old layout into the directory the plugin uses now.
|
|
374
|
+
*
|
|
375
|
+
* Before the `?.dsh` component was added here, a desktop launched from a shortcut (no `DSH_HOME`
|
|
376
|
+
* in the environment) put its browser profile in `<homedir>/dsh-builtin-browser-host/` while its
|
|
377
|
+
* settings and history lived in `<homedir>/.dsh/dsh-builtin-browser-host/`. Login state lives
|
|
378
|
+
* INSIDE the browser profile, so leaving the old directory behind silently signs the user out of
|
|
379
|
+
* everything they had signed into — reported after the fix shipped, as a one-time cost of it.
|
|
380
|
+
*
|
|
381
|
+
* That cost does not have to be paid. The move happens only when the new location is absent, so a
|
|
382
|
+
* profile in use is never overwritten, and every failure — a cross-device rename, a permission
|
|
383
|
+
* problem, a profile another process holds — falls through to an empty profile, which is exactly
|
|
384
|
+
* what would have happened without this function. The old directory is then left untouched rather
|
|
385
|
+
* than deleted, because a half-moved profile is worse than a stale one.
|
|
386
|
+
*
|
|
387
|
+
* @param profileRoot - the root the plugin resolves today (`<DSH_HOME>/dsh-builtin-browser-host`).
|
|
388
|
+
* @param legacyRoot - the pre-`DSH_HOME` layout's root. Injected so the move can be exercised
|
|
389
|
+
* without touching the real home directory.
|
|
390
|
+
*/
|
|
391
|
+
export function adoptLegacyProfileRoot(
|
|
392
|
+
profileRoot: string,
|
|
393
|
+
legacyRoot: string = join(homedir(), BROWSER_PROFILE_DIR),
|
|
394
|
+
): void {
|
|
395
|
+
try {
|
|
396
|
+
if (legacyRoot === profileRoot) return
|
|
397
|
+
if (!existsSync(legacyRoot) || existsSync(profileRoot)) return
|
|
398
|
+
// `renameSync` needs the destination's PARENT to exist, and on a fresh install it does not:
|
|
399
|
+
// the old layout never created `.dsh` at all. Creating it is harmless if it is already there.
|
|
400
|
+
mkdirSync(dirname(profileRoot), { recursive: true })
|
|
401
|
+
renameSync(legacyRoot, profileRoot)
|
|
402
|
+
} catch {
|
|
403
|
+
// Nothing here is worth failing an apply over; the fallback is the pre-existing behaviour.
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
296
407
|
/** The host web-server surface this plugin uses, described structurally. */
|
|
297
408
|
interface WebServerHost {
|
|
298
409
|
readonly webServer: {
|
|
@@ -316,7 +427,11 @@ interface WebServerHost {
|
|
|
316
427
|
* @param ctx - the plugin context.
|
|
317
428
|
* @param settings - the settings document to expose.
|
|
318
429
|
*/
|
|
319
|
-
function installSettingsRoute(
|
|
430
|
+
function installSettingsRoute(
|
|
431
|
+
ctx: Context,
|
|
432
|
+
settings: SettingsStore,
|
|
433
|
+
onChannelChange?: () => void,
|
|
434
|
+
): void {
|
|
320
435
|
const inject = (ctx as unknown as {
|
|
321
436
|
inject?: (deps: readonly string[], callback: (host: WebServerHost) => void) => void
|
|
322
437
|
}).inject
|
|
@@ -326,7 +441,7 @@ function installSettingsRoute(ctx: Context, settings: SettingsStore): void {
|
|
|
326
441
|
host.effect(() => host.webServer.register({
|
|
327
442
|
kind: 'exact',
|
|
328
443
|
path: SETTINGS_ROUTE,
|
|
329
|
-
handler: (request, response) => handleSettingsRequest(request, response, settings),
|
|
444
|
+
handler: (request, response) => handleSettingsRequest(request, response, settings, onChannelChange),
|
|
330
445
|
}, 'dsh-builtin-browser: settings'))
|
|
331
446
|
})
|
|
332
447
|
} catch {
|
|
@@ -340,11 +455,13 @@ function installSettingsRoute(ctx: Context, settings: SettingsStore): void {
|
|
|
340
455
|
* @param request - the incoming request.
|
|
341
456
|
* @param response - the response to write.
|
|
342
457
|
* @param settings - the settings document.
|
|
458
|
+
* @param onChannelChange - re-runs the carrier choice when this write changed `browser.channel`.
|
|
343
459
|
*/
|
|
344
460
|
async function handleSettingsRequest(
|
|
345
461
|
request: IncomingMessage,
|
|
346
462
|
response: ServerResponse,
|
|
347
463
|
settings: SettingsStore,
|
|
464
|
+
onChannelChange?: () => void,
|
|
348
465
|
): Promise<void> {
|
|
349
466
|
const json = (status: number, body: unknown): void => {
|
|
350
467
|
response.writeHead(status, {
|
|
@@ -370,7 +487,19 @@ async function handleSettingsRequest(
|
|
|
370
487
|
try {
|
|
371
488
|
const body = await readBody(request)
|
|
372
489
|
const patch: unknown = body.trim() === '' ? {} : JSON.parse(body)
|
|
373
|
-
|
|
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
|
+
})
|
|
374
503
|
} catch (error) {
|
|
375
504
|
json(400, { ok: false, error: `invalid settings patch: ${String(error)}` })
|
|
376
505
|
}
|