@lenorin/dsh-tauri-launcher 1.0.9 → 1.0.11

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.md CHANGED
@@ -137,6 +137,23 @@ dsh plugin --profile web remove @lenorin/dsh-tauri-launcher
137
137
  > 注:原 `shortcutName` 配置项已移除(2026-09)——快捷方式文件名由插件与桌面应用
138
138
  > 共用同一常量(`DeepSeek Harness.lnk`):两侧各自维护名字会导致各建出一个 .lnk。
139
139
 
140
+ ### 候选目录的自动探测
141
+
142
+ 心跳、退出标记与桌面应用配置都在**桌面应用 exe 同目录**,而桌面应用可能存在多份
143
+ 副本(npm 包内预编译 exe、本地 `build.ps1` 产物)。插件按下述优先级挑目录,因此
144
+ 无论实际运行的是哪一份副本,状态判定、退出标记与启动目标都会对齐到同一个实例:
145
+
146
+ 1. 行配置 `launcherExe` 所在目录(硬指定);
147
+ 2. **正在运行的** `dsh-launcher` 进程所在目录(`Get-Process` 取 `Path`);
148
+ 3. 桌面快捷方式 `DeepSeek Harness.lnk` 的目标目录(WScript.Shell 读 `TargetPath`);
149
+ 4. 行配置 `launcherDirs`(候选列表);
150
+ 5. 本次插件运行中发现过的目录(实例退出后仍扫描其心跳,避免状态在「已停止/未知」间抖动);
151
+ 6. 包内 `launcher/bin`(兜底)。
152
+
153
+ 只有真的含 `dsh-launcher.exe` 的目录会被采用——指向已删除副本的失效快捷方式会自动
154
+ 跳过,回退到下一候选。运行中实例/快捷方式目标的探测结果缓存 5 秒;探测不可用时
155
+ (无 `subprocess` 服务等)自动回退到第 4/6 项,行为与旧版一致。
156
+
140
157
  ## 桌面应用构建与发布
141
158
 
142
159
  - npm 包自带预编译 exe(`launcher/bin/dsh-launcher.exe`),插件安装后自动探测、装上即用;
Binary file
package/lib/index.js CHANGED
@@ -9,6 +9,11 @@
9
9
  *
10
10
  * 浏览器侧通过本插件注册的 /api/dsh-tauri-launcher/* 路由通信(仅回环)。
11
11
  *
12
+ * 标记协议以 **exe 同目录**为界,而桌面应用可能有多个副本(包内预编译 exe、
13
+ * 本地构建产物)。因此候选目录按优先级自动探测:行配置 launcherExe →
14
+ * 运行中实例目录 → 桌面快捷方式目标目录 → 行配置 launcherDirs →
15
+ * 本会话发现过的目录 → 包内 `launcher/bin`,见 baseDirs。
16
+ *
12
17
  * 可调参数全部走行配置(见 apply 的 config 处理与 README「配置」章节);
13
18
  * 内置候选目录只是默认值,供未配置时自动探测。
14
19
  */
@@ -22,12 +27,44 @@ export const inject = ['webServer', 'fs', 'subprocess']
22
27
 
