@lenorin/dsh-tauri-launcher 1.0.4 → 1.0.6

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/lib/index.js CHANGED
@@ -1,478 +1,488 @@
1
- /**
2
- * dsh-tauri-launcher — host half.
3
- *
4
- * 在 DSH Web 设置中启动/退出本机 DeepSeek Harness Tauri 桌面应用,并联动
5
- * 桌面快捷方式(开启自动创建、关闭自动删除、可手动补建)。与桌面应用的
6
- * 协作协议(launcher exe 同目录):
7
- * - `.dsh-heartbeat`:桌面应用每秒写入的 Unix 时间戳,freshSecs 秒内新鲜视为运行中;
8
- * - `.dsh-quit`:内容 `1` 且 60 秒内新鲜 → 桌面应用仅退出自身(消费时自删)。
9
- *
10
- * 浏览器侧通过本插件注册的 /api/dsh-tauri-launcher/* 路由通信(仅回环)。
11
- *
12
- * 可调参数全部走行配置(见 apply 的 config 处理与 README「配置」章节);
13
- * 内置候选目录只是默认值,供未配置时自动探测。
14
- */
15
-
16
- import { fileURLToPath } from 'node:url'
17
-
18
- export const name = 'desktop-launcher'
19
-
20
- /** 路由注册与文件/进程操作所需的主机服务(声明后加载器会等待就绪再 apply)。 */
21
- export const inject = ['webServer', 'fs', 'subprocess']
22
-
23
- const STDIO = { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' }
24
-
25
- /** 本包随附的预编译桌面应用 exe 目录(无论安装到哪个 profile 都能定位)。 */
26
- const PACKAGE_BIN = fileURLToPath(new URL('../launcher/bin', import.meta.url))
27
-
28
- /** 未配置 launcherDirs 时的默认候选目录(仅包内随附的预编译 exe;本地自行构建的 exe 通过行配置 launcherDirs/launcherExe 指定)。 */
29
- const DEFAULT_DIRS = [PACKAGE_BIN]
30
-
31
- const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
32
- const psQuote = (value) => "'" + String(value).replace(/'/g, "''") + "'"
33
-
34
- function writeJson(res, status, body) {
35
- const payload = JSON.stringify(body)
36
- res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
37
- res.end(payload)
38
- }
39
-
40
- /** 请求体大小上限(回环接口的防御性约束)。 */
41
- const MAX_BODY_BYTES = 1024 * 1024
42
-
43
- async function readJsonBody(req) {
44
- const chunks = []
45
- let total = 0
46
- for await (const chunk of req) {
47
- total += chunk.length
48
- if (total > MAX_BODY_BYTES) return {}
49
- chunks.push(chunk)
50
- }
51
- try {
52
- const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'))
53
- return typeof parsed === 'object' && parsed !== null ? parsed : {}
54
- } catch {
55
- return {}
56
- }
57
- }
58
-
59
- /** 仅允许本机回环请求(与 dsh-ssh 相同的信任边界)。 */
60
- function isLoopback(req) {
61
- const address = String(req.socket.remoteAddress ?? '')
62
- return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1'
63
- }
64
-
65
- /**
66
- * CSRF 防线(#4):回环之外直接拒绝;回环内还须满足任一——
67
- * 1. 携带本插件约定的自定义头(同源 fetch 可带,跨站 form POST 无法伪造);
68
- * 2. Origin 为 DSH 自身页面地址(http://127.0.0.1|localhost|[::1]:port);
69
- * 3. Referer 指向 DSH 自身页面地址。
70
- * 三者全缺(如 <img>/<script> 等无头请求)拒绝。
71
- */
72
- function isTrustedRequest(req, port) {
73
- if (!isLoopback(req)) return false
74
- const headers = req.headers || {}
75
- if (headers['x-requested-with'] === 'dsh-tauri-launcher') return true
76
- const allowed = new Set([
77
- 'http://127.0.0.1:' + port,
78
- 'http://localhost:' + port,
79
- 'http://[::1]:' + port,
80
- ])
81
- if (headers.origin && allowed.has(headers.origin)) return true
82
- const referer = headers.referer
83
- if (referer) {
84
- const base = referer.split('/').slice(0, 3).join('/')
85
- if (allowed.has(base)) return true
86
- }
87
- return false
88
- }
89
-
90
- export function apply(ctx, config) {
91
- // 注入的服务由加载器保证在 apply 前就绪(不能在 apply 时用 ctx.get 提前
92
- // 捕获未就绪的服务,否则会永久拿到 undefined)。
93
- const fs = ctx.fs
94
- const subprocess = ctx.subprocess
95
- const sandboxPolicy = ctx.get('sandboxPolicy')
96
-
97
- const resolved = {
98
- /** 直接指定桌面应用 exe 路径;空则按 launcherDirs/内置候选自动探测。 */
99
- launcherExe: config && typeof config.launcherExe === 'string' ? config.launcherExe : '',
100
- /** 候选 exe 目录列表;为空时使用内置默认候选。 */
101
- launcherDirs: Array.isArray(config && config.launcherDirs) ? config.launcherDirs.filter((d) => typeof d === 'string') : [],
102
- /** 心跳“新鲜窗口”(秒)。桌面应用每秒写心跳,默认 4 秒即 4 个周期的余量。 */
103
- freshSecs: config && typeof config.freshSecs === 'number' && config.freshSecs > 1 ? config.freshSecs : 4,
104
- /** 桌面快捷方式的文件名。 */
105
- shortcutName: config && typeof config.shortcutName === 'string' && config.shortcutName !== '' ? config.shortcutName : 'DeepSeek Harness.lnk',
106
- }
107
-
108
- let lastFsError = ''
109
- let lastMarkerError = ''
110
- let spawnHandle = null
111
- let shortcutCache = { t: 0, value: false }
112
-
113
- function baseDirs() {
114
- const list = resolved.launcherDirs.slice()
115
- if (resolved.launcherExe) {
116
- const slash = resolved.launcherExe.lastIndexOf('\\')
117
- if (slash > 0) list.push(resolved.launcherExe.slice(0, slash))
118
- }
119
- for (const dir of DEFAULT_DIRS) list.push(dir)
120
- return [...new Set(list)]
121
- }
122
-
123
- async function exeExists(dir) {
124
- if (!fs) return false
125
- try {
126
- const target = await fs.resolve(dir + '\\dsh-launcher.exe')
127
- const info = await fs.stat(target)
128
- return Boolean(info && info.type === 'file')
129
- } catch (error) {
130
- lastFsError = String((error && error.message) || error)
131
- return false
132
- }
133
- }
134
-
135
- async function exeDirs() {
136
- const checked = await Promise.all(baseDirs().map(async (dir) => ((await exeExists(dir)) ? dir : null)))
137
- return checked.filter((dir) => dir !== null)
138
- }
139
-
140
- async function fileText(dir, name) {
141
- if (!fs) return null
142
- try {
143
- const target = await fs.resolve(dir + '\\' + name)
144
- const info = await fs.stat(target)
145
- if (!info) return null
146
- const text = await fs.readText(target)
147
- return String(text).trim()
148
- } catch (error) {
149
- lastFsError = String((error && error.message) || error)
150
- return null
151
- }
152
- }
153
-
154
- async function hbState(dir) {
155
- const text = await fileText(dir, '.dsh-heartbeat')
156
- if (text === null || text === '') return null
157
- const stamp = Number(text)
158
- if (!Number.isFinite(stamp)) return null
159
- const age = Math.abs(Date.now() / 1000 - stamp)
160
- return age < resolved.freshSecs
161
- }
162
-
163
- async function isRunning() {
164
- const dirs = await exeDirs()
165
- const states = []
166
- for (const dir of dirs) states.push(await hbState(dir))
167
- if (states.includes(true)) return true
168
- if (states.length === 0 || states.every((s) => s === null)) return null
169
- return false
170
- }
171
-
172
- async function waitFresh(seconds) {
173
- let sawFile = false
174
- for (let i = 0; i < seconds * 2; i++) {
175
- const state = await isRunning()
176
- if (state === true) return true
177
- if (state === false) sawFile = true
178
- await sleep(500)
179
- }
180
- return sawFile ? false : null
181
- }
182
-
183
- async function waitGone(seconds) {
184
- for (let i = 0; i < seconds * 2; i++) {
185
- const state = await isRunning()
186
- if (state === false) return true
187
- if (state === null) return null
188
- await sleep(500)
189
- }
190
- return false
191
- }
192
-
193
- async function pickExe() {
194
- if (resolved.launcherExe) {
195
- try {
196
- const target = await fs.resolve(resolved.launcherExe)
197
- const info = await fs.stat(target)
198
- if (info && info.type === 'file') return resolved.launcherExe
199
- } catch (error) {
200
- lastFsError = String((error && error.message) || error)
201
- }
202
- }
203
- const dirs = await exeDirs()
204
- if (dirs.length > 0) return dirs[0] + '\\dsh-launcher.exe'
205
- return null
206
- }
207
-
208
- async function runPowerShell(script, cwd) {
209
- if (!subprocess) throw new Error('subprocess 服务不可用')
210
- const handle = subprocess.spawn({
211
- argv: ['powershell', '-NoProfile', '-NonInteractive', '-Command', script],
212
- cwd: cwd || (await pickExe()) || '',
213
- stdio: STDIO,
214
- graceMs: 3000,
215
- })
216
- if (handle && handle.done) await handle.done.catch(() => {})
217
- }
218
-
219
- async function runPowerShellExitCode(script, cwd) {
220
- if (!subprocess) return null
221
- try {
222
- const handle = subprocess.spawn({
223
- argv: ['powershell', '-NoProfile', '-NonInteractive', '-Command', script],
224
- cwd: cwd || process.cwd(),
225
- stdio: STDIO,
226
- graceMs: 3000,
227
- })
228
- const outcome = handle && handle.done ? await handle.done.catch(() => null) : null
229
- return outcome && typeof outcome.exitCode === 'number' ? outcome.exitCode : null
230
- } catch (error) {
231
- lastMarkerError = String((error && error.message) || error)
232
- return null
233
- }
234
- }
235
-
236
- async function quitScriptFor(value) {
237
- const exe = await pickExe()
238
- if (!exe) return null
239
- const dir = exe.slice(0, exe.lastIndexOf('\\'))
240
- return {
241
- dir,
242
- script: "Set-Content -LiteralPath '" + dir + "\\.dsh-quit' -Value '" + value + "' -NoNewline",
243
- }
244
- }
245
-
246
- async function writeQuitMarker(value) {
247
- const job = await quitScriptFor(value)
248
- if (!job) {
249
- lastMarkerError = 'writeQuitMarker: no exe dir'
250
- return
251
- }
252
- try {
253
- await runPowerShell(job.script, job.dir)
254
- lastMarkerError = ''
255
- } catch (error) {
256
- lastMarkerError = String((error && error.message) || error)
257
- }
258
- }
259
-
260
- function writeQuitMarkerNoWait(value) {
261
- void quitScriptFor(value).then((job) => {
262
- if (!job) return
263
- try {
264
- subprocess.spawn({
265
- argv: ['powershell', '-NoProfile', '-NonInteractive', '-Command', job.script],
266
- cwd: job.dir,
267
- stdio: STDIO,
268
- graceMs: 3000,
269
- })
270
- } catch (error) {
271
- lastMarkerError = String((error && error.message) || error)
272
- }
273
- })
274
- }
275
-
276
- async function quitFileGone() {
277
- const exe = await pickExe()
278
- if (!exe) return true
279
- const dir = exe.slice(0, exe.lastIndexOf('\\'))
280
- return (await fileText(dir, '.dsh-quit')) === null
281
- }
282
-
283
- async function quitMarkerIsOne() {
284
- const exe = await pickExe()
285
- if (!exe) return false
286
- const dir = exe.slice(0, exe.lastIndexOf('\\'))
287
- return (await fileText(dir, '.dsh-quit')) === '1'
288
- }
289
-
290
- function shortcutExistsScript() {
291
- return "$desktop=[Environment]::GetFolderPath('Desktop'); $lnk=Join-Path $desktop " + psQuote(resolved.shortcutName) + "; if (Test-Path -LiteralPath $lnk) { exit 0 } else { exit 1 }"
292
- }
293
-
294
- function shortcutCreateScript(exe) {
295
- return "$desktop=[Environment]::GetFolderPath('Desktop'); $lnk=Join-Path $desktop " + psQuote(resolved.shortcutName) + "; " +
296
- "$exe=" + psQuote(exe) + "; " +
297
- '$ws=New-Object -ComObject WScript.Shell; $sc=$ws.CreateShortcut($lnk); ' +
298
- '$sc.TargetPath=$exe; $sc.WorkingDirectory=Split-Path $exe; ' +
299
- "$sc.IconLocation=($exe + ',0'); $sc.Description=" + psQuote('DeepSeek Harness 桌面启动器') + '; ' +
300
- '$sc.Save(); if (Test-Path -LiteralPath $lnk) { exit 0 } else { exit 1 }'
301
- }
302
-
303
- function shortcutDeleteScript() {
304
- return "$desktop=[Environment]::GetFolderPath('Desktop'); $lnk=Join-Path $desktop " + psQuote(resolved.shortcutName) + '; ' +
305
- 'Remove-Item -LiteralPath $lnk -Force -ErrorAction SilentlyContinue; ' +
306
- 'if (Test-Path -LiteralPath $lnk) { exit 1 } else { exit 0 }'
307
- }
308
-
309
- async function shortcutExists() {
310
- const now = Date.now()
311
- if (now - shortcutCache.t < 5000) return shortcutCache.value
312
- const code = await runPowerShellExitCode(shortcutExistsScript(), process.cwd())
313
- shortcutCache = { t: Date.now(), value: code === 0 }
314
- return shortcutCache.value
315
- }
316
-
317
- async function ensureShortcut() {
318
- if (await shortcutExists()) return true
319
- const exe = await pickExe()
320
- if (!exe) return false
321
- const code = await runPowerShellExitCode(shortcutCreateScript(exe), process.cwd())
322
- shortcutCache = { t: Date.now(), value: code === 0 }
323
- return code === 0
324
- }
325
-
326
- async function removeShortcut() {
327
- if (!(await shortcutExists())) return true
328
- const code = await runPowerShellExitCode(shortcutDeleteScript(), process.cwd())
329
- shortcutCache = { t: Date.now(), value: code !== 0 }
330
- return code === 0
331
- }
332
-
333
- async function buildDiag() {
334
- const lines = []
335
- lines.push('services: fs=' + !!fs + ' subprocess=' + !!subprocess + ' sandboxPolicy=' + !!sandboxPolicy)
336
- lines.push('workspaceRoot: ' + (sandboxPolicy ? sandboxPolicy.workspaceRoot : '(none)'))
337
- lines.push('freshWindowSecs: ' + resolved.freshSecs)
338
- lines.push('launcherExe: ' + (resolved.launcherExe || '(auto)'))
339
- lines.push('shortcut: ' + (await shortcutExists()))
340
- const dirs = await exeDirs()
341
- lines.push('exeDirs: ' + JSON.stringify(dirs))
342
- const exe = await pickExe()
343
- lines.push('pickExe: ' + (exe || '(null)'))
344
- if (lastFsError) lines.push('fsError: ' + lastFsError)
345
- lines.push('markerWrite: ' + (lastMarkerError || 'ok'))
346
- for (const dir of dirs) {
347
- lines.push('heartbeat(' + dir.split('\\').slice(-2).join('\\') + '): ' + String(await hbState(dir)))
348
- }
349
- if (dirs.length === 0) lines.push('heartbeat: (no exe dirs)')
350
- return lines.join('\n')
351
- }
352
-
353
- async function getState() {
354
- const running = await isRunning()
355
- const exe = await pickExe()
356
- const shortcut = await shortcutExists()
357
- return { ok: true, desktop: running, shortcut, exe: exe || null }
358
- }
359
-
360
- async function getDiag() {
361
- return { ok: true, diag: await buildDiag() }
362
- }
363
-
364
- async function setDesktop(enabled) {
365
- if (enabled) {
366
- if (await quitMarkerIsOne()) await writeQuitMarker('0')
367
- const exe = await pickExe()
368
- if (!exe) {
369
- return { ok: false, error: '未找到桌面应用可执行文件(可通过行配置 launcherExe 或 launcherDirs 指定)。', diag: await buildDiag() }
370
- }
371
- if (!subprocess) {
372
- return { ok: false, error: 'subprocess 服务不可用,无法启动桌面应用。', diag: await buildDiag() }
373
- }
374
- try {
375
- const dir = exe.slice(0, exe.lastIndexOf('\\'))
376
- spawnHandle = subprocess.spawn({ argv: [exe], cwd: dir, stdio: STDIO, graceMs: 3000 })
377
- } catch (error) {
378
- return { ok: false, error: '启动桌面应用失败:' + String((error && error.message) || error), diag: await buildDiag() }
379
- }
380
- const state = await waitFresh(20)
381
- if (state === true) {
382
- if (!(await ensureShortcut())) lastMarkerError = 'shortcut sync failed (create)'
383
- return { ok: true, desktop: true, shortcut: await shortcutExists(), diag: '' }
384
- }
385
- if (state === null) return { ok: true, desktop: null, shortcut: await shortcutExists(), diag: '' }
386
- return { ok: true, desktop: false, shortcut: await shortcutExists(), diag: '' }
387
- }
388
-
389
- const owned = spawnHandle
390
- spawnHandle = null
391
- if (owned) {
392
- try { owned.terminate() } catch {}
393
- }
394
- await writeQuitMarker('1')
395
- let confirmed = false
396
- for (let i = 0; i < 12; i++) {
397
- if (await quitFileGone()) { confirmed = true; break }
398
- if ((await isRunning()) === false) { confirmed = true; break }
399
- await sleep(500)
400
- }
401
- if (confirmed) {
402
- if (owned && owned.done) await Promise.race([owned.done.catch(() => {}), sleep(3000)])
403
- writeQuitMarkerNoWait('0')
404
- if (!(await removeShortcut())) lastMarkerError = 'shortcut sync failed (delete)'
405
- return { ok: true, desktop: false, shortcut: await shortcutExists(), diag: '' }
406
- }
407
- writeQuitMarkerNoWait('0')
408
- const state = await waitGone(20)
409
- if (state === true) {
410
- if (!(await removeShortcut())) lastMarkerError = 'shortcut sync failed (delete)'
411
- return { ok: true, desktop: false, shortcut: await shortcutExists(), diag: '' }
412
- }
413
- if (state === null) return { ok: true, desktop: null, shortcut: await shortcutExists(), diag: '' }
414
- try {
415
- const exe = await pickExe()
416
- const dir = exe ? exe.slice(0, exe.lastIndexOf('\\')) : process.cwd()
417
- // 仅终止与探测到的 exe 路径匹配的进程,避免误杀其他同名程序。
418
- const script = "$p = Get-Process -Name dsh-launcher -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq " + psQuote(exe || '') + " }; if ($p) { $p | Stop-Process -Force -ErrorAction SilentlyContinue }"
419
- await runPowerShell(script, dir)
420
- } catch (error) {
421
- lastMarkerError = String((error && error.message) || error)
422
- }
423
- const state2 = await waitGone(8)
424
- if (state2 === true) {
425
- if (!(await removeShortcut())) lastMarkerError = 'shortcut sync failed (delete)'
426
- return { ok: true, desktop: false, shortcut: await shortcutExists(), diag: '' }
427
- }
428
- return { ok: false, error: '桌面应用仍在运行(退出请求与强制结束均未生效)。', shortcut: await shortcutExists(), diag: await buildDiag() }
429
- }
430
-
431
- async function setShortcut() {
432
- const ok = await ensureShortcut()
433
- if (ok) return { ok: true, shortcut: true, diag: '' }
434
- return { ok: false, error: '创建桌面快捷方式失败。', shortcut: await shortcutExists(), diag: await buildDiag() }
435
- }
436
-
437
- ctx.effect(() => {
438
- const routes = [
439
- {
440
- kind: 'exact',
441
- path: '/api/dsh-tauri-launcher/state',
442
- handler: async (req, res) => {
443
- if (!isTrustedRequest(req, ctx.webServer.port)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
444
- writeJson(res, 200, await getState())
445
- },
446
- },
447
- {
448
- kind: 'exact',
449
- path: '/api/dsh-tauri-launcher/set-desktop',
450
- handler: async (req, res) => {
451
- if (!isTrustedRequest(req, ctx.webServer.port)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
452
- const body = await readJsonBody(req)
453
- writeJson(res, 200, await setDesktop(Boolean(body.enabled)))
454
- },
455
- },
456
- {
457
- kind: 'exact',
458
- path: '/api/dsh-tauri-launcher/set-shortcut',
459
- handler: async (req, res) => {
460
- if (!isTrustedRequest(req, ctx.webServer.port)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
461
- writeJson(res, 200, await setShortcut())
462
- },
463
- },
464
- {
465
- kind: 'exact',
466
- path: '/api/dsh-tauri-launcher/diagnose',
467
- handler: async (req, res) => {
468
- if (!isTrustedRequest(req, ctx.webServer.port)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
469
- writeJson(res, 200, await getDiag())
470
- },
471
- },
472
- ]
473
- const disposers = routes.map((route) => ctx.webServer.register(route))
474
- return () => {
475
- for (const dispose of disposers) dispose()
476
- }
477
- }, 'dsh-tauri-launcher: routes')
478
- }
1
+ /**
2
+ * dsh-tauri-launcher — host half.
3
+ *
4
+ * 在 DSH Web 设置中启动/退出本机 DeepSeek Harness Tauri 桌面应用,并联动
5
+ * 桌面快捷方式(开启自动创建、关闭自动删除、可手动补建)。与桌面应用的
6
+ * 协作协议(launcher exe 同目录):
7
+ * - `.dsh-heartbeat`:桌面应用每秒写入的 Unix 时间戳,freshSecs 秒内新鲜视为运行中;
8
+ * - `.dsh-quit`:内容 `1` 且 60 秒内新鲜 → 桌面应用仅退出自身(消费时自删)。
9
+ *
10
+ * 浏览器侧通过本插件注册的 /api/dsh-tauri-launcher/* 路由通信(仅回环)。
11
+ *
12
+ * 可调参数全部走行配置(见 apply 的 config 处理与 README「配置」章节);
13
+ * 内置候选目录只是默认值,供未配置时自动探测。
14
+ */
15
+
16
+ import { fileURLToPath } from 'node:url'
17
+
18
+ export const name = 'desktop-launcher'
19
+
20
+ /** 路由注册与文件/进程操作所需的主机服务(声明后加载器会等待就绪再 apply)。 */
21
+ export const inject = ['webServer', 'fs', 'subprocess']
22
+
23
+ const STDIO = { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' }
24
+
25
+ /** 本包随附的预编译桌面应用 exe 目录(无论安装到哪个 profile 都能定位)。 */
26
+ const PACKAGE_BIN = fileURLToPath(new URL('../launcher/bin', import.meta.url))
27
+
28
+ /** 未配置 launcherDirs 时的默认候选目录(仅包内随附的预编译 exe;本地自行构建的 exe 通过行配置 launcherDirs/launcherExe 指定)。 */
29
+ const DEFAULT_DIRS = [PACKAGE_BIN]
30
+
31
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
32
+ const psQuote = (value) => "'" + String(value).replace(/'/g, "''") + "'"
33
+
34
+ function writeJson(res, status, body) {
35
+ const payload = JSON.stringify(body)
36
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
37
+ res.end(payload)
38
+ }
39
+
40
+ /** 请求体大小上限(回环接口的防御性约束)。 */
41
+ const MAX_BODY_BYTES = 1024 * 1024
42
+
43
+ async function readJsonBody(req) {
44
+ const chunks = []
45
+ let total = 0
46
+ for await (const chunk of req) {
47
+ total += chunk.length
48
+ if (total > MAX_BODY_BYTES) return {}
49
+ chunks.push(chunk)
50
+ }
51
+ try {
52
+ const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'))
53
+ return typeof parsed === 'object' && parsed !== null ? parsed : {}
54
+ } catch {
55
+ return {}
56
+ }
57
+ }
58
+
59
+ /** 仅允许本机回环请求(与 dsh-ssh 相同的信任边界)。 */
60
+ function isLoopback(req) {
61
+ const address = String(req.socket.remoteAddress ?? '')
62
+ return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1'
63
+ }
64
+
65
+ /**
66
+ * CSRF 防线(#4):回环之外直接拒绝;回环内还须满足任一——
67
+ * 1. 携带本插件约定的自定义头(同源 fetch 可带,跨站 form POST 无法伪造);
68
+ * 2. Origin 为 DSH 自身页面地址(http://127.0.0.1|localhost|[::1]:port);
69
+ * 3. Referer 指向 DSH 自身页面地址。
70
+ * 三者全缺(如 <img>/<script> 等无头请求)拒绝。
71
+ */
72
+ function isTrustedRequest(req, port) {
73
+ if (!isLoopback(req)) return false
74
+ const headers = req.headers || {}
75
+ if (headers['x-requested-with'] === 'dsh-tauri-launcher') return true
76
+ const allowed = new Set([
77
+ 'http://127.0.0.1:' + port,
78
+ 'http://localhost:' + port,
79
+ 'http://[::1]:' + port,
80
+ ])
81
+ if (headers.origin && allowed.has(headers.origin)) return true
82
+ const referer = headers.referer
83
+ if (referer) {
84
+ const base = referer.split('/').slice(0, 3).join('/')
85
+ if (allowed.has(base)) return true
86
+ }
87
+ return false
88
+ }
89
+
90
+ export function apply(ctx, config) {
91
+ // 注入的服务由加载器保证在 apply 前就绪(不能在 apply 时用 ctx.get 提前
92
+ // 捕获未就绪的服务,否则会永久拿到 undefined)。
93
+ const fs = ctx.fs
94
+ const subprocess = ctx.subprocess
95
+ const sandboxPolicy = ctx.get('sandboxPolicy')
96
+
97
+ const resolved = {
98
+ /** 直接指定桌面应用 exe 路径;空则按 launcherDirs/内置候选自动探测。 */
99
+ launcherExe: config && typeof config.launcherExe === 'string' ? config.launcherExe : '',
100
+ /** 候选 exe 目录列表;为空时使用内置默认候选。 */
101
+ launcherDirs: Array.isArray(config && config.launcherDirs) ? config.launcherDirs.filter((d) => typeof d === 'string') : [],
102
+ /** 心跳“新鲜窗口”(秒)。桌面应用每秒写心跳,默认 4 秒即 4 个周期的余量。 */
103
+ freshSecs: config && typeof config.freshSecs === 'number' && config.freshSecs > 1 ? config.freshSecs : 4,
104
+ /** 桌面快捷方式的文件名。 */
105
+ shortcutName: config && typeof config.shortcutName === 'string' && config.shortcutName !== '' ? config.shortcutName : 'DeepSeek Harness.lnk',
106
+ }
107
+
108
+ let lastFsError = ''
109
+ let lastMarkerError = ''
110
+ let spawnHandle = null
111
+ let shortcutCache = { t: 0, value: false }
112
+
113
+ function baseDirs() {
114
+ const list = resolved.launcherDirs.slice()
115
+ if (resolved.launcherExe) {
116
+ const slash = resolved.launcherExe.lastIndexOf('\\')
117
+ if (slash > 0) list.push(resolved.launcherExe.slice(0, slash))
118
+ }
119
+ for (const dir of DEFAULT_DIRS) list.push(dir)
120
+ return [...new Set(list)]
121
+ }
122
+
123
+ async function exeExists(dir) {
124
+ if (!fs) return false
125
+ try {
126
+ const target = await fs.resolve(dir + '\\dsh-launcher.exe')
127
+ const info = await fs.stat(target)
128
+ return Boolean(info && info.type === 'file')
129
+ } catch (error) {
130
+ lastFsError = String((error && error.message) || error)
131
+ return false
132
+ }
133
+ }
134
+
135
+ async function exeDirs() {
136
+ const checked = await Promise.all(baseDirs().map(async (dir) => ((await exeExists(dir)) ? dir : null)))
137
+ return checked.filter((dir) => dir !== null)
138
+ }
139
+
140
+ async function fileText(dir, name) {
141
+ if (!fs) return null
142
+ try {
143
+ const target = await fs.resolve(dir + '\\' + name)
144
+ const info = await fs.stat(target)
145
+ if (!info) return null
146
+ const text = await fs.readText(target)
147
+ return String(text).trim()
148
+ } catch (error) {
149
+ lastFsError = String((error && error.message) || error)
150
+ return null
151
+ }
152
+ }
153
+
154
+ async function hbState(dir) {
155
+ const text = await fileText(dir, '.dsh-heartbeat')
156
+ if (text === null || text === '') return null
157
+ const stamp = Number(text)
158
+ if (!Number.isFinite(stamp)) return null
159
+ const age = Math.abs(Date.now() / 1000 - stamp)
160
+ return age < resolved.freshSecs
161
+ }
162
+
163
+ async function isRunning() {
164
+ const dirs = await exeDirs()
165
+ const states = []
166
+ for (const dir of dirs) states.push(await hbState(dir))
167
+ if (states.includes(true)) return true
168
+ if (states.length === 0 || states.every((s) => s === null)) return null
169
+ return false
170
+ }
171
+
172
+ async function waitFresh(seconds) {
173
+ let sawFile = false
174
+ for (let i = 0; i < seconds * 2; i++) {
175
+ const state = await isRunning()
176
+ if (state === true) return true
177
+ if (state === false) sawFile = true
178
+ await sleep(500)
179
+ }
180
+ return sawFile ? false : null
181
+ }
182
+
183
+ async function waitGone(seconds) {
184
+ for (let i = 0; i < seconds * 2; i++) {
185
+ const state = await isRunning()
186
+ if (state === false) return true
187
+ if (state === null) return null
188
+ await sleep(500)
189
+ }
190
+ return false
191
+ }
192
+
193
+ async function pickExe() {
194
+ if (resolved.launcherExe) {
195
+ try {
196
+ const target = await fs.resolve(resolved.launcherExe)
197
+ const info = await fs.stat(target)
198
+ if (info && info.type === 'file') return resolved.launcherExe
199
+ } catch (error) {
200
+ lastFsError = String((error && error.message) || error)
201
+ }
202
+ }
203
+ const dirs = await exeDirs()
204
+ if (dirs.length > 0) return dirs[0] + '\\dsh-launcher.exe'
205
+ return null
206
+ }
207
+
208
+ async function runPowerShell(script, cwd) {
209
+ if (!subprocess) throw new Error('subprocess 服务不可用')
210
+ const handle = subprocess.spawn({
211
+ argv: ['powershell', '-NoProfile', '-NonInteractive', '-Command', script],
212
+ cwd: cwd || (await pickExe()) || '',
213
+ stdio: STDIO,
214
+ graceMs: 3000,
215
+ })
216
+ if (handle && handle.done) await handle.done.catch(() => {})
217
+ }
218
+
219
+ async function runPowerShellExitCode(script, cwd) {
220
+ if (!subprocess) return null
221
+ try {
222
+ const handle = subprocess.spawn({
223
+ argv: ['powershell', '-NoProfile', '-NonInteractive', '-Command', script],
224
+ cwd: cwd || process.cwd(),
225
+ stdio: STDIO,
226
+ graceMs: 3000,
227
+ })
228
+ const outcome = handle && handle.done ? await handle.done.catch(() => null) : null
229
+ return outcome && typeof outcome.exitCode === 'number' ? outcome.exitCode : null
230
+ } catch (error) {
231
+ lastMarkerError = String((error && error.message) || error)
232
+ return null
233
+ }
234
+ }
235
+
236
+ async function quitScriptFor(value) {
237
+ const exe = await pickExe()
238
+ if (!exe) return null
239
+ const dir = exe.slice(0, exe.lastIndexOf('\\'))
240
+ return {
241
+ dir,
242
+ // dir/value 一律经 psQuote 转义(单引号翻倍),避免含单引号的路径破坏脚本。
243
+ script: 'Set-Content -LiteralPath ' + psQuote(dir + '\\.dsh-quit') + ' -Value ' + psQuote(value) + ' -NoNewline',
244
+ }
245
+ }
246
+
247
+ async function writeQuitMarker(value) {
248
+ const job = await quitScriptFor(value)
249
+ if (!job) {
250
+ lastMarkerError = 'writeQuitMarker: no exe dir'
251
+ return
252
+ }
253
+ try {
254
+ await runPowerShell(job.script, job.dir)
255
+ lastMarkerError = ''
256
+ } catch (error) {
257
+ lastMarkerError = String((error && error.message) || error)
258
+ }
259
+ }
260
+
261
+ function writeQuitMarkerNoWait(value) {
262
+ void quitScriptFor(value).then((job) => {
263
+ if (!job) return
264
+ try {
265
+ subprocess.spawn({
266
+ argv: ['powershell', '-NoProfile', '-NonInteractive', '-Command', job.script],
267
+ cwd: job.dir,
268
+ stdio: STDIO,
269
+ graceMs: 3000,
270
+ })
271
+ } catch (error) {
272
+ lastMarkerError = String((error && error.message) || error)
273
+ }
274
+ })
275
+ }
276
+
277
+ async function quitFileGone() {
278
+ const exe = await pickExe()
279
+ if (!exe) return true
280
+ const dir = exe.slice(0, exe.lastIndexOf('\\'))
281
+ return (await fileText(dir, '.dsh-quit')) === null
282
+ }
283
+
284
+ async function quitMarkerIsOne() {
285
+ const exe = await pickExe()
286
+ if (!exe) return false
287
+ const dir = exe.slice(0, exe.lastIndexOf('\\'))
288
+ return (await fileText(dir, '.dsh-quit')) === '1'
289
+ }
290
+
291
+ function shortcutExistsScript() {
292
+ return "$desktop=[Environment]::GetFolderPath('Desktop'); $lnk=Join-Path $desktop " + psQuote(resolved.shortcutName) + "; if (Test-Path -LiteralPath $lnk) { exit 0 } else { exit 1 }"
293
+ }
294
+
295
+ function shortcutCreateScript(exe) {
296
+ return "$desktop=[Environment]::GetFolderPath('Desktop'); $lnk=Join-Path $desktop " + psQuote(resolved.shortcutName) + "; " +
297
+ "$exe=" + psQuote(exe) + "; " +
298
+ '$ws=New-Object -ComObject WScript.Shell; $sc=$ws.CreateShortcut($lnk); ' +
299
+ '$sc.TargetPath=$exe; $sc.WorkingDirectory=Split-Path $exe; ' +
300
+ "$sc.IconLocation=($exe + ',0'); $sc.Description=" + psQuote('DeepSeek Harness 桌面启动器') + '; ' +
301
+ '$sc.Save(); if (Test-Path -LiteralPath $lnk) { exit 0 } else { exit 1 }'
302
+ }
303
+
304
+ function shortcutDeleteScript() {
305
+ return "$desktop=[Environment]::GetFolderPath('Desktop'); $lnk=Join-Path $desktop " + psQuote(resolved.shortcutName) + '; ' +
306
+ 'Remove-Item -LiteralPath $lnk -Force -ErrorAction SilentlyContinue; ' +
307
+ 'if (Test-Path -LiteralPath $lnk) { exit 1 } else { exit 0 }'
308
+ }
309
+
310
+ async function shortcutExists() {
311
+ const now = Date.now()
312
+ if (now - shortcutCache.t < 5000) return shortcutCache.value
313
+ const code = await runPowerShellExitCode(shortcutExistsScript(), process.cwd())
314
+ shortcutCache = { t: Date.now(), value: code === 0 }
315
+ return shortcutCache.value
316
+ }
317
+
318
+ async function ensureShortcut() {
319
+ if (await shortcutExists()) return true
320
+ const exe = await pickExe()
321
+ if (!exe) return false
322
+ const code = await runPowerShellExitCode(shortcutCreateScript(exe), process.cwd())
323
+ shortcutCache = { t: Date.now(), value: code === 0 }
324
+ return code === 0
325
+ }
326
+
327
+ async function removeShortcut() {
328
+ if (!(await shortcutExists())) return true
329
+ const code = await runPowerShellExitCode(shortcutDeleteScript(), process.cwd())
330
+ shortcutCache = { t: Date.now(), value: code !== 0 }
331
+ return code === 0
332
+ }
333
+
334
+ async function buildDiag() {
335
+ const lines = []
336
+ lines.push('services: fs=' + !!fs + ' subprocess=' + !!subprocess + ' sandboxPolicy=' + !!sandboxPolicy)
337
+ lines.push('workspaceRoot: ' + (sandboxPolicy ? sandboxPolicy.workspaceRoot : '(none)'))
338
+ lines.push('freshWindowSecs: ' + resolved.freshSecs)
339
+ lines.push('launcherExe: ' + (resolved.launcherExe || '(auto)'))
340
+ lines.push('shortcut: ' + (await shortcutExists()))
341
+ const dirs = await exeDirs()
342
+ lines.push('exeDirs: ' + JSON.stringify(dirs))
343
+ const exe = await pickExe()
344
+ lines.push('pickExe: ' + (exe || '(null)'))
345
+ if (lastFsError) lines.push('fsError: ' + lastFsError)
346
+ lines.push('markerWrite: ' + (lastMarkerError || 'ok'))
347
+ for (const dir of dirs) {
348
+ lines.push('heartbeat(' + dir.split('\\').slice(-2).join('\\') + '): ' + String(await hbState(dir)))
349
+ }
350
+ if (dirs.length === 0) lines.push('heartbeat: (no exe dirs)')
351
+ return lines.join('\n')
352
+ }
353
+
354
+ async function getState() {
355
+ const running = await isRunning()
356
+ const exe = await pickExe()
357
+ const shortcut = await shortcutExists()
358
+ return { ok: true, desktop: running, shortcut, exe: exe || null }
359
+ }
360
+
361
+ async function getDiag() {
362
+ return { ok: true, diag: await buildDiag() }
363
+ }
364
+
365
+ async function setDesktop(enabled) {
366
+ if (enabled) {
367
+ if (await quitMarkerIsOne()) await writeQuitMarker('0')
368
+ const exe = await pickExe()
369
+ if (!exe) {
370
+ return { ok: false, error: '未找到桌面应用可执行文件(可通过行配置 launcherExe 或 launcherDirs 指定)。', diag: await buildDiag() }
371
+ }
372
+ if (!subprocess) {
373
+ return { ok: false, error: 'subprocess 服务不可用,无法启动桌面应用。', diag: await buildDiag() }
374
+ }
375
+ try {
376
+ const dir = exe.slice(0, exe.lastIndexOf('\\'))
377
+ spawnHandle = subprocess.spawn({ argv: [exe], cwd: dir, stdio: STDIO, graceMs: 3000 })
378
+ } catch (error) {
379
+ return { ok: false, error: '启动桌面应用失败:' + String((error && error.message) || error), diag: await buildDiag() }
380
+ }
381
+ const state = await waitFresh(20)
382
+ if (state === true) {
383
+ if (!(await ensureShortcut())) {
384
+ lastMarkerError = 'shortcut sync failed (create)'
385
+ return {
386
+ ok: false,
387
+ desktop: true,
388
+ shortcut: false,
389
+ error: '桌面应用已启动,但创建桌面快捷方式失败(可稍后在设置中手动重试)。',
390
+ diag: await buildDiag(),
391
+ }
392
+ }
393
+ return { ok: true, desktop: true, shortcut: true, diag: '' }
394
+ }
395
+ if (state === null) return { ok: true, desktop: null, shortcut: await shortcutExists(), diag: '' }
396
+ return { ok: true, desktop: false, shortcut: await shortcutExists(), diag: '' }
397
+ }
398
+
399
+ const owned = spawnHandle
400
+ spawnHandle = null
401
+ if (owned) {
402
+ try { owned.terminate() } catch {}
403
+ }
404
+ await writeQuitMarker('1')
405
+ let confirmed = false
406
+ for (let i = 0; i < 12; i++) {
407
+ if (await quitFileGone()) { confirmed = true; break }
408
+ if ((await isRunning()) === false) { confirmed = true; break }
409
+ await sleep(500)
410
+ }
411
+ if (confirmed) {
412
+ if (owned && owned.done) await Promise.race([owned.done.catch(() => {}), sleep(3000)])
413
+ writeQuitMarkerNoWait('0')
414
+ if (!(await removeShortcut())) lastMarkerError = 'shortcut sync failed (delete)'
415
+ return { ok: true, desktop: false, shortcut: await shortcutExists(), diag: '' }
416
+ }
417
+ writeQuitMarkerNoWait('0')
418
+ const state = await waitGone(20)
419
+ if (state === true) {
420
+ if (!(await removeShortcut())) lastMarkerError = 'shortcut sync failed (delete)'
421
+ return { ok: true, desktop: false, shortcut: await shortcutExists(), diag: '' }
422
+ }
423
+ if (state === null) return { ok: true, desktop: null, shortcut: await shortcutExists(), diag: '' }
424
+ try {
425
+ const exe = await pickExe()
426
+ const dir = exe ? exe.slice(0, exe.lastIndexOf('\\')) : process.cwd()
427
+ // 仅终止与探测到的 exe 路径匹配的进程,避免误杀其他同名程序。
428
+ const script = "$p = Get-Process -Name dsh-launcher -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq " + psQuote(exe || '') + " }; if ($p) { $p | Stop-Process -Force -ErrorAction SilentlyContinue }"
429
+ await runPowerShell(script, dir)
430
+ } catch (error) {
431
+ lastMarkerError = String((error && error.message) || error)
432
+ }
433
+ const state2 = await waitGone(8)
434
+ if (state2 === true) {
435
+ if (!(await removeShortcut())) lastMarkerError = 'shortcut sync failed (delete)'
436
+ return { ok: true, desktop: false, shortcut: await shortcutExists(), diag: '' }
437
+ }
438
+ return { ok: false, error: '桌面应用仍在运行(退出请求与强制结束均未生效)。', shortcut: await shortcutExists(), diag: await buildDiag() }
439
+ }
440
+
441
+ async function setShortcut() {
442
+ const ok = await ensureShortcut()
443
+ if (ok) return { ok: true, shortcut: true, diag: '' }
444
+ return { ok: false, error: '创建桌面快捷方式失败。', shortcut: await shortcutExists(), diag: await buildDiag() }
445
+ }
446
+
447
+ ctx.effect(() => {
448
+ const routes = [
449
+ {
450
+ kind: 'exact',
451
+ path: '/api/dsh-tauri-launcher/state',
452
+ handler: async (req, res) => {
453
+ if (!isTrustedRequest(req, ctx.webServer.port)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
454
+ writeJson(res, 200, await getState())
455
+ },
456
+ },
457
+ {
458
+ kind: 'exact',
459
+ path: '/api/dsh-tauri-launcher/set-desktop',
460
+ handler: async (req, res) => {
461
+ if (!isTrustedRequest(req, ctx.webServer.port)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
462
+ const body = await readJsonBody(req)
463
+ writeJson(res, 200, await setDesktop(Boolean(body.enabled)))
464
+ },
465
+ },
466
+ {
467
+ kind: 'exact',
468
+ path: '/api/dsh-tauri-launcher/set-shortcut',
469
+ handler: async (req, res) => {
470
+ if (!isTrustedRequest(req, ctx.webServer.port)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
471
+ writeJson(res, 200, await setShortcut())
472
+ },
473
+ },
474
+ {
475
+ kind: 'exact',
476
+ path: '/api/dsh-tauri-launcher/diagnose',
477
+ handler: async (req, res) => {
478
+ if (!isTrustedRequest(req, ctx.webServer.port)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
479
+ writeJson(res, 200, await getDiag())
480
+ },
481
+ },
482
+ ]
483
+ const disposers = routes.map((route) => ctx.webServer.register(route))
484
+ return () => {
485
+ for (const dispose of disposers) dispose()
486
+ }
487
+ }, 'dsh-tauri-launcher: routes')
488
+ }