@lenorin/dsh-tauri-launcher 1.0.8 → 1.0.9

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 CHANGED
@@ -77,6 +77,7 @@ loopback routes, and controls the desktop app via a sentinel-file protocol (see
77
77
  dsh-tauri-launcher/
78
78
  ├── README.md # Chinese README
79
79
  ├── README.en.md # English README
80
+ ├── CHANGELOG.md # changelog — the single source of release notes (bilingual)
80
81
  ├── package.json # dsh.bundle + dsh.client dual manifest
81
82
  ├── cordis.patch.yml # composition patch (inserts the plugin row)
82
83
  ├── lib/
@@ -138,9 +139,12 @@ fit common deployments):
138
139
  launcherExe: '' # absolute path to the desktop-app exe; empty = auto-detect
139
140
  launcherDirs: [] # candidate exe directories; empty = built-in defaults
140
141
  freshSecs: 4 # heartbeat "freshness window" in seconds (must be > write interval)
141
- shortcutName: 'DeepSeek Harness.lnk' # desktop-shortcut file name
142
142
  ```
143
143
 
144
+ > Note: the former `shortcutName` key was removed (2026-09) — the shortcut file name is a
145
+ > single constant shared by the plugin and the desktop app (`DeepSeek Harness.lnk`);
146
+ > maintaining it on both sides produced two differently-named `.lnk` files.
147
+
144
148
  ## Desktop App Build & Release
145
149
 
146
150
  - The npm package ships a pre-built exe (`launcher/bin/dsh-launcher.exe`); the plugin auto-detects it on install — ready to use out of the box;
@@ -156,6 +160,7 @@ fit common deployments):
156
160
 
157
161
  - [docs/architecture.md](docs/architecture.md) — architecture & sentinel-file protocol (Chinese)
158
162
  - [docs/release.md](docs/release.md) — install, build, release, uninstall & configuration reference (Chinese)
163
+ - [CHANGELOG.md](CHANGELOG.md) — release notes per version (Chinese + English)
159
164
 
160
165
  ## License
161
166
 
package/README.md CHANGED
@@ -73,6 +73,7 @@ Web 插件(宿主半)通过 `/api/dsh-tauri-launcher/*` 回环路由与浏
73
73
  dsh-tauri-launcher/
74
74
  ├── README.md # 中文 README
75
75
  ├── README.en.md # 英文 README
76
+ ├── CHANGELOG.md # 更新日志——发版说明的唯一来源(中英双语)
76
77
  ├── package.json # dsh.bundle + dsh.client 双 manifest
77
78
  ├── cordis.patch.yml # 组合补丁(插入插件行)
78
79
  ├── lib/
@@ -131,9 +132,11 @@ dsh plugin --profile web remove @lenorin/dsh-tauri-launcher
131
132
  launcherExe: '' # 桌面应用 exe 绝对路径;空 = 自动探测
132
133
  launcherDirs: [] # 候选 exe 目录列表;空 = 内置默认候选
133
134
  freshSecs: 4 # 心跳“新鲜窗口”(秒),须大于心跳写入周期
134
- shortcutName: 'DeepSeek Harness.lnk' # 桌面快捷方式文件名
135
135
  ```
136
136
 
137
+ > 注:原 `shortcutName` 配置项已移除(2026-09)——快捷方式文件名由插件与桌面应用
138
+ > 共用同一常量(`DeepSeek Harness.lnk`):两侧各自维护名字会导致各建出一个 .lnk。
139
+
137
140
  ## 桌面应用构建与发布
138
141
 
139
142
  - npm 包自带预编译 exe(`launcher/bin/dsh-launcher.exe`),插件安装后自动探测、装上即用;
@@ -149,6 +152,7 @@ dsh plugin --profile web remove @lenorin/dsh-tauri-launcher
149
152
 
150
153
  - [docs/architecture.md](docs/architecture.md) — 架构与标记文件协议
151
154
  - [docs/release.md](docs/release.md) — 安装、构建、发布、卸载与配置参考
155
+ - [CHANGELOG.md](CHANGELOG.md) — 更新日志(逐版本发版说明,GitHub/Gitee 发行版正文的来源)
152
156
 
153
157
  ## License
154
158
 
Binary file
package/lib/client.js CHANGED
@@ -422,6 +422,28 @@ body [class*='centerCol'] {
422
422
  ))
423
423
  }
424
424
 
425
+ /* ---------- 外壳 ↔ 插件 postMessage 协议 ---------- */
426
+ // 消息统一信封 { v, type, payload }。为兼容旧版对端(旧 exe 配新插件,
427
+ // 或反之),发送时同时保留旧字段、解析时信封优先并回退旧字段;下个大
428
+ // 版本再移除旧字段。字段与超时清单见项目 docs/architecture.md。
429
+ const MSG_VERSION = 1
430
+ const MSG = {
431
+ /** 外壳 → 插件:导航命令(dir: back | forward | ping)。 */
432
+ NAV_COMMAND: 'navCommand',
433
+ /** 外壳 → 插件:Windows 系统主题(scheme)。 */
434
+ SYSTEM_THEME: 'systemTheme',
435
+ /** 插件 → 外壳:前进/后退可用性(back / forward)。 */
436
+ NAV_STATUS: 'navStatus',
437
+ /** 插件 → 外壳:DSH 主题与真实 token(scheme + 六色)。 */
438
+ THEME_SYNC: 'themeSync',
439
+ }
440
+
441
+ /** 读取信封载荷;版本或类型不匹配时返回 null(调用方回退旧字段)。 */
442
+ function readEnvelope(data, type) {
443
+ if (!data || data.v !== MSG_VERSION || data.type !== type) return null
444
+ return data.payload && typeof data.payload === 'object' ? data.payload : null
445
+ }
446
+
425
447
  /* ---------- 标题栏主题中继(DSH → 外壳) ---------- */
