dsh-builtin-browser 0.3.1 → 0.4.1

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.
Files changed (34) hide show
  1. package/README.en.md +84 -39
  2. package/README.md +78 -38
  3. package/client.js +2 -3
  4. package/cordis.patch.yml +13 -7
  5. package/desktop-bridge/install.mjs +274 -12
  6. package/desktop-bridge/plugin-browser-bridge.js +55 -15
  7. package/docs/architecture.md +1 -1
  8. package/docs/tool-reference.md +8 -8
  9. package/docs/user-guide.md +4 -2
  10. package/docs/why-browser.md +1 -1
  11. package/lib/browser-electron/desktop-bridge-host.d.ts +10 -1
  12. package/lib/browser-electron/desktop-bridge-host.js +10 -2
  13. package/lib/browser-electron/entry.js +22 -2
  14. package/lib/browser-electron/history-store.js +5 -1
  15. package/lib/browser-electron/missing-system-browser.d.ts +54 -0
  16. package/lib/browser-electron/missing-system-browser.js +89 -0
  17. package/lib/browser-electron/provider.d.ts +54 -1
  18. package/lib/browser-electron/provider.js +241 -37
  19. package/lib/browser-electron/remote-host.js +6 -3
  20. package/lib/browser-electron/settings-store.js +10 -4
  21. package/lib/browser-electron/system-browser.d.ts +27 -6
  22. package/lib/browser-electron/system-browser.js +274 -37
  23. package/lib/tool-browser/index.d.ts +14 -0
  24. package/lib/tool-browser/index.js +143 -58
  25. package/package.json +1 -1
  26. package/src/browser-electron/desktop-bridge-host.ts +11 -2
  27. package/src/browser-electron/entry.ts +22 -2
  28. package/src/browser-electron/history-store.ts +5 -1
  29. package/src/browser-electron/missing-system-browser.ts +113 -0
  30. package/src/browser-electron/provider.ts +267 -36
  31. package/src/browser-electron/remote-host.ts +6 -3
  32. package/src/browser-electron/settings-store.ts +11 -4
  33. package/src/browser-electron/system-browser.ts +256 -31
  34. package/src/tool-browser/index.ts +149 -63
@@ -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.
@@ -61,7 +61,7 @@ RemoteElectronViewHost ──TCP JSON-RPC──▶ host-main.js
61
61
  - **协议**:本机 loopback TCP,每行一个 JSON(`{ id, op, ... }` ↔ `{ id, ok, result|err }`);
62
62
  - **认证**:每次 spawn 生成随机 token,经 **stdin 首行 + `DSH_BROWSER_RPC_TOKEN` 环境变量双通道**传给子进程(绝不进 argv,避免进程列表泄露;Windows GUI 进程收不到 piped stdin,环境变量兜底),子进程首条消息必须回传该 token(`hello`);服务端只接受**一个**连接,其余连接直接断开——本机其他进程无法伪冒子进程或注入回复;
63
63
  - **Electron 定位**:① `ELECTRON_PATH`(显式覆盖,优先于一切自动发现)→ ② 随插件安装的 electron 包(纯文件系统探测,含 pnpm store 布局,不触发 44+ 懒下载)→ ③ 锚点与 pnpm store 中**版本最新**者(33.x 有合成器缺陷,建议 ≥ 40)→ ④ 当前进程是**裸** Electron(dev 模式)时复用宿主二进制(`process.execPath`)→ ⑤ 进程祖先树中的裸 Electron 二进制(Windows 用 PowerShell CIM 查询,仅在其它路径全部落空时才执行)。**打包应用不参与复用**:旁有 `resources/app.asar` 的可执行文件(如 DSH Desktop.exe)不能按脚本参数拉起——spawn 会启动应用本体并秒退(单实例锁,即 "browser host exited (code=0)"),一律跳过;
