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/README.en.md +78 -33
- package/README.md +74 -33
- 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 +41 -22
- 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 +75 -22
- package/lib/browser-electron/system-browser.js +314 -65
- 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 +42 -22
- 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 +292 -59
- package/src/tool-browser/index.ts +148 -62
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
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
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
|
|
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';
|
|
@@ -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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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;
|