dsh-builtin-browser 0.3.0 → 0.4.0

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/cordis.patch.yml CHANGED
@@ -8,13 +8,19 @@
8
8
  # though every tool works — hence the inert root row under the bare package name.
9
9
  #
10
10
  # The browser seam (ctx.browser) is always mounted, and the Electron provider plus
11
- # the browser_* tools are always registered: when a host provides an embedded view
12
- # seam the provider drives that view, and otherwise it self-hosts its own Electron
13
- # window, so installing the plugin is enough for the browser to work anywhere.
14
- # DSH 0.2 no longer provides `electronViewHost` (the desktop shell's embedded
15
- # view), so `ctx.get('electronViewHost')` currently resolves to undefined and
16
- # every surface self-hosts. The row keeps reading it on purpose: a host that
17
- # reintroduces such a seam is then picked up without a code change.
11
+ # the browser_* tools are always registered. The CARRIER is chosen at run time:
12
+ #
13
+ # 1. a browser the user selected in settings (their installed Chrome / Edge),
14
+ # launched with a plugin-owned profile;
15
+ # 2. the desktop shell's sidebar, driven over its loopback bridge — the agent
16
+ # then works on the same page the human is looking at;
17
+ # 3. a self-hosted Electron window, which is what every surface without either
18
+ # of the above gets; installing the plugin is enough for the browser to work.
19
+ #
20
+ # DSH 0.2 no longer provides `electronViewHost` (the desktop shell's embedded view),
21
+ # so the row below resolves to undefined on current hosts. It keeps reading the
22
+ # seam on purpose: a host that reintroduces one is then picked up without a code
23
+ # change, and an explicit `viewHost` always outranks the automatic choice.
18
24
  - insert:
19
25
  # Root row. The host's client-module scan resolves a loader row's specifier
20
26
  # to a PACKAGE ROOT; a row named after a subpath is skipped, and this package
@@ -8,31 +8,92 @@
8
8
  * expressed as something replayable rather than as a one-off edit — re-run this
9
9
  * after every desktop upgrade.
10
10
  *
11
+ * TWO LAYOUTS
12
+ * DSH Desktop ships in two shapes, and they need different work:
13
+ *
14
+ * unpacked `<install>/resources/app/lib/main.js` is a real file. The
15
+ * long-standing path: copy the module next to main.js and append a
16
+ * guarded import to it.
17
+ *
18
+ * packed `<install>/resources/app.asar` holds the whole app (main.js is
19
+ * inside the archive) and `resources/app.asar.unpacked` holds the
20
+ * entries marked `unpacked`, i.e. the native modules. The official
21
+ * Windows build is packed — where this script used to fail with
22
+ * "not a DSH Desktop install" on a perfectly good installation,
23
+ * because it only ever looked for the unpacked main.js.
24
+ *
25
+ * HOW THE PACKED LAYOUT IS HANDLED
26
+ * Electron loads `app.asar` in preference to `app/`, and a directory only wins
27
+ * once the archive is not there. Rewriting the archive in place is not an
28
+ * option either: the official build enables the
29
+ * `EnableEmbeddedAsarIntegrityValidation` fuse (measured: enabled), so a
30
+ * modified app.asar is a broken app. The archive is therefore never touched:
31
+ *
32
+ * 1. extract `app.asar` into `resources/app`, taking the real bytes of
33
+ * `unpacked` entries from `resources/app.asar.unpacked` (a straight
34
+ * archive walk would leave the native modules as empty stubs);
35
+ * 2. install the bridge into that tree exactly as in the unpacked layout;
36
+ * 3. rename `app.asar` to `app.asar.before-bridge`, so Electron falls back to
37
+ * the directory that was just built.
38
+ *
39
+ * The trigger is the ARCHIVE's presence, not the directory's absence. A desktop
40
+ * update writes a new `app.asar` and leaves any earlier extraction lying around
41
+ * (the updater does not know about that directory), so keying off "the tree is
42
+ * missing" would patch the stale tree while Electron keeps loading the archive —
43
+ * a run that reports success with the bridge dead. Whenever the archive is
44
+ * there it is the app, so the tree is rebuilt from it.
45
+ *
46
+ * `--revert` moves the archive back and restores the pristine main.js. The
47
+ * extracted tree then becomes inert (the archive wins again) and can be deleted.
48
+ *
11
49
  * WHAT IT DOES (idempotent)
12
50
  * 1. copies `plugin-browser-bridge.js` next to the app's main bundle;
13
51
  * 2. appends a guarded import to `lib/main.js` that starts it after `whenReady`,
14
52
  * keeping a pristine `main.js.before-bridge` backup the first time;