64
- - **稳健性**:子进程/套接字都有 `error` 监听(否则未捕获事件会炸掉整个 DSH 进程);子进程退出自动重启,已物化的视图句柄在下次调用时**自动重建并重试一次**(会话跨崩溃存活,仅页面状态丢失,不再出现 "browser host is not running" 僵尸态);物化失败可重试;下载仅限 HTTP(S)、`savePath` 必须绝对路径(默认限定 `~/Downloads`,可配置 `downloadDir` 覆盖)、流式限流 + Content-Length 提前拒绝、256MB 上限与 60s 超时,文件由**子进程直接落盘**(临时文件 + rename,不再经 RPC 传 base64);cookie 导出/恢复有 30s 超时;
64
+ - **稳健性**:子进程/套接字都有 `error` 监听(否则未捕获事件会炸掉整个 DSH 进程);子进程退出自动重启,已物化的视图句柄在下次调用时**自动重建并重试一次**(会话跨崩溃存活,仅页面状态丢失,不再出现 "browser host is not running" 僵尸态);物化失败可重试;下载仅限 HTTP(S)、`savePath` 必须绝对路径(`downloadDir` 已配置时用它;未配置时按序探测 —— 存在的 `XDG_DOWNLOAD_DIR` → home 下第一个存在的 `Downloads`/`下载`/`下載` → 回退 `~/Downloads`,首次使用时创建)、流式限流 + Content-Length 提前拒绝、256MB 上限与 60s 超时,文件由**子进程直接落盘**(临时文件 + rename,不再经 RPC 传 base64);cookie 导出/恢复有 30s 超时,且两者都受设置里 `credentials.allowRead` 门控 —— 关闭时抛 `BROWSER_AUTH_DISABLED`(导出**与恢复**都拒绝),有原生方法时优先用它,否则走 CDP 的 `Storage.getCookies` / `Storage.setCookies`(三种载体都可用);
65
65
  - **视图可见性**:多标签/多会话时,`showView` 隐藏其他视图并把目标视图置顶(remove+re-add),确保用户看到的是活动标签;目标是当前可见视图时**跳过** remove/re-add(否则每次操作都闪烁);窗口标题实时显示当前可见会话的任务标识 + 页面标题/URL(`showView` 携带会话 label,子进程读 `getTitle`/`getURL`);窗口 resize 时视图 bounds 自动跟随;
66
66
  - **孤儿防护**:父进程断开时子进程自动退出,不留僵尸窗口;
67
67
  - **cookie 落盘**:子进程使用独立 userData 目录(`<DSH_HOME>/dsh-builtin-browser-host`),登录态跨重启保留(另有 `browser_auth` 手动导出/恢复)。
@@ -1,6 +1,6 @@
1
1
  # 工具参考
2
2
 
3
- 全部 34 个 `browser_*` 工具。守卫列:✅ 表示该动作受 `browser_restrict` 白名单约束;只读工具永不拦截。
3
+ 全部 34 个 `browser_*` 工具。守卫列:✅ 表示该动作受 `browser_restrict` 白名单约束;**「– 豁免」表示无论白名单怎么写都不拦截**(豁免集合为 `READ_ONLY_TOOLS`,`src/tool-browser/index.ts` 导出:**只观察的工具** —— `snapshot`/`a11y`/`content`/`scrape`/`screenshot`/`get_value`/`wait`/`challenge`/`list_tabs`/`session`/`history`/`visited`/`auth` —— 加上 **解除限制 / 从坏状态恢复的工具** `restrict`/`reset_session`/`reset`;`restrict` 在其中,否则限制到空的任务再也解不开);**「–」表示该工具只观察、不做动作**,故没有可拦的东西。两者之外的工具都受白名单约束 —— 例如 `browser_close_tab` 不属于豁免集合。
4
4
 
5
5
  ## 页面与导航
6
6
 
@@ -30,16 +30,16 @@
30
30
  | `browser_check` | `target`(必填), `checked?` | `{ checked }` | ✅ | 勾选/取消勾选 checkbox 或 radio(按 target 定位) |
31
31
  | `browser_select` | `target`(必填), `optionValue?`/`optionText?`/`optionIndex?`(三选一) | `{ value, text }` | ✅ | 选中 `<select>` 的某个选项(按 target 定位) |
32
32
  | `browser_clear` | `target`(必填) | `{ cleared }` | ✅ | 清空输入/文本域/contenteditable,或取消勾选 checkbox/radio |
33
- | `browser_get_value` | `target`(必填) | `{ value?, checked?, selectedText? }` | – | 读取元素当前值,用于操作后验证 |
34
- | `browser_scrape` | `item`(必填), `fields`(必填,映射), `timeoutMs?` | `{ count, items[] }` | – | 结构化提取:容器选择器 + 字段映射(如 `{"title": "h3", "url": "a@href"}`);静态 CSS 查询、不执行任意代码、CSP 安全;等待 item 出现(默认 5s) |
33
+ | `browser_get_value` | `target`(必填) | `{ value?, checked?, selectedText? }` | – 豁免 | 读取元素当前值,用于操作后验证 |
34
+ | `browser_scrape` | `item`(必填), `fields`(必填,映射), `timeoutMs?` | `{ count, items[] }` | – 豁免 | 结构化提取:容器选择器 + 字段映射(如 `{"title": "h3", "url": "a@href"}`);静态 CSS 查询、不执行任意代码、CSP 安全;等待 item 出现(默认 5s) |
35
35
 
