dsh-builtin-browser 0.3.1 → 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/README.en.md +75 -30
- package/README.md +70 -30
- package/cordis.patch.yml +13 -7
- package/desktop-bridge/install.mjs +274 -12
- package/desktop-bridge/plugin-browser-bridge.js +55 -15
- package/lib/browser-electron/desktop-bridge-host.d.ts +10 -1
- package/lib/browser-electron/desktop-bridge-host.js +10 -2
- package/lib/browser-electron/entry.js +22 -2
- package/lib/browser-electron/history-store.js +5 -1
- package/lib/browser-electron/missing-system-browser.d.ts +54 -0
- package/lib/browser-electron/missing-system-browser.js +89 -0
- package/lib/browser-electron/provider.d.ts +54 -1
- package/lib/browser-electron/provider.js +241 -37
- package/lib/browser-electron/remote-host.js +6 -3
- package/lib/browser-electron/settings-store.js +10 -4
- package/lib/browser-electron/system-browser.d.ts +27 -6
- package/lib/browser-electron/system-browser.js +222 -31
- package/lib/tool-browser/index.d.ts +14 -0
- package/lib/tool-browser/index.js +142 -57
- package/package.json +1 -1
- package/src/browser-electron/desktop-bridge-host.ts +11 -2
- package/src/browser-electron/entry.ts +22 -2
- package/src/browser-electron/history-store.ts +5 -1
- package/src/browser-electron/missing-system-browser.ts +113 -0
- package/src/browser-electron/provider.ts +267 -36
- package/src/browser-electron/remote-host.ts +6 -3
- package/src/browser-electron/settings-store.ts +11 -4
- package/src/browser-electron/system-browser.ts +210 -30
- package/src/tool-browser/index.ts +148 -62
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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 =>
|
|
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
|
|
144
|
-
|
|
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
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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';
|
|
@@ -96,10 +97,25 @@ export function apply(ctx, config) {
|
|
|
96
97
|
// spawns a browser window. (An earlier version launched it here, which meant a
|
|
97
98
|
// browser appeared the moment the plugin loaded — reported and fixed.)
|
|
98
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';
|
|
99
106
|
if (channel !== 'bundled') {
|
|
100
107
|
const detected = detectBrowser(channel);
|
|
101
108
|
if (detected === undefined) {
|
|
102
|
-
|
|
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
|
+
}
|
|
103
119
|
}
|
|
104
120
|
else {
|
|
105
121
|
const home = process.env.DSH_HOME ?? homedir();
|
|
@@ -114,6 +130,10 @@ export function apply(ctx, config) {
|
|
|
114
130
|
adopt(new SystemBrowserViewHost(detected, profileDir, [], persist ? undefined : profileDir), `will drive the installed ${detected.kind} (${detected.path}${persist ? '' : ', ephemeral profile'})`);
|
|
115
131
|
}
|
|
116
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;
|
|
117
137
|
void DesktopBridgeViewHost.discover().then(sidebar => {
|
|
118
138
|
// No bridge (plain `dsh web`, an older desktop build, or a shell that already
|
|
119
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
|
-
|
|
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;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A host that exists only to explain why the browser you asked for is not here.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS
|
|
5
|
+
* When the browser setting names a browser explicitly (Chrome or Edge rather than
|
|
6
|
+
* "bundled" or "automatic"), the plugin used to log a warning and quietly fall back
|
|
7
|
+
* to its own Electron window. That is worse than failing: you asked for Chrome, you
|
|
8
|
+
* got an Electron window, and the error that eventually surfaced talked about
|
|
9
|
+
* Electron — pointing at something you never chose and never wanted to install. The
|
|
10
|
+
* actual problem ("no Chrome on this machine") never reached you.
|
|
11
|
+
*
|
|
12
|
+
* So the two choices are treated as the different requests they are:
|
|
13
|
+
* - `auto` means "any browser will do", and falling back to the bundled one is the
|
|
14
|
+
* behaviour it asks for;
|
|
15
|
+
* - `chrome` / `edge` are explicit, and an explicit request that cannot be met is
|
|
16
|
+
* reported as such, with the ways out of it.
|
|
17
|
+
*
|
|
18
|
+
* `available()` reports true on purpose. Reporting false would make the provider pick
|
|
19
|
+
* a different carrier and hide the problem again, which is exactly the bug.
|
|
20
|
+
*/
|
|
21
|
+
import type { ElectronBrowserViewHost, ElectronViewHandle } from './provider.js';
|
|
22
|
+
import type { BrowserChannel } from './system-browser.js';
|
|
23
|
+
/**
|
|
24
|
+
* A carrier that cannot carry anything: every command explains the real problem.
|
|
25
|
+
*
|
|
26
|
+
* The message is written to be acted on, not just read: it says which browser was
|
|
27
|
+
* requested, where the plugin looked, and the three concrete ways to resolve it.
|
|
28
|
+
*/
|
|
29
|
+
export declare class MissingSystemBrowserHost implements ElectronBrowserViewHost {
|
|
30
|
+
private readonly kind;
|
|
31
|
+
private readonly searched;
|
|
32
|
+
/**
|
|
33
|
+
* @param kind - the browser the user asked for.
|
|
34
|
+
* @param searched - the names and locations that were checked, for the report.
|
|
35
|
+
*/
|
|
36
|
+
constructor(kind: Exclude<BrowserChannel, 'bundled' | 'auto'>, searched: readonly string[]);
|
|
37
|
+
/** @returns always true: see the module comment — reporting false would hide this. */
|
|
38
|
+
available(): boolean;
|
|
39
|
+
/**
|
|
40
|
+
* The message every command fails with.
|
|
41
|
+
* @returns a multi-line explanation with the fixes.
|
|
42
|
+
*/
|
|
43
|
+
private explain;
|
|
44
|
+
/**
|
|
45
|
+
* Create a handle whose every command fails with the explanation.
|
|
46
|
+
* @returns a handle that never reaches a page.
|
|
47
|
+
*/
|
|
48
|
+
createView(): ElectronViewHandle;
|
|
49
|
+
/**
|
|
50
|
+
* Accept a handle for destruction. Nothing was created, so nothing is released.
|
|
51
|
+
* @param handle - the handle returned by {@link createView}.
|
|
52
|
+
*/
|
|
53
|
+
destroyView(handle: ElectronViewHandle): void;
|
|
54
|
+
}
|