15
- * 3. does nothing if the import is already present.
53
+ * 3. does nothing if the import is already present;
54
+ * 4. keeps `dsh <args>` working: the Desktop CLI launcher and the CLI's own
55
+ * runtime lookup both spell `app.asar` out loud and both break once the
56
+ * archive is moved aside, so both are patched (each with its own
57
+ * `.before-bridge` backup) and `--revert` restores them.
16
58
  *
17
59
  * USAGE
18
- * node apps/desktop/bridge/install.mjs [path-to-DeepSeek-Harness-install]
60
+ * node desktop-bridge/install.mjs [path-to-DeepSeek-Harness-install]
19
61
  * Default path: %LOCALAPPDATA%\Programs\DeepSeek Harness
20
- * Pass --revert to restore the pre-bridge main.js instead.
62
+ * Pass --revert to restore the pre-bridge state instead.
21
63
  */
22
- import { copyFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
23
- import { join } from 'node:path'
64
+ import { copyFileSync, existsSync, mkdirSync, openSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
65
+ import { dirname, join } from 'node:path'
24
66
 
25
67
  const DEFAULT_INSTALL = join(process.env.LOCALAPPDATA ?? '', 'Programs', 'DeepSeek Harness')
26
68
  const args = process.argv.slice(2)
27
69
  const revert = args.includes('--revert')
28
70
  const installRoot = args.find(arg => !arg.startsWith('--')) ?? DEFAULT_INSTALL
29
71
 
30
- const appDir = join(installRoot, 'resources', 'app')
72
+ const resourcesDir = join(installRoot, 'resources')
73
+ const appDir = join(resourcesDir, 'app')
74
+ const asarPath = join(resourcesDir, 'app.asar')
75
+ const asarMoved = `${asarPath}.before-bridge`
31
76
  const mainPath = join(appDir, 'lib', 'main.js')
32
77
  const bridgeTarget = join(appDir, 'lib', 'plugin-browser-bridge.js')
33
78
  const bridgeSource = join(import.meta.dirname, 'plugin-browser-bridge.js')
34
79
  const backupPath = `${mainPath}.before-bridge`
35
80
 
81
+ // Moving the archive aside breaks two more places that spell `app.asar` out
82
+ // loud, and both are used by `dsh <args>` (the Desktop CLI launcher and the
83
+ // plugin manager behind it), so they are patched here and restored by --revert:
84
+ //
85
+ // runtime/cli/bin/dsh.cmd launches the CLI by that path
86
+ // dsh/.../dsh-desktop-host/lib/cli.js locates `runtime` by that name
87
+ //
88
+ // Without them the CLI dies with MODULE_NOT_FOUND even though the app itself
89
+ // runs fine (the shell resolves everything through app.getAppPath()).
90
+ const cliShimPath = join(resourcesDir, 'runtime', 'cli', 'bin', 'dsh.cmd')
91
+ const cliShimBackup = `${cliShimPath}.before-bridge`
92
+ const hostCliPath = join(appDir, 'dsh', 'node_modules', '@deepseek-ai', 'dsh-desktop-host', 'lib', 'cli.js')
93
+ const hostCliBackup = `${hostCliPath}.before-bridge`
94
+ const CLI_SHIM_MARKER = 'dsh-builtin-browser: resolve the app directory'
95
+ const HOST_CLI_MARKER = 'dsh-builtin-browser: recognize an unpacked `app` directory'
96
+
36
97
  /** The exact block appended to main.js. Keep it recognisable: idempotence and
37
98
  * reverting both key off this marker. */
38
99
  const MARKER = '// dsh-builtin-browser: hand the plugin a way to drive the sidebar'
@@ -54,25 +115,225 @@ app.whenReady().then(async () => {
54
115
  });
55
116
  `
56
117
 
57
- if (!existsSync(mainPath)) {
58
- console.error(`not a DSH Desktop install (no ${mainPath})`)
118
+ /**
119
+ * Parse an asar header — a Chromium pickle holding
120
+ * `[payloadSize][jsonLength][json…]`, preceded by an 8-byte outer pickle.
121
+ * @param file - the archive path.
122
+ * @returns the parsed header tree plus the offset where file content starts.
123
+ */
124
+ function readAsarHeader(file) {
125
+ const fd = openSync(file, 'r')
126
+ const sizeBuf = Buffer.alloc(8)
127
+ readSync(fd, sizeBuf, 0, 8, 0)
128
+ const headerSize = sizeBuf.readUInt32LE(4)
129
+ const headerBuf = Buffer.alloc(headerSize)
130
+ readSync(fd, headerBuf, 0, headerSize, 8)
131
+ const jsonLength = headerBuf.readUInt32LE(4)
132
+ const header = JSON.parse(headerBuf.toString('utf8', 8, 8 + jsonLength))
133
+ return { fd, header, contentBase: 8 + headerSize }
134
+ }
135
+
136
+ /**
137
+ * Write one asar tree to disk, taking `unpacked` entries from the sibling
138
+ * `.unpacked` directory — their bytes are not in the archive.
139
+ * @param asar - the archive path.
140
+ * @param outDir - directory to extract into.
141
+ * @returns file and byte counts, and how many entries came from `.unpacked`.
142
+ */
143
+ function extractAsar(asar, outDir) {
144
+ const { fd, header, contentBase } = readAsarHeader(asar)
145
+ const unpackedRoot = `${asar}.unpacked`
146
+ let files = 0
147
+ let bytes = 0
148
+ let unpackedFiles = 0
149
+ const walk = (node, prefix) => {
150
+ for (const [name, entry] of Object.entries(node.files ?? {})) {
151
+ const relativePath = prefix === '' ? name : `${prefix}/${name}`
152
+ const target = join(outDir, ...relativePath.split('/'))
153
+ if (entry.files) {
154
+ mkdirSync(target, { recursive: true })
155
+ walk(entry, relativePath)
156
+ continue
157
+ }
158
+ mkdirSync(dirname(target), { recursive: true })
159
+ if (entry.unpacked) {
160
+ const source = join(unpackedRoot, ...relativePath.split('/'))
161
+ if (!existsSync(source)) throw new Error(`unpacked entry is missing: ${source}`)
162
+ copyFileSync(source, target)
163
+ unpackedFiles += 1
164
+ } else {
165
+ const buffer = Buffer.alloc(entry.size)
166
+ readSync(fd, buffer, 0, entry.size, contentBase + Number(entry.offset))
167
+ writeFileSync(target, buffer)
168
+ }
169
+ const actual = statSync(target).size
170
+ if (actual !== entry.size) throw new Error(`size mismatch for ${relativePath}: header ${entry.size}, wrote ${actual}`)
171
+ files += 1
172
+ bytes += actual
173
+ }
174
+ }
175
+ mkdirSync(outDir, { recursive: true })
176
+ walk(header, '')
177
+ return { files, bytes, unpackedFiles }
178
+ }
179
+
180
+ /**
181
+ * Make the Desktop CLI launcher resolve the app directory instead of spelling
182
+ * `app.asar` out loud.
183
+ *
184
+ * `resources/runtime/cli/bin/dsh.cmd` is what the `dsh` command on PATH calls,
185
+ * and it launches the CLI through an `app.asar\…` path. Once the archive is
186
+ * moved aside that path is gone, so `dsh <args>` dies with MODULE_NOT_FOUND
187
+ * while the app itself keeps working (the shell resolves everything through
188
+ * `app.getAppPath()`). The patch touches only the app-directory fragment: the
189
+ * launcher keeps its own spelling of everything else, including the executable
190
+ * name, so a build whose binary is named differently still works.
191
+ *
192
+ * @returns what happened, for the install log.
193
+ */
194
+ function patchCliShim() {
195
+ if (!existsSync(cliShimPath)) return 'launcher not present (skipped)'
196
+ const text = readFileSync(cliShimPath, 'utf8')
197
+ if (text.includes(CLI_SHIM_MARKER)) return 'already patched'
198
+ if (!/%~dp0(?:\.\.\\)+app\.asar\\dsh\\/.test(text)) return 'unexpected launcher shape (skipped)'
199
+ if (!existsSync(cliShimBackup)) copyFileSync(cliShimPath, cliShimBackup)
200
+ const eol = text.includes('\r\n') ? '\r\n' : '\n'
201
+ const setLocal = /^.*setlocal.*$/m.exec(text)
202
+ const injection = [
203
+ `rem ${CLI_SHIM_MARKER} (resources\\app when the bridge is installed,`,
204
+ 'rem resources\\app.asar on a stock installation).',
205
+ 'set "DSH_APP=%~dp0..\\..\\..\\app"',
206
+ 'if not exist "%DSH_APP%\\dsh\\node_modules\\@deepseek-ai\\dsh-desktop-host\\lib\\cli.js" set "DSH_APP=%~dp0..\\..\\..\\app.asar"',
207
+ ].join(eol)
208
+ const withVariables = setLocal === null
209
+ ? `${injection}${eol}${text}`
210
+ : text.replace(setLocal[0], `${setLocal[0]}${eol}${injection}`)
211
+ writeFileSync(cliShimPath, withVariables.replace(/%~dp0(?:\.\.\\)+app\.asar\\dsh\\/, '%DSH_APP%\\dsh\\'))
212
+ return 'patched'
213
+ }
214
+
215
+ /**
216
+ * Teach the Desktop CLI where its `runtime` directory is when the app is an
217
+ * unpacked `app` directory rather than `app.asar`.
218
+ *
219
+ * `dsh-desktop-host/lib/cli.js` derives the directory that holds pnpm as
220
+ * `dirname(runtimeArchivePath(runtimeDir) ?? runtimeDir)`, and
221
+ * `runtimeArchivePath` only recognises a parent literally named `app.asar`.
222
+ * With the app at `resources/app` that falls back to one level too few and
223
+ * resolves `resources/app/runtime`, which does not exist — so the plugin
224
+ * manager behind `dsh plugin …` cannot start.
225
+ *
226
+ * @returns what happened, for the install log.
227
+ */
228
+ function patchHostCli() {
229
+ if (!existsSync(hostCliPath)) return 'cli.js not present (skipped)'
230
+ const text = readFileSync(hostCliPath, 'utf8')
231
+ if (text.includes(HOST_CLI_MARKER)) return 'already patched'
232
+ const before = '\tawait runDesktopCli(runtimeDir, join(dirname(runtimeArchivePath(runtimeDir) ?? runtimeDir), "runtime"));'
233
+ if (!text.includes(before)) return 'unexpected cli.js shape (skipped)'
234
+ if (!existsSync(hostCliBackup)) copyFileSync(hostCliPath, hostCliBackup)
235
+ const eol = text.includes('\r\n') ? '\r\n' : '\n'
236
+ const after = [
237
+ `\t// ${HOST_CLI_MARKER} as the app root,`,
238
+ '\t// so an installation whose app.asar was moved aside still finds its runtime.',
239
+ '\tconst cliOwnerDir = dirname(runtimeDir);',
240
+ '\tconst cliSupportRoot = basename(cliOwnerDir) === "app" || basename(cliOwnerDir) === "app.asar" ? dirname(cliOwnerDir) : cliOwnerDir;',
241
+ '\tawait runDesktopCli(runtimeDir, join(cliSupportRoot, "runtime"));',
242
+ ].join(eol)
243
+ writeFileSync(hostCliPath, text.replace(before, after))
244
+ return 'patched'
245
+ }
246
+
247
+ /** Apply both CLI compatibility patches and report them. */
248
+ function installCliCompatibility() {
249
+ console.log(` cli launcher: ${patchCliShim()}`)
250
+ console.log(` cli runtime resolution: ${patchHostCli()}`)
251
+ }
252
+
253
+ // The archive wins whenever it exists, so ITS presence — not the absence of the
254
+ // directory — decides the layout. After a desktop update the archive is back
255
+ // (a new build) while an older extraction is still lying around, and keying off
256
+ // the directory would patch a tree that Electron never loads.
257
+ const packedLayout = existsSync(asarPath)
258
+ if (!existsSync(mainPath) && !existsSync(asarPath)) {
259
+ console.error('not a DSH Desktop install: neither')
260
+ console.error(` ${mainPath}`)
261
+ console.error('nor')
262
+ console.error(` ${asarPath}`)
263
+ console.error('exists. Pass the installation directory as the first argument.')
59
264
  process.exit(1)
60
265
  }
61
266
 
62
267
  if (revert) {
63
- if (!existsSync(backupPath)) {
64
- console.error(`nothing to revert: ${backupPath} does not exist`)
268
+ let reverted = false
269
+ if (existsSync(asarMoved)) {
270
+ if (existsSync(asarPath)) rmSync(asarPath, { force: true })
271
+ renameSync(asarMoved, asarPath)
272
+ console.log(`restored the app archive: app.asar.before-bridge -> app.asar`)
273
+ reverted = true
274
+ }
275
+ if (existsSync(backupPath)) {
276
+ copyFileSync(backupPath, mainPath)
277
+ console.log(`restored ${mainPath} from ${backupPath}`)
278
+ reverted = true
279
+ }
280
+ if (existsSync(cliShimBackup)) {
281
+ copyFileSync(cliShimBackup, cliShimPath)
282
+ console.log(`restored the CLI launcher: ${cliShimPath}`)
283
+ reverted = true
284
+ }
285
+ if (existsSync(hostCliBackup) && existsSync(dirname(hostCliPath))) {
286
+ copyFileSync(hostCliBackup, hostCliPath)
287
+ console.log(`restored the CLI runtime resolution: ${hostCliPath}`)
288
+ reverted = true
289
+ }
290
+ if (!reverted) {
291
+ console.error(`nothing to revert in ${installRoot}`)
65
292
  process.exit(1)
66
293
  }
67
- copyFileSync(backupPath, mainPath)
68
- console.log(`reverted ${mainPath} from ${backupPath}`)
294
+ console.log(`note: ${appDir} is inert while app.asar exists; delete it to reclaim ~350 MB`)
69
295
  process.exit(0)
70
296
  }
71
297
 
298
+ if (packedLayout) {
299
+ const stale = existsSync(appDir)
300
+ const started = Date.now()
301
+ console.log(stale
302
+ ? `packed layout detected (${asarPath}); an earlier extraction is present and will be rebuilt`
303
+ : `packed layout detected (${asarPath})`)
304
+ if (stale) {
305
+ // Rebuild rather than merge: the tree has to describe THIS archive, and a
306
+ // merge would keep files the new build no longer ships. Removing it is safe
307
+ // here — the archive is present, so the app is not running from this
308
+ // directory, and a failure below leaves the archive in place and the
309
+ // installation working.
310
+ rmSync(appDir, { recursive: true, force: true })
311
+ }
312
+ console.log(`extracting into ${appDir} — this writes ~355 MB and can take a minute (measured: 8-55s)...`)
313
+ const result = extractAsar(asarPath, appDir)
314
+ const megabytes = (result.bytes / 1024 / 1024).toFixed(1)
315
+ const seconds = ((Date.now() - started) / 1000).toFixed(1)
316
+ console.log(` extracted ${result.files} files / ${megabytes} MB` +
317
+ ` (${result.unpackedFiles} from app.asar.unpacked) in ${seconds}s`)
318
+ // A fresh extraction invalidates any earlier pristine copy: --revert has to be
319
+ // able to restore the main.js that belongs to THIS build.
320
+ copyFileSync(mainPath, backupPath)
321
+ try {
322
+ renameSync(asarPath, asarMoved)
323
+ console.log('moved the archive aside: app.asar -> app.asar.before-bridge')
324
+ console.log(` Electron now loads ${appDir}; the archive is untouched, so the asar integrity fuse cannot trip`)
325
+ } catch (error) {
326
+ console.error(`could not move app.asar aside: ${String(error)}`)
327
+ console.error('close DSH Desktop completely and run this again.')
328
+ process.exit(1)
329
+ }
330
+ }
331
+
72
332
  const current = readFileSync(mainPath, 'utf8')
73
333
  if (current.includes(MARKER)) {
74
334
  console.log('bridge already installed (main.js carries the marker); refreshing the module copy')
75
335
  copyFileSync(bridgeSource, bridgeTarget)
336
+ installCliCompatibility()
76
337
  process.exit(0)
77
338
  }
78
339
 
@@ -95,4 +356,5 @@ copyFileSync(bridgeSource, bridgeTarget)
95
356
  console.log(`installed bridge into ${appDir}`)
96
357
  console.log(` module: ${bridgeTarget}`)
97
358
  console.log(` patch: ${mainPath} (+${SNIPPET.split('\n').length} lines)`)
359
+ installCliCompatibility()
98
360
  console.log('restart DSH Desktop to activate it.')
@@ -24,12 +24,24 @@
24
24
  import { createServer } from 'node:net'
25
25
  import { randomBytes } from 'node:crypto'
26
26
  import { writeFileSync, mkdirSync } from 'node:fs'
27
+ import { homedir } from 'node:os'
27
28
  import { join } from 'node:path'
28
29
  import { app, webContents } from 'electron'
29
30
 
30
- /** Absolute path of the endpoint file the plugin reads. */
31
+ /**
32
+ * Absolute path of the endpoint file the plugin reads.
33
+ *
34
+ * The fallback has to match the plugin's own (`process.env.DSH_HOME ??
35
+ * homedir()/.dsh`, see src/browser-electron/desktop-bridge-host.ts). With a
36
+ * bare `?? ''` an unset DSH_HOME resolves to the process CWD, so the endpoint
37
+ * landed beside the app — while the plugin looked in ~/.dsh and never found it.
38
+ * The failure is silent in both directions: discovery returns undefined and the
39
+ * plugin quietly falls back to a self-hosted window, with no error anywhere.
40
+ * Measured on DSH Desktop 0.2.0-rc.2, launched from a shortcut (no DSH_HOME in
41
+ * its environment).
42
+ */
31
43
  function endpointFile() {
32
- const home = process.env.DSH_HOME ?? ''
44
+ const home = process.env.DSH_HOME ?? join(homedir(), '.dsh')
33
45
  return join(home, 'dsh-builtin-browser-bridge.json')
34
46
  }
35
47
 
@@ -128,10 +140,21 @@ async function handle(request) {
128
140
  // b. otherwise type into the address field and submit with REAL key input
129
141
  // (synthetic events do not move React's controlled input, and the field
130
142
  // also loses focus to re-renders, so both halves are needed).
143
+ let submitVerdict = 'n/a'
131
144
  const restored = await sendCdp(shell.id, 'Runtime.evaluate', {
145
+ // Only real controls count, and only their OWN text does. Matching any
146
+ // node's `textContent` made a container that merely mentioned restoring a
147
+ // page report RESTORED — which skipped the address route below entirely
148
+ // and left the guest unmaterialized (measured on DSH 0.2.0-rc.2, where the
149
+ // sidebar shows no restore control at all).
132
150
  expression: `(() => {
133
151
  const nodes = Array.from(document.querySelectorAll('button,a,[role=button],div[role=link]'));
134
- const hit = nodes.find(n => /恢复页面|恢复上次|上次打开/.test((n.textContent || '').trim()));
152
+ const hit = nodes.find(n => {
153
+ const own = (n.textContent || '').trim();
154
+ if (own.length > 24) return false;
155
+ return /^(恢复页面|恢复上次|恢复上次页面|上次打开)$/.test(own)
156
+ || /恢复|restore/i.test(n.getAttribute('aria-label') || n.getAttribute('title') || '');
157
+ });
135
158
  if (hit === undefined) return 'NO_RESTORE';
136
159
  hit.click();
137
160
  return 'RESTORED';
@@ -140,8 +163,15 @@ async function handle(request) {
140
163
  })
141
164
  if (restored?.result?.value !== 'RESTORED') {
142
165
  if (url !== '') {
143
- // Set the value the way React accepts it, then submit with real input.
144
- await sendCdp(shell.id, 'Runtime.evaluate', {
166
+ // Set the value the way React accepts it, then SUBMIT THE FORM.
167
+ //
168
+ // A dispatched Enter is not enough: the sidebar browser's toolbar is a
169
+ // `<form>` whose submission is wired to an explicit control (aria-label
170
+ // "前往" / "Go"), and implicit submission does not fire there — measured
171
+ // on DSH 0.2.0-rc.2, where Enter left the field filled and the guest
172
+ // uncreated. `requestSubmit()` goes through the same path the button
173
+ // does, so it works with React's onSubmit and needs no localized label.
174
+ const typed = await sendCdp(shell.id, 'Runtime.evaluate', {
145
175
  expression: `(() => {
146
176
  const inputs = Array.from(document.querySelectorAll('input')).filter(i => /HTTP|地址|url/i.test((i.placeholder || '') + (i.getAttribute('aria-label') || '')));
147
177
  if (inputs.length === 0) return 'NO_ADDRESS_BAR';
@@ -150,18 +180,28 @@ async function handle(request) {
150
180
  setter.call(input, ${JSON.stringify(String(request.url ?? ''))});
151
181
  input.dispatchEvent(new Event('input', { bubbles: true }));
152
182
  input.focus();
153
- return 'TYPED';
183
+ const form = input.form ?? input.closest('form');
184
+ if (form) { form.requestSubmit(); return 'SUBMITTED_FORM'; }
185
+ const button = input.parentElement
186
+ ? input.parentElement.querySelector('button[type=submit],button')
187
+ : null;
188
+ if (button) { button.click(); return 'CLICKED_SUBMIT'; }
189
+ return 'TYPED_ONLY';
154
190
  })()`,
155
191
  returnByValue: true,
156
192
  })
157
- for (const type of ['keyDown', 'keyUp']) {
158
- await sendCdp(shell.id, 'Input.dispatchKeyEvent', {
159
- type,
160
- key: 'Enter',
161
- code: 'Enter',
162
- windowsVirtualKeyCode: 13,
163
- nativeVirtualKeyCode: 13,
164
- })
193
+ submitVerdict = String(typed?.result?.value ?? 'n/a')
194
+ // Enter stays as the last resort for sidebars without a form.
195
+ if (submitVerdict === 'TYPED_ONLY') {
196
+ for (const type of ['keyDown', 'keyUp']) {
197
+ await sendCdp(shell.id, 'Input.dispatchKeyEvent', {
198
+ type,
199
+ key: 'Enter',
200
+ code: 'Enter',
201
+ windowsVirtualKeyCode: 13,
202
+ nativeVirtualKeyCode: 13,
203
+ })
204
+ }
165
205
  }
166
206
  }
167
207
  }
@@ -177,7 +217,7 @@ async function handle(request) {
177
217
  return { ok: true, created: true, id: created[0].id, via: restored?.result?.value === 'RESTORED' ? 'restore' : 'address' }
178
218
  }
179
219
  }
180
- throw new Error(`the sidebar did not create a browser guest (prepare=${String(verdict)}, restore=${String(restored?.result?.value)})`)
220
+ throw new Error(`the sidebar did not create a browser guest (prepare=${String(verdict)}, restore=${String(restored?.result?.value)}, submit=${submitVerdict})`)
181
221
  }
182
222
  if (op === 'ensureTabs') {
183
223
  // Grow the sidebar's browser tab strip to `count` tabs and report every guest.
@@ -35,7 +35,6 @@ export declare function bridgeEndpointPath(): string;
35
35
  * point is a single page both parties can see.
36
36
  */
37
37
  export declare class DesktopBridgeViewHost implements ElectronBrowserViewHost {
38
- private readonly endpoint;
39
38
  private readonly connection;
40
39
  private readonly views;
41
40
  /**
@@ -47,6 +46,16 @@ export declare class DesktopBridgeViewHost implements ElectronBrowserViewHost {
47
46
  * session gets its own page).
48
47
  */
49
48
  private readonly orphaned;
49
+ /**
50
+ * Registered by the provider, and deliberately never invoked on this carrier.
51
+ *
52
+ * The sidebar is the shell's own interface: a human clicking inside that page is an
53
+ * event the shell owns, and the bridge exposes no operation that reports it. The
54
+ * handler is stored anyway so the interface contract holds (a caller may register
55
+ * one and must not be surprised) and so a future bridge operation can deliver it
56
+ * without changing the provider. Consequently no user-action event is emitted while
57
+ * the desktop sidebar is the carrier — by design, not by omission.
58
+ */
50
59
  private userActionHandler;
51
60
  /**
52
61
  * @param endpoint - the shell's published bridge endpoint.
@@ -50,7 +50,6 @@ function isGuestGone(error) {
50
50
  * point is a single page both parties can see.
51
51
  */
52
52
  export class DesktopBridgeViewHost {
53
- endpoint;
54
53
  connection;
55
54
  views = new Map();
56
55
  /**
@@ -62,12 +61,21 @@ export class DesktopBridgeViewHost {
62
61
  * session gets its own page).
63
62
  */
64
63
  orphaned = new Set();
64
+ /**
65
+ * Registered by the provider, and deliberately never invoked on this carrier.
66
+ *
67
+ * The sidebar is the shell's own interface: a human clicking inside that page is an
68
+ * event the shell owns, and the bridge exposes no operation that reports it. The
69
+ * handler is stored anyway so the interface contract holds (a caller may register
70
+ * one and must not be surprised) and so a future bridge operation can deliver it
71
+ * without changing the provider. Consequently no user-action event is emitted while
72
+ * the desktop sidebar is the carrier — by design, not by omission.
73
+ */
65
74
  userActionHandler;
66
75
  /**
67
76
  * @param endpoint - the shell's published bridge endpoint.
68
77
  */
69
78
  constructor(endpoint) {
70
- this.endpoint = endpoint;
71
79
  this.connection = new BridgeConnection(endpoint);
72
80
  }
73
81
  /**
@@ -16,7 +16,8 @@ import { homedir } from 'node:os';
16
16
  import { join } from 'node:path';
17
17
  import { defaultHostMainPath, RemoteElectronViewHost } from './remote-host.js';
18
18
  import { DesktopBridgeViewHost } from './desktop-bridge-host.js';
19
- import { detectBrowser, SystemBrowserViewHost } from './system-browser.js';
19
+ import { detectBrowser, searchSummary, SystemBrowserViewHost } from './system-browser.js';
20
+ import { MissingSystemBrowserHost } from './missing-system-browser.js';
20
21
  import { SettingsStore } from './settings-store.js';
21
22
  export { ELECTRON_BROWSER_PROVIDER_ID, ElectronBrowserProvider, } from './provider.js';
22
23
  export { RemoteElectronViewHost, defaultHostMainPath } from './remote-host.js';
@@ -90,31 +91,49 @@ export function apply(ctx, config) {
90
91
  // A browser the user explicitly asked for outranks every automatic choice: the
91
92
  // whole point of the setting is that they know which browser they want. It is
92
93
  // launched with a plugin-owned profile, so their own windows are untouched.
93
- void (async () => {
94
- const channel = settings.get().browser.channel;
95
- if (channel === 'bundled')
96
- return;
94
+ //
95
+ // Registration is INERT: constructing the host starts nothing. The browser only
96
+ // launches when a command actually needs a page, so merely running DSH never
97
+ // spawns a browser window. (An earlier version launched it here, which meant a
98
+ // browser appeared the moment the plugin loaded — reported and fixed.)
99
+ const channel = settings.get().browser.channel;
100
+ // Naming a browser is a decision about which one to use, so it also decides whether the
101
+ // desktop sidebar may take over. Skipping discovery for an explicit choice is what makes
102
+ // that choice real — otherwise a user who picked Chrome would silently get the sidebar,
103
+ // and a user who picked a browser that is not installed would never see the explanation
104
+ // written for exactly that case, because the sidebar would quietly replace it.
105
+ const explicitChoice = channel !== 'bundled' && channel !== 'auto';
106
+ if (channel !== 'bundled') {
97
107
  const detected = detectBrowser(channel);
98
108
  if (detected === undefined) {
99
- ctx.logger?.warn?.(`dsh-builtin-browser: ${channel} was requested but no installation was found; using the bundled browser`);
100
- return;
109
+ // `auto` promises to take whatever is available, so falling back is what it
110
+ // asked for. Naming a browser is a specific request, and quietly satisfying it
111
+ // with a different one turns the real problem ("no Chrome here") into a
112
+ // confusing error about Electron later on. Report it instead.
113
+ if (channel === 'auto') {
114
+ ctx.logger?.warn?.('dsh-builtin-browser: no installed browser was found; using the bundled browser');
115
+ }
116
+ else {
117
+ adopt(new MissingSystemBrowserHost(channel, searchSummary(channel)), `the selected ${channel} is not installed — commands will explain how to fix it`);
118
+ }
101
119
  }
102
- const home = process.env.DSH_HOME ?? homedir();
103
- // Login state lives in the browser profile, so the cookies setting decides where
104
- // that profile goes: a stable directory keeps the user signed in across restarts
105
- // (the point of using their own browser), while turning persistence off gets a
106
- // throwaway directory that is removed when the browser is released.
107
- const persist = settings.get().cookies.persist;
108
- const profileDir = persist
109
- ? join(home, 'dsh-builtin-browser-host', `${detected.kind}-profile`)
110
- : join(home, 'dsh-builtin-browser-host', `${detected.kind}-profile-ephemeral-${randomUUID()}`);
111
- const host = await SystemBrowserViewHost.launch(detected, profileDir, [], persist ? undefined : profileDir);
112
- if (host === undefined) {
113
- ctx.logger?.warn?.(`dsh-builtin-browser: ${detected.kind} did not start; using the bundled browser`);
114
- return;
120
+ else {
121
+ const home = process.env.DSH_HOME ?? homedir();
122
+ // Login state lives in the browser profile, so the cookies setting decides where
123
+ // that profile goes: a stable directory keeps the user signed in across restarts
124
+ // (the point of using their own browser), while turning persistence off gets a
125
+ // throwaway directory that is removed when the browser is released.
126
+ const persist = settings.get().cookies.persist;
127
+ const profileDir = persist
128
+ ? join(home, 'dsh-builtin-browser-host', `${detected.kind}-profile`)
129
+ : join(home, 'dsh-builtin-browser-host', `${detected.kind}-profile-ephemeral-${randomUUID()}`);
130
+ adopt(new SystemBrowserViewHost(detected, profileDir, [], persist ? undefined : profileDir), `will drive the installed ${detected.kind} (${detected.path}${persist ? '' : ', ephemeral profile'})`);
115
131
  }
116
- adopt(host, `driving the installed ${detected.kind} (${detected.path}${persist ? '' : ', ephemeral profile'})`);
117
- })().catch(() => { });
132
+ }
133
+ // An explicit choice was already adopted above; discovering the sidebar here would
134
+ // replace it, which is exactly what this guard prevents.
135
+ if (explicitChoice)
136
+ return;
118
137
  void DesktopBridgeViewHost.discover().then(sidebar => {
119
138
  // No bridge (plain `dsh web`, an older desktop build, or a shell that already
120
139
  // exited): keep self-hosting, which is what every surface has always done.
@@ -137,7 +137,11 @@ export class HistoryStore {
137
137
  return [];
138
138
  }
139
139
  const pages = [];
140
- for (const line of raw.split('\n')) {
140
+ // Strip a BOM first: trim() does not remove U+FEFF (it is not whitespace in
141
+ // ECMAScript), so a file written by Notepad or PowerShell made the first line fail to
142
+ // parse and be discarded as corrupt — silently losing one visit, or the whole file when
143
+ // it holds one. settings-store strips one for the same reason.
144
+ for (const line of raw.replace(/^\uFEFF/, '').split('\n')) {
141
145
  const trimmed = line.trim();
142
146
  if (trimmed === '')
143
147
  continue;