36
36
  ## 标签与会话
37
37
 
38
38
  | 工具 | 参数 | 输出 | 守卫 | 说明 |
39
39
  | --- | --- | --- | --- | --- |
40
- | `browser_list_tabs` | – | `{ session, tabs[] }` | – | 当前会话的标签列表 |
40
+ | `browser_list_tabs` | – | `{ tabs: [{ id, url, active }] }` | – | 当前会话的标签列表(输出 schema 只有 `tabs`,**没有 `session` 字段**;会话标识请用 `browser_session`) |
41
41
  | `browser_switch_tab` | `tabId`(必填) | `{ switched }` | ✅ | 按 id 切换标签;自托管下同步切换可见视图 |
42
- | `browser_close_tab` | `tabId`(必填) | `{ closed }` | – | 关闭标签;关闭活动标签后激活下一个 |
42
+ | `browser_close_tab` | `tabId`(必填) | `{ closed }` | ✅ | 关闭标签;关闭活动标签后激活下一个。**不属于只读豁免集合,受白名单约束**;`tabId` 只在本会话内查找(接受 `tab:<uuid>` 或裸 uuid),陈旧 id 不会关掉别的任务的标签页 |
43
43
  | `browser_reset` | – | `{ reset }` | ✅ | 关闭本任务所有标签,回到一个空白标签 |
44
44
  | `browser_session` | – | `{ session, tabs[] }` | – | 查看本任务的浏览器会话与标签 |
45
45
  | `browser_reset_session` | – | `{ reset }` | ✅ | 关闭并重建本任务的浏览器会话(崩溃/卡死后恢复) |
@@ -57,14 +57,14 @@
57
57
 
58
58
  | 工具 | 参数 | 输出 | 守卫 | 说明 |
59
59
  | --- | --- | --- | --- | --- |
60
- | `browser_auth` | `action`(flush/restore,必填), `cookies?` | `{ cookies[]? / restored? }` | ✅ | 导出/恢复 cookie(自托管可用);flush 返回 cookie 列表,restore 带列表写回 |
61
- | `browser_restrict` | `allowed?` | `{ restrictedTo[] }` | – | 设置动作白名单;空列表解除;未知工具名报错。**软护栏**——模型可自行解除,非安全边界 |
60
+ | `browser_auth` | `action`(flush/restore,必填), `cookies?` | `{ cookies[]? / restored? }` | ✅ | 导出/恢复 cookie —— **三种载体都可用**:自托管走原生会话,桌面侧栏与本机 Chrome/Edge 走 CDP 的 `Storage.getCookies` / `Storage.setCookies`;flush 返回 cookie 列表,restore 带列表写回。设置里「允许读取 cookies / 导出登录状态」关闭时,flush 与 restore **都**抛 `BROWSER_AUTH_DISABLED` |
61
+ | `browser_restrict` | `allowed?` | `{ restrictedTo[] }` | – | 设置动作白名单;空列表解除;**只校验名字是否以 `browser_` 开头**(写错的前缀会报错,`browser_typo` 这种拼错但前缀合法的名字会被接受、等同于拦掉该名字,不额外报错);守卫按名字匹配,因此白名单里列什么名字就只放行什么名字。**软护栏**——模型可自行解除,非安全边界 |
62
62
 
63
63
  ## 截图
64
64
 
65
65
  | 工具 | 参数 | 输出 | 守卫 | 说明 |
66
66
  | --- | --- | --- | --- | --- |
67
- | `browser_screenshot` | `fullPage?`, `savePath?`, `format?`(png/jpeg), `quality?`, `maxWidth?`, `maxHeight?` | `{ dataUrl, path? }` | – | 截图;PNG 默认,JPEG 仅自托管原生路径;`maxWidth`/`maxHeight` 等比缩放降低视觉模型开销;`savePath` 落盘供视觉模型读取(与 `browser_download` 同一准入门:必须位于 `downloadDir` 内且不覆盖已有文件) |
67
+ | `browser_screenshot` | `fullPage?`, `savePath?`, `format?`(png/jpeg), `quality?`, `maxWidth?`, `maxHeight?` | `{ dataUrl, path? }` | – | 截图;PNG 默认,**JPEG 在自托管与本机 Chrome/Edge 上都可用**(自托管在原生 `capturePage` 路径编码,本机浏览器经 CDP 声明 `supportsCdpJpeg`;**桌面侧栏**的 Electron CDP JPEG 编码器会挂起,该载体下 JPEG 请求返回 PNG);`maxWidth`/`maxHeight` 等比缩放**三种载体都支持**;`fullPage` 会跳过原生 `capturePage`、三种载体统一走 CDP `captureBeyondViewport`;`savePath` 落盘供视觉模型读取(与 `browser_download` 同一准入门:必须位于 `downloadDir` 内且不覆盖已有文件) |
68
68
 