23
28
  const STDIO = { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' }
24
29
 
30
+ /**
31
+ * 拉起桌面应用时使用的 stdio(**不能用 inherit**)。
32
+ *
33
+ * DSH 自身可能是被桌面应用拉起的:它的 stdout/stderr 就是桌面应用当时创建的管道
34
+ * (`harness.rs` 用 `Stdio::piped()` 拉起 `dsh web`)。桌面应用退出后这根管道的读端
35
+ * 消失,此后以 inherit 拉起的子进程继承的是**已断开的手柄**,一写即 panic 退出
36
+ * (2026-09-15 实测:exit code 101,现象为「设置里开关打开但桌面端起不来」,而关闭
37
+ * 方向正常——关闭只写标记文件)。收集模式让管道由 DSH 这一侧持有,既避开坏句柄,
38
+ * 又把桌面应用启动期的输出留下来供诊断使用。
39
+ */
40
+ const APP_STDIO = { stdin: 'ignore', stdout: { maxBytes: 16384 }, stderr: { maxBytes: 16384 } }
41
+
25
42
  /** 本包随附的预编译桌面应用 exe 目录(无论安装到哪个 profile 都能定位)。 */
26
43
  const PACKAGE_BIN = fileURLToPath(new URL('../launcher/bin', import.meta.url))
27
44
 
28
45
  /** 未配置 launcherDirs 时的默认候选目录(仅包内随附的预编译 exe;本地自行构建的 exe 通过行配置 launcherDirs/launcherExe 指定)。 */
29
46
  const DEFAULT_DIRS = [PACKAGE_BIN]
30
47
 
48
+ /**
49
+ * 运行中实例 / 桌面快捷方式目标的探测缓存时长(毫秒)。状态轮询与启停等待
50
+ * 循环都会反复取候选目录,探测结果按此窗口复用,避免每次读取都拉起 PowerShell。
51
+ */
52
+ const DISCOVERY_TTL_MS = 5000
53
+
54
+ /** 会话内累积的候选目录上限(含已退出实例的目录)。 */
55
+ const MAX_KNOWN_DIRS = 8
56
+
57
+ /**
58
+ * PowerShell 输出编码前置:Windows PowerShell 5.1 默认按控制台代码页写重定向
59
+ * 输出,非 ASCII 路径会乱码;`ctx.subprocess` 的收集模式按 UTF-8 解码,故显式
60
+ * 钉成 UTF-8(与 `@deepseek-ai/dsh-pwsh-local` 的 ENCODING_PREAMBLE 同做法)。
61
+ */
62
+ const PS_UTF8_PREAMBLE = '[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); $OutputEncoding = [System.Text.UTF8Encoding]::new($false); '
63
+
64
+ /** 列出运行中的桌面应用 exe 路径(同名 exe 可能有多份副本同时在跑)。 */
65
+ const RUNNING_EXE_SCRIPT =
66
+ 'Get-Process -Name dsh-launcher -ErrorAction SilentlyContinue | ForEach-Object { try { $_.Path } catch {} }'
67
+
31
68
  /**
32
69
  * 桌面快捷方式文件名。**单一来源**:必须与桌面应用 `src-tauri/src/settings.rs`
33
70
  * 的 `SHORTCUT_NAME` 常量一致——两侧各自维护名字会导致插件与桌面应用各建
@@ -129,15 +166,126 @@ export function apply(ctx, config) {
129
166
 
130
167
  let lastFsError = ''
131
168
  let lastMarkerError = ''
169
+ let lastProbeError = ''
132
170
  let spawnHandle = null
133
171
  let shortcutCache = { t: 0, value: false }
172
+ /** 最近一次拉起桌面应用的退出事实(未退出为 null),用于把「起来即死」报出来。 */
173
+ let appExit = null
174
+ /** 该桌面应用进程启动期的输出(收集模式),仅在启动失败时用于诊断。 */
175
+ let appOutput = ''
134
176
 
135
- function baseDirs() {
136
- const list = resolved.launcherDirs.slice()
177
+ /**
178
+ * 已在本次插件运行中发现过的候选目录(最近使用在前)。实例退出后仍保留:
179
+ * 该目录里的心跳文件会变成「过期」而非「不存在」,让状态稳定落在
180
+ * 「已停止」而不是回跳到 null(未知),退出流程的确认逻辑也不会中途丢目标。
181
+ */
182
+ const knownDirs = []
183
+ /** 「运行中实例目录 + 桌面快捷方式目标目录」的探测缓存。 */
184
+ const discovery = { t: 0, runningDirs: [], shortcutDir: '' }
185
+
186
+ /**
187
+ * 运行采集模式 PowerShell 并取回 stdout 文本(短探测脚本)。
188
+ * 任何失败(服务缺失、spawn 失败、provider 未提供收集流)都返回空串,
189
+ * 调用方据此回退到既有候选目录——探测不可用不改变原有行为。
190
+ */
191
+ async function capturePowerShell(script) {
192
+ if (!subprocess) return ''
193
+ try {
194
+ const handle = subprocess.spawn({
195
+ argv: ['powershell', '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', PS_UTF8_PREAMBLE + script],
196
+ cwd: process.cwd(),
197
+ stdio: { stdin: 'ignore', stdout: { maxBytes: 16384 }, stderr: { maxBytes: 4096 } },
198
+ graceMs: 3000,
199
+ })
200
+ if (handle && handle.done) await handle.done.catch(() => null)
201
+ const reader = handle && handle.collected ? handle.collected.stdout : null
202
+ if (!reader) return ''
203
+ return String(reader.readFrom(0).text || '')
204
+ } catch (error) {
205
+ lastProbeError = String((error && error.message) || error)
206
+ return ''
207
+ }
208
+ }
209
+
210
+ /** 收集模式下该句柄的输出(stdout/stderr 合并、限长),用于诊断「起来即死」。 */
211
+ function collectedText(handle) {
212
+ if (!handle || !handle.collected) return ''
213
+ const parts = []
214
+ for (const stream of ['stdout', 'stderr']) {
215
+ const reader = handle.collected[stream]
216
+ if (!reader) continue
217
+ try {
218
+ const text = String(reader.readFrom(0).text || '').trim()
219
+ if (text) parts.push(stream + ': ' + text)
220
+ } catch {
221
+ // 收集流不可读取时忽略:诊断信息缺一行不影响主流程。
222
+ }
223
+ }
224
+ return parts.join('\n').slice(0, 2000)
225
+ }
226
+
227
+ /** 退出事实的可读描述(exit code / signal / 未知原因)。 */
228
+ function describeExit(outcome) {
229
+ if (!outcome) return '未知原因'
230
+ if (typeof outcome.exitCode === 'number') return 'exit code ' + outcome.exitCode
231
+ if (outcome.signal) return 'signal ' + outcome.signal
232
+ return outcome.error ? String(outcome.error) : '未知原因'
233
+ }
234
+
235
+ /** 记住一个已发现的候选目录(去重、最近使用在前、限量)。 */
236
+ function rememberDir(dir) {
237
+ if (!dir) return
238
+ const hit = knownDirs.indexOf(dir)
239
+ if (hit >= 0) knownDirs.splice(hit, 1)
240
+ knownDirs.unshift(dir)
241
+ if (knownDirs.length > MAX_KNOWN_DIRS) knownDirs.length = MAX_KNOWN_DIRS
242
+ }
243
+
244
+ /**
245
+ * 探测正在运行的桌面应用目录与桌面快捷方式目标目录(TTL 内复用)。
246
+ *
247
+ * 为什么需要:标记协议以 exe 同目录为界,而桌面应用存在多份副本(包内预编译
248
+ * exe、本地构建产物)。只看包内目录时,若实际运行的是另一份副本,就会读到空
249
+ * 心跳 → 状态「未知」、退出标记写进错误目录、开关反而拉起第二份实例。
250
+ */
251
+ async function probe() {
252
+ if (Date.now() - discovery.t < DISCOVERY_TTL_MS) return discovery
253
+ const [runningText, targetText] = await Promise.all([
254
+ capturePowerShell(RUNNING_EXE_SCRIPT),
255
+ capturePowerShell(shortcutTargetScript()),
256
+ ])
257
+ const runningDirs = []
258
+ for (const line of String(runningText).split(/\r?\n/)) {
259
+ const dir = dirOf(line.trim())
260
+ if (dir && !runningDirs.includes(dir)) runningDirs.push(dir)
261
+ }
262
+ const targetLine = String(targetText).split(/\r?\n/).map((line) => line.trim()).filter((line) => line !== '')[0] || ''
263
+ discovery.t = Date.now()
264
+ discovery.runningDirs = runningDirs
265
+ discovery.shortcutDir = dirOf(targetLine)
266
+ for (const dir of runningDirs) rememberDir(dir)
267
+ if (discovery.shortcutDir) rememberDir(discovery.shortcutDir)
268
+ return discovery
269
+ }
270
+
271
+ /**
272
+ * 候选 exe 目录,按启动优先级排列:
273
+ * 1. 行配置 launcherExe 所在目录;2. 运行中实例目录;3. 桌面快捷方式目标目录;
274
+ * 4. 行配置 launcherDirs;5. 本会话发现过的目录;6. 包内随附 exe 目录。
275
+ * 只有真的含 dsh-launcher.exe 的目录才会被 exeDirs() 采用——失效的快捷方式
276
+ * 目标会被自动跳过,回退到下一候选。
277
+ */
278
+ async function baseDirs() {
279
+ const found = await probe()
280
+ const list = []
137
281
  if (resolved.launcherExe) {
138
282
  const dir = dirOf(resolved.launcherExe)
139
283
  if (dir) list.push(dir)
140
284
  }
285
+ for (const dir of found.runningDirs) list.push(dir)
286
+ if (found.shortcutDir) list.push(found.shortcutDir)
287
+ for (const dir of resolved.launcherDirs) list.push(dir)
288
+ for (const dir of knownDirs) list.push(dir)
141
289
  for (const dir of DEFAULT_DIRS) list.push(dir)
142
290
  return [...new Set(list)]
143
291
  }
@@ -155,7 +303,8 @@ export function apply(ctx, config) {
155
303
  }
156
304
 
157
305
  async function exeDirs() {
158
- const checked = await Promise.all(baseDirs().map(async (dir) => ((await exeExists(dir)) ? dir : null)))
306
+ const dirs = await baseDirs()
307
+ const checked = await Promise.all(dirs.map(async (dir) => ((await exeExists(dir)) ? dir : null)))
159
308
  return checked.filter((dir) => dir !== null)
160
309
  }
161
310
 
@@ -324,6 +473,12 @@ export function apply(ctx, config) {
324
473
  return "$desktop=[Environment]::GetFolderPath('Desktop'); $lnk=Join-Path $desktop " + psQuote(SHORTCUT_NAME) + "; if (Test-Path -LiteralPath $lnk) { exit 0 } else { exit 1 }"
325
474
  }
326
475
 
476
+ /** 读桌面快捷方式的目标 exe 路径;快捷方式不存在或目标为空时无输出。 */
477
+ function shortcutTargetScript() {
478
+ return "$desktop=[Environment]::GetFolderPath('Desktop'); $lnk=Join-Path $desktop " + psQuote(SHORTCUT_NAME) + '; ' +
479
+ 'if (Test-Path -LiteralPath $lnk) { $ws=New-Object -ComObject WScript.Shell; $sc=$ws.CreateShortcut($lnk); if ($sc.TargetPath) { $sc.TargetPath } }'
480
+ }
481
+
327
482
  function shortcutCreateScript(exe) {
328
483
  return "$desktop=[Environment]::GetFolderPath('Desktop'); $lnk=Join-Path $desktop " + psQuote(SHORTCUT_NAME) + "; " +
329
484
  "$exe=" + psQuote(exe) + "; " +
@@ -370,12 +525,18 @@ export function apply(ctx, config) {
370
525
  lines.push('freshWindowSecs: ' + resolved.freshSecs)
371
526
  lines.push('launcherExe: ' + (resolved.launcherExe || '(auto)'))
372
527
  lines.push('shortcut: ' + (await shortcutExists()))
528
+ const found = await probe()
529
+ lines.push('runningDirs: ' + JSON.stringify(found.runningDirs))
530
+ lines.push('shortcutTarget: ' + (found.shortcutDir || '(none)'))
373
531
  const dirs = await exeDirs()
374
532
  lines.push('exeDirs: ' + JSON.stringify(dirs))
375
533
  const exe = await pickExe()
376
534
  lines.push('pickExe: ' + (exe || '(null)'))
377
535
  if (lastFsError) lines.push('fsError: ' + lastFsError)
536
+ if (lastProbeError) lines.push('probeError: ' + lastProbeError)
378
537
  lines.push('markerWrite: ' + (lastMarkerError || 'ok'))
538
+ if (appExit) lines.push('appExit: ' + describeExit(appExit))
539
+ if (appOutput) lines.push('appOutput: ' + appOutput)
379
540
  for (const dir of dirs) {
380
541
  lines.push('heartbeat(' + dir.split('\\').slice(-2).join('\\') + '): ' + String(await hbState(dir)))
381
542
  }
@@ -395,6 +556,8 @@ export function apply(ctx, config) {
395
556
  }
396
557
 
397
558
  async function setDesktop(enabled) {
559
+ appExit = null
560
+ appOutput = ''
398
561
  if (enabled) {
399
562
  if (await quitMarkerIsOne()) await writeQuitMarker('0')
400
563
  const exe = await pickExe()
@@ -406,7 +569,18 @@ export function apply(ctx, config) {
406
569
  }
407
570
  try {
408
571
  const dir = dirOf(exe)
409
- spawnHandle = subprocess.spawn({ argv: [exe], cwd: dir, stdio: STDIO, graceMs: 3000 })
572
+ // 拉起前记住该目录:即使探测缓存仍是旧的,心跳扫描也已覆盖新实例的目录。
573
+ rememberDir(dir)
574
+ const handle = subprocess.spawn({ argv: [exe], cwd: dir, stdio: APP_STDIO, graceMs: 3000 })
575
+ spawnHandle = handle
576
+ // 记录退出事实:桌面应用「起来即死」时(例如继承了坏句柄而 panic),
577
+ // 只有退出码 + 它自己的输出能说明原因,否则用户只看到开关弹回而没有解释。
578
+ if (handle && handle.done) {
579
+ handle.done.then(
580
+ (outcome) => { appExit = outcome || { exitCode: null, signal: null } },
581
+ (error) => { appExit = { exitCode: null, signal: null, error: String((error && error.message) || error) } },
582
+ )
583
+ }
410
584
  } catch (error) {
411
585
  return { ok: false, error: '启动桌面应用失败:' + String((error && error.message) || error), diag: await buildDiag() }
412
586
  }
@@ -424,6 +598,17 @@ export function apply(ctx, config) {
424
598
  }
425
599
  return { ok: true, desktop: true, shortcut: true, diag: '' }
426
600
  }
601
+ // 未在就绪窗口内看到新鲜心跳:若进程已退出,把退出事实与输出报出来。
602
+ appOutput = collectedText(spawnHandle)
603
+ if (appExit) {
604
+ return {
605
+ ok: false,
606
+ desktop: false,
607
+ shortcut: await shortcutExists(),
608
+ error: '桌面应用启动后立即退出(' + describeExit(appExit) + ')。',
609
+ diag: await buildDiag(),
610
+ }
611
+ }
427
612
  if (state === null) return { ok: true, desktop: null, shortcut: await shortcutExists(), diag: '' }
428
613
  return { ok: true, desktop: false, shortcut: await shortcutExists(), diag: '' }
429
614
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenorin/dsh-tauri-launcher",
3
- "version": "1.0.9",
3
+ "version": "1.0.11",
4
4
  "description": "DSH Web 插件:基于 Tauri 2 的 DeepSeek Harness 桌面启动器,自动联动桌面快捷方式,exe 路径与探测窗口可配置。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",