426
448
 
427
449
  /**
@@ -431,6 +453,8 @@ body [class*='centerCol'] {
431
453
  * 普通浏览器标签(window.parent === window)直接跳过。
432
454
  * 注意:内置主题的快照 tokens 是空表,真实颜色在 CSS 变量里
433
455
  * (body[data-ds-dark-theme] 切换),必须经 getComputedStyle 取。
456
+ * ⚠️ 所有读取都在当前任务之后(见下方 schedulePost 的时序说明)——
457
+ * 同步读会拿到上一个主题的 DOM 状态,标题栏会滞后一个主题。
434
458
  */
435
459
  function installThemeRelay(ctx) {
436
460
  const readVar = (name) => {
@@ -448,9 +472,11 @@ body [class*='centerCol'] {
448
472
  }
449
473
  }
450
474
 
451
- let themeSent = false
475
+ let pending = false
476
+ let lastSignature = ''
452
477
  const postTheme = () => {
453
- if (window.parent === window) return
478
+ pending = false
479
+ if (window.parent === window) return true
454
480
  let scheme = null
455
481
  try {
456
482
  const theme = ctx.get('theme')
@@ -463,8 +489,7 @@ body [class*='centerCol'] {
463
489
  if (!scheme) {
464
490
  scheme = document.body.hasAttribute('data-ds-dark-theme') ? 'dark' : 'light'
465
491
  }
466
- const payload = {
467
- __dshLauncherTheme: 1,
492
+ const theme = {
468
493
  scheme,
469
494
  bg: readVar('--dsw-specific-sidebar-fill'),
470
495
  fg: readVar('--dsw-alias-label-primary'),
@@ -473,19 +498,53 @@ body [class*='centerCol'] {
473
498
  sep: readVar('--dsw-alias-border-l1'),
474
499
  danger: readVar('--dsw-alias-state-error-primary'),
475
500
  }
476
- themeSent = true
477
- window.parent.postMessage(payload, '*')
478
- // 返回 true 供重试梯子判定“已送达”,停止后续重试(与历史 themeSent 语义一致)
501
+ const signature = JSON.stringify(theme)
502
+ // 同一状态只投递一次:事件、DOM 观察、重试梯子三路触发时去重
503
+ if (signature === lastSignature) return true
504
+ lastSignature = signature
505
+ // 双发:信封(新)+ 旧字段(兼容旧版外壳);下个大版本移除旧字段。
506
+ window.parent.postMessage(
507
+ { ...theme, __dshLauncherTheme: 1, v: MSG_VERSION, type: MSG.THEME_SYNC, payload: theme },
508
+ '*',
509
+ )
510
+ // 返回 true 供重试梯子判定“已送达”,停止后续重试
511
+ return true
512
+ }
513
+
514
+ /**
515
+ * 读取必须延后到当前任务之后(DSH 0.1.2-rc.1 实测的时序坑):
516
+ * ui-layout 的 ThemePresenter 与我们的 theme/change 监听器同处一次
517
+ * **同步**派发,谁先注册不保证;同步读取会拿到上一个主题的 body 属性
518
+ * 与 --dsw-* 计算值——表现为「DSH 界面已切浅色,标题栏仍是深色」
519
+ * (标题栏滞后一个主题)。setTimeout(0) 保证 presenter 的 DOM 写入生效。
520
+ */
521
+ const schedulePost = () => {
522
+ if (pending) return true
523
+ pending = true
524
+ setTimeout(() => {
525
+ try {
526
+ postTheme()
527
+ } catch {
528
+ pending = false
529
+ }
530
+ }, 0)
479
531
  return true
480
532
  }
481
533
 
482
534
  try {
483
- ctx.on('theme/change', postTheme)
535
+ ctx.on('theme/change', schedulePost)
484
536
  } catch { /* 事件不可用时依赖下面的重试梯子 */ }
485
537
 
538
+ // 兜底:直接观察 body 的主题属性,事件时序变化时也能补发(同状态去重)
539
+ if (typeof MutationObserver === 'function') {
540
+ const observer = new MutationObserver(() => schedulePost())
541
+ observer.observe(document.body, { attributes: true, attributeFilter: ['data-ds-dark-theme'] })
542
+ ctx.effect(() => () => observer.disconnect(), 'dsh-tauri-launcher: theme relay observer')
543
+ }
544
+
486
545
  // 主题服务可能在本插件之后才就绪,且 preference 等于默认值时不会发
487
546
  // theme/change:用重试梯子保证初始状态一定送达(成功或 6 秒后停止)。
488
- const disposeTimer = retryLadder(postTheme, {})
547
+ const disposeTimer = retryLadder(schedulePost, {})
489
548
  ctx.effect(() => disposeTimer, 'dsh-tauri-launcher: theme relay timer')
490
549
  }
491
550
 
@@ -524,8 +583,7 @@ body [class*='centerCol'] {
524
583
 
525
584
  const onSystemThemeMessage = (event) => {
526
585
  const data = event.data || {}
527
- if (event.source !== window.parent) return
528
- if (event.origin !== parentOrigin) return
586
+ if (!isShellMessage(event, parentOrigin)) return
529
587
  const scheme = data.__tbSystemTheme
530
588
  if (scheme === 'light' || scheme === 'dark') applySystemTheme(scheme)
531
589
  }
@@ -558,11 +616,15 @@ body [class*='centerCol'] {
558
616
 
559
617
  const sendStatus = () => {
560
618
  if (window.parent === window) return
561
- const payload = { __tbNavStatus: { back: nav.status().back, forward: nav.status().forward } }
619
+ const status = { back: nav.status().back, forward: nav.status().forward }
562
620
  try {
563
- // 用 '*' 投递:WebView2 下对 http://tauri.localhost 虚拟主机的
564
- // 精确 targetOrigin 匹配有丢弃嫌疑;外壳侧会校验 event.origin。
565
- window.parent.postMessage(payload, '*')
621
+ // 双发:信封(新)+ 旧字段(兼容旧版外壳)。用 '*' 投递:WebView2 下
622
+ // 对 http://tauri.localhost 虚拟主机的精确 targetOrigin 匹配有丢弃
623
+ // 嫌疑;外壳侧会校验 event.origin。
624
+ window.parent.postMessage(
625
+ { __tbNavStatus: status, v: MSG_VERSION, type: MSG.NAV_STATUS, payload: status },
626
+ '*',
627
+ )
566
628
  } catch { /* 忽略 */ }
567
629
  }
568
630
 
@@ -606,8 +668,7 @@ body [class*='centerCol'] {
606
668
 
607
669
  const onNavMessage = (event) => {
608
670
  const data = event.data || {}
609
- if (event.source !== window.parent) return
610
- if (event.origin !== parentOrigin) return
671
+ if (!isShellMessage(event, parentOrigin)) return
611
672
  const dir = data.__tbNav
612
673
  if (dir === 'back') goBack()
613
674
  else if (dir === 'forward') goForward()
@@ -665,15 +726,36 @@ body [class*='centerCol'] {
665
726
 
666
727
  /**
667
728
  * 父窗口 origin:跨源读不到 window.parent.location,用 document.referrer
668
- * 推导(兜底 http://tauri.localhost)。
729
+ * 推导。**注意 referrer 不可靠**:DSH 0.1.5 + WebView2 实测,iframe 经
730
+ * token 303 握手后 `document.referrer` 为空,推导结果退化为兜底值,
731
+ * 与外壳实际 origin 不符——因此校验一律走 [`isShellMessage`] 的白名单。
669
732
  */
670
733
  function deriveParentOrigin() {
671
734
  try {
672
735
  if (document.referrer) return new URL(document.referrer).origin
673
- } catch { /* 兜底 http://tauri.localhost */ }
736
+ } catch { /* 兜底见 isShellMessage 的白名单 */ }
674
737
  return 'http://tauri.localhost'
675
738
  }
676
739
 
740
+ /**
741
+ * 外壳页(桌面端主窗口)的已知 origin:本地外壳服务(`shell_server.rs`,
742
+ * 127.0.0.1:3081)与历史 tauri 资产协议虚拟主机。
743
+ */
744
+ const SHELL_ORIGINS = ['http://127.0.0.1:3081', 'http://tauri.localhost']
745
+
746
+ /**
747
+ * 判断消息是否来自桌面端外壳。
748
+ * `event.source === window.parent` 是最强的身份校验(只有真正的父 frame
749
+ * 能匹配);origin 再按「referrer 推导值或已知外壳白名单」收口——只认
750
+ * 推导值会在 referrer 缺失时把整条通道(导航命令、系统主题)全部丢弃
751
+ * (2026-09-12 探针实证:34 次 ping 与全部 nav/theme 消息均因 origin
752
+ * 不匹配被丢弃,症状是 ◀/▶ 无响应、Windows 主题不跟随)。
753
+ */
754
+ function isShellMessage(event, parentOrigin) {
755
+ if (event.source !== window.parent) return false
756
+ return event.origin === parentOrigin || SHELL_ORIGINS.includes(event.origin)
757
+ }
758
+
677
759
  exports.apply = function (ctx) {
678
760
  const slots = ctx.get('slots')
679
761
  if (slots === undefined) return
package/lib/index.js CHANGED
@@ -28,9 +28,22 @@ const PACKAGE_BIN = fileURLToPath(new URL('../launcher/bin', import.meta.url))
28
28
  /** 未配置 launcherDirs 时的默认候选目录(仅包内随附的预编译 exe;本地自行构建的 exe 通过行配置 launcherDirs/launcherExe 指定)。 */
29
29
  const DEFAULT_DIRS = [PACKAGE_BIN]
30
30
 
31
+ /**
32
+ * 桌面快捷方式文件名。**单一来源**:必须与桌面应用 `src-tauri/src/settings.rs`
33
+ * 的 `SHORTCUT_NAME` 常量一致——两侧各自维护名字会导致插件与桌面应用各建
34
+ * 一个 .lnk(优化报告 v2 B3),因此不再提供行配置。
35
+ */
36
+ const SHORTCUT_NAME = 'DeepSeek Harness.lnk'
37
+
31
38
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
32
39
  const psQuote = (value) => "'" + String(value).replace(/'/g, "''") + "'"
33
40
 
41
+ /** exe 所在目录(Windows 反斜杠路径);无分隔符时返回空串(调用方回退)。 */
42
+ const dirOf = (exePath) => {
43
+ const slash = exePath.lastIndexOf('\\')
44
+ return slash > 0 ? exePath.slice(0, slash) : ''
45
+ }
46
+
34
47
  function writeJson(res, status, body) {
35
48
  const payload = JSON.stringify(body)
36
49
  res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
@@ -69,15 +82,26 @@ function isLoopback(req) {
69
82
  * 3. Referer 指向 DSH 自身页面地址。
70
83
  * 三者全缺(如 <img>/<script> 等无头请求)拒绝。
71
84
  */
85
+ /** 允许的 DSH 页面 Origin 集合(按端口缓存,避免每个请求重建 Set)。 */
86
+ const allowedOriginsByPort = new Map()
87
+ function allowedOriginsFor(port) {
88
+ let set = allowedOriginsByPort.get(port)
89
+ if (set === undefined) {
90
+ set = new Set([
91
+ 'http://127.0.0.1:' + port,
92
+ 'http://localhost:' + port,
93
+ 'http://[::1]:' + port,
94
+ ])
95
+ allowedOriginsByPort.set(port, set)
96
+ }
97
+ return set
98
+ }
99
+
72
100
  function isTrustedRequest(req, port) {
73
101
  if (!isLoopback(req)) return false
74
102
  const headers = req.headers || {}
75
103
  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
- ])
104
+ const allowed = allowedOriginsFor(port)
81
105
  if (headers.origin && allowed.has(headers.origin)) return true
82
106
  const referer = headers.referer
83
107
  if (referer) {
@@ -101,8 +125,6 @@ export function apply(ctx, config) {
101
125
  launcherDirs: Array.isArray(config && config.launcherDirs) ? config.launcherDirs.filter((d) => typeof d === 'string') : [],
102
126
  /** 心跳“新鲜窗口”(秒)。桌面应用每秒写心跳,默认 4 秒即 4 个周期的余量。 */
103
127
  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
128
  }
107
129
 
108
130
  let lastFsError = ''
@@ -113,8 +135,8 @@ export function apply(ctx, config) {
113
135
  function baseDirs() {
114
136
  const list = resolved.launcherDirs.slice()
115
137
  if (resolved.launcherExe) {
116
- const slash = resolved.launcherExe.lastIndexOf('\\')
117
- if (slash > 0) list.push(resolved.launcherExe.slice(0, slash))
138
+ const dir = dirOf(resolved.launcherExe)
139
+ if (dir) list.push(dir)
118
140
  }
119
141
  for (const dir of DEFAULT_DIRS) list.push(dir)
120
142
  return [...new Set(list)]
@@ -156,8 +178,12 @@ export function apply(ctx, config) {
156
178
  if (text === null || text === '') return null
157
179
  const stamp = Number(text)
158
180
  if (!Number.isFinite(stamp)) return null
159
- const age = Math.abs(Date.now() / 1000 - stamp)
160
- return age < resolved.freshSecs
181
+ // 单向新鲜度:只认「不早于 now - freshSecs、且不显著超前」的时间戳。
182
+ // 原实现取 Math.abs(),会把**未来时间戳**也判成新鲜——系统时钟回拨或
183
+ // 写入方时钟超前时,残留的心跳文件会被误判为「运行中」(优化报告 v2 C1)。
184
+ // 容 1 秒负向抖动:同一台机器共用系统时钟,正常不会超前。
185
+ const age = Date.now() / 1000 - stamp
186
+ return age > -1 && age < resolved.freshSecs
161
187
  }
162
188
 
163
189
  async function isRunning() {
@@ -205,29 +231,32 @@ export function apply(ctx, config) {
205
231
  return null
206
232
  }
207
233
 
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
234
+ /**
235
+ * 执行 PowerShell 脚本。
236
+ * - 默认(`wantExitCode: false`):等待执行结束,忽略退出码;spawn 失败抛出,
237
+ * 由调用方决定是否记录(写标记等「执行即可」的场景)。
238
+ * - `wantExitCode: true`:返回退出码(无法取得时为 null),失败记入
239
+ * `lastMarkerError` 供诊断使用(原 runPowerShellExitCode 的语义)。
240
+ */
241
+ async function runPowerShell(script, cwd, { wantExitCode = false } = {}) {
242
+ if (!subprocess) {
243
+ if (wantExitCode) return null
244
+ throw new Error('subprocess 服务不可用')
245
+ }
221
246
  try {
222
247
  const handle = subprocess.spawn({
223
248
  argv: ['powershell', '-NoProfile', '-NonInteractive', '-Command', script],
224
- cwd: cwd || process.cwd(),
249
+ // 不带 cwd 时:需要退出码的调用多为绝对路径脚本,用进程 cwd 即可;
250
+ // 其余(写标记/杀进程)落在 exe 同目录,便于相对路径与权限。
251
+ cwd: cwd || (wantExitCode ? process.cwd() : ((await pickExe()) || '')),
225
252
  stdio: STDIO,
226
253
  graceMs: 3000,
227
254
  })
228
255
  const outcome = handle && handle.done ? await handle.done.catch(() => null) : null
256
+ if (!wantExitCode) return undefined
229
257
  return outcome && typeof outcome.exitCode === 'number' ? outcome.exitCode : null
230
258
  } catch (error) {
259
+ if (!wantExitCode) throw error
231
260
  lastMarkerError = String((error && error.message) || error)
232
261
  return null
233
262
  }
@@ -236,7 +265,7 @@ export function apply(ctx, config) {
236
265
  async function quitScriptFor(value) {
237
266
  const exe = await pickExe()
238
267
  if (!exe) return null
239
- const dir = exe.slice(0, exe.lastIndexOf('\\'))
268
+ const dir = dirOf(exe)
240
269
  return {
241
270
  dir,
242
271
  // dir/value 一律经 psQuote 转义(单引号翻倍),避免含单引号的路径破坏脚本。
@@ -244,23 +273,19 @@ export function apply(ctx, config) {
244
273
  }
245
274
  }
246
275
 
247
- async function writeQuitMarker(value) {
276
+ /**
277
+ * 写退出标记(`.dsh-quit`)。
278
+ * - `wait: true`(默认):等待 PowerShell 结束,成功/失败都记入 `lastMarkerError`;
279
+ * - `wait: false`:即发即忘(退出收尾阶段不为写标记增加延迟)。
280
+ * 原 writeQuitMarkerNoWait 与本函数仅此一点差异,合并后由参数区分(v2 C3)。
281
+ */
282
+ async function writeQuitMarker(value, { wait = true } = {}) {
248
283
  const job = await quitScriptFor(value)
249
284
  if (!job) {
250
- lastMarkerError = 'writeQuitMarker: no exe dir'
285
+ if (wait) lastMarkerError = 'writeQuitMarker: no exe dir'
251
286
  return
252
287
  }
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
288
+ if (!wait) {
264
289
  try {
265
290
  subprocess.spawn({
266
291
  argv: ['powershell', '-NoProfile', '-NonInteractive', '-Command', job.script],
@@ -271,29 +296,36 @@ export function apply(ctx, config) {
271
296
  } catch (error) {
272
297
  lastMarkerError = String((error && error.message) || error)
273
298
  }
274
- })
299
+ return
300
+ }
301
+ try {
302
+ await runPowerShell(job.script, job.dir)
303
+ lastMarkerError = ''
304
+ } catch (error) {
305
+ lastMarkerError = String((error && error.message) || error)
306
+ }
275
307
  }
276
308
 
277
309
  async function quitFileGone() {
278
310
  const exe = await pickExe()
279
311
  if (!exe) return true
280
- const dir = exe.slice(0, exe.lastIndexOf('\\'))
312
+ const dir = dirOf(exe)
281
313
  return (await fileText(dir, '.dsh-quit')) === null
282
314
  }
283
315
 
284
316
  async function quitMarkerIsOne() {
285
317
  const exe = await pickExe()
286
318
  if (!exe) return false
287
- const dir = exe.slice(0, exe.lastIndexOf('\\'))
319
+ const dir = dirOf(exe)
288
320
  return (await fileText(dir, '.dsh-quit')) === '1'
289
321
  }
290
322
 
291
323
  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 }"
324
+ return "$desktop=[Environment]::GetFolderPath('Desktop'); $lnk=Join-Path $desktop " + psQuote(SHORTCUT_NAME) + "; if (Test-Path -LiteralPath $lnk) { exit 0 } else { exit 1 }"
293
325
  }
294
326
 
295
327
  function shortcutCreateScript(exe) {
296
- return "$desktop=[Environment]::GetFolderPath('Desktop'); $lnk=Join-Path $desktop " + psQuote(resolved.shortcutName) + "; " +
328
+ return "$desktop=[Environment]::GetFolderPath('Desktop'); $lnk=Join-Path $desktop " + psQuote(SHORTCUT_NAME) + "; " +
297
329
  "$exe=" + psQuote(exe) + "; " +
298
330
  '$ws=New-Object -ComObject WScript.Shell; $sc=$ws.CreateShortcut($lnk); ' +
299
331
  '$sc.TargetPath=$exe; $sc.WorkingDirectory=Split-Path $exe; ' +
@@ -302,7 +334,7 @@ export function apply(ctx, config) {
302
334
  }
303
335
 
304
336
  function shortcutDeleteScript() {
305
- return "$desktop=[Environment]::GetFolderPath('Desktop'); $lnk=Join-Path $desktop " + psQuote(resolved.shortcutName) + '; ' +
337
+ return "$desktop=[Environment]::GetFolderPath('Desktop'); $lnk=Join-Path $desktop " + psQuote(SHORTCUT_NAME) + '; ' +
306
338
  'Remove-Item -LiteralPath $lnk -Force -ErrorAction SilentlyContinue; ' +
307
339
  'if (Test-Path -LiteralPath $lnk) { exit 1 } else { exit 0 }'
308
340
  }
@@ -310,7 +342,7 @@ export function apply(ctx, config) {
310
342
  async function shortcutExists() {
311
343
  const now = Date.now()
312
344
  if (now - shortcutCache.t < 5000) return shortcutCache.value
313
- const code = await runPowerShellExitCode(shortcutExistsScript(), process.cwd())
345
+ const code = await runPowerShell(shortcutExistsScript(), process.cwd(), { wantExitCode: true })
314
346
  shortcutCache = { t: Date.now(), value: code === 0 }
315
347
  return shortcutCache.value
316
348
  }
@@ -319,14 +351,14 @@ export function apply(ctx, config) {
319
351
  if (await shortcutExists()) return true
320
352
  const exe = await pickExe()
321
353
  if (!exe) return false
322
- const code = await runPowerShellExitCode(shortcutCreateScript(exe), process.cwd())
354
+ const code = await runPowerShell(shortcutCreateScript(exe), process.cwd(), { wantExitCode: true })
323
355
  shortcutCache = { t: Date.now(), value: code === 0 }
324
356
  return code === 0
325
357
  }
326
358
 
327
359
  async function removeShortcut() {
328
360
  if (!(await shortcutExists())) return true
329
- const code = await runPowerShellExitCode(shortcutDeleteScript(), process.cwd())
361
+ const code = await runPowerShell(shortcutDeleteScript(), process.cwd(), { wantExitCode: true })
330
362
  shortcutCache = { t: Date.now(), value: code !== 0 }
331
363
  return code === 0
332
364
  }
@@ -373,7 +405,7 @@ export function apply(ctx, config) {
373
405
  return { ok: false, error: 'subprocess 服务不可用,无法启动桌面应用。', diag: await buildDiag() }
374
406
  }
375
407
  try {
376
- const dir = exe.slice(0, exe.lastIndexOf('\\'))
408
+ const dir = dirOf(exe)
377
409
  spawnHandle = subprocess.spawn({ argv: [exe], cwd: dir, stdio: STDIO, graceMs: 3000 })
378
410
  } catch (error) {
379
411
  return { ok: false, error: '启动桌面应用失败:' + String((error && error.message) || error), diag: await buildDiag() }
@@ -410,11 +442,11 @@ export function apply(ctx, config) {
410
442
  }
411
443
  if (confirmed) {
412
444
  if (owned && owned.done) await Promise.race([owned.done.catch(() => {}), sleep(3000)])
413
- writeQuitMarkerNoWait('0')
445
+ void writeQuitMarker('0', { wait: false })
414
446
  if (!(await removeShortcut())) lastMarkerError = 'shortcut sync failed (delete)'
415
447
  return { ok: true, desktop: false, shortcut: await shortcutExists(), diag: '' }
416
448
  }
417
- writeQuitMarkerNoWait('0')
449
+ void writeQuitMarker('0', { wait: false })
418
450
  const state = await waitGone(20)
419
451
  if (state === true) {
420
452
  if (!(await removeShortcut())) lastMarkerError = 'shortcut sync failed (delete)'
@@ -423,7 +455,7 @@ export function apply(ctx, config) {
423
455
  if (state === null) return { ok: true, desktop: null, shortcut: await shortcutExists(), diag: '' }
424
456
  try {
425
457
  const exe = await pickExe()
426
- const dir = exe ? exe.slice(0, exe.lastIndexOf('\\')) : process.cwd()
458
+ const dir = (exe && dirOf(exe)) || process.cwd()
427
459
  // 仅终止与探测到的 exe 路径匹配的进程,避免误杀其他同名程序。
428
460
  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
461
  await runPowerShell(script, dir)
@@ -445,39 +477,46 @@ export function apply(ctx, config) {
445
477
  }
446
478
 
447
479
  ctx.effect(() => {
480
+ /**
481
+ * 统一的信任边界守卫:回环 + 同源(自定义头 / Origin / Referer)校验,
482
+ * 不通过直接 403。原实现把这三行复制进每个 handler(优化报告 v2 C4)。
483
+ */
484
+ const guard = (handler) => async (req, res) => {
485
+ if (!isTrustedRequest(req, ctx.webServer.port)) {
486
+ return writeJson(res, 403, { ok: false, error: 'forbidden' })
487
+ }
488
+ return handler(req, res)
489
+ }
490
+
448
491
  const routes = [
449
492
  {
450
493
  kind: 'exact',
451
494
  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' })
495
+ handler: guard(async (_req, res) => {
454
496
  writeJson(res, 200, await getState())
455
- },
497
+ }),
456
498
  },
457
499
  {
458
500
  kind: 'exact',
459
501
  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' })
502
+ handler: guard(async (req, res) => {
462
503
  const body = await readJsonBody(req)
463
504
  writeJson(res, 200, await setDesktop(Boolean(body.enabled)))
464
- },
505
+ }),
465
506
  },
466
507
  {
467
508
  kind: 'exact',
468
509
  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' })
510
+ handler: guard(async (_req, res) => {
471
511
  writeJson(res, 200, await setShortcut())
472
- },
512
+ }),
473
513
  },
474
514
  {
475
515
  kind: 'exact',
476
516
  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' })
517
+ handler: guard(async (_req, res) => {
479
518
  writeJson(res, 200, await getDiag())
480
- },
519
+ }),
481
520
  },
482
521
  ]
483
522
  const disposers = routes.map((route) => ctx.webServer.register(route))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenorin/dsh-tauri-launcher",
3
- "version": "1.0.8",
3
+ "version": "1.0.9",
4
4
  "description": "DSH Web 插件:基于 Tauri 2 的 DeepSeek Harness 桌面启动器,自动联动桌面快捷方式,exe 路径与探测窗口可配置。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",