69
69
  ## 常用组合
70
70
 
@@ -17,10 +17,12 @@ dsh plugin --profile web add dsh-builtin-browser
17
17
  dsh plugin --profile web add <本仓库路径>
18
18
  ```
19
19
 
20
- 安装会链接插件、把 `dsh-builtin-browser` 加入 profile 的 bundle 层,并挂载三行:
20
+ 安装会链接插件、把 `dsh-builtin-browser` 加入 profile 的 bundle 层,并挂载四行(一个惰性的根行 + 三个功能行):
21
21
 
22
22
  | 行 | 子路径 | 角色 |
23
23
  | --- | --- | --- |
24
+ | `dsh-builtin-browser` | 包名本身 | **惰性根行**:不注册任何东西(空 `apply()`),只用来声明包名 —— 宿主的客户端插件扫描靠精确包名读到 `dsh.client`,少了这行设置栏不会出现 |
25
+ | --- | --- | --- |
24
26
  | `browser` | `dsh-builtin-browser/browser` | `ctx.browser` 能力 seam(始终挂载) |
25
27
  | `browser-electron` | `dsh-builtin-browser/browser-electron` | Electron CDP provider |
26
28
  | `tool-browser` | `dsh-builtin-browser/tool-browser` | `browser_*` 模型侧工具 |
@@ -31,7 +33,7 @@ dsh plugin --profile web add <本仓库路径>
31
33
 
32
34
  | 行 | 配置项 | 类型 | 默认 | 说明 |
33
35
  | --- | --- | --- | --- | --- |
34
- | `browser-electron` | `viewHost` | 对象 | 必填 | 宿主提供的 `ElectronBrowserViewHost`(通常 `!!js ctx.get('electronViewHost')`) |
36
+ | `browser-electron` | `viewHost` | 对象 | 可选 | 宿主提供的 `ElectronBrowserViewHost`(通常 `!!js ctx.get('electronViewHost')`)。**不传时插件自己选载体**:桌面端驱动官方侧栏、否则自托管;设置里显式选择的 Chrome/Edge 优先于两者 |
35
37
  | `browser-electron` | `httpOnly` | 布尔 | `true` | 仅允许 HTTP(S) 导航;`file:`/`data:` 等拒绝 |
36
38
  | `browser-electron` | `snapshotMaxElements` | 数字 | `60` | 快照最多收录的交互元素数 |
37
39
  | `browser-electron` | `contentMaxChars` | 数字 | `100000` | 内容抓取默认字符上限 |
@@ -33,7 +33,7 @@ LLM agent 需要"上网"。传统做法是给 agent 一个**无头浏览器**(Pl
33
33
  ## 设计取舍
34
34
 
35
35
  - **登录态共享**:所有任务共享同一份 cookie(符合"用户登录一次,agent 到处可用");需要隔离时用 `browser_auth` 手动导出/恢复。
36
- - **任务级会话隔离**:每个 DSH 会话(任务)拥有独立的浏览器会话(标签页与历史),并发任务互不抢页面;但窗口只有一个,当前活动的任务视图可见。
36
+ - **任务级会话隔离**:每个 DSH 会话(任务)拥有独立的浏览器会话(标签页与历史),并发任务互不抢页面;自托管载体下**每个会话连窗口都各自一份**(窗口按会话 id 分组,窗口标题带任务标识),侧栏与本机浏览器载体则是同一个宿主里切换当前可见视图。
37
37
  - **装好即用**:不依赖桌面外壳。有外壳时嵌入外壳视图;纯 `dsh web` 时插件自托管——自己拉起 Electron 窗口,通过本机 TCP JSON-RPC 驱动。
38
38
  - **边界清晰**:可见视图、浏览器列布局属于宿主外壳;插件只负责 seam、provider 与工具,不含任何 UI。
39
39
  - **人机验证不硬刚**:检测到 Cloudflare/reCAPTCHA/hCaptcha/Turnstile 时停下,请用户在共享窗口人工完成,而不是盲目重试。
@@ -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
  /**