@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 +17 -0
- package/launcher/bin/dsh-launcher.exe +0 -0
- package/lib/index.js +189 -4
- package/package.json +1 -1
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
|
-
|
|
136
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|