@lenorin/dsh-tauri-launcher 1.0.8 → 1.0.10
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 +6 -1
- package/README.md +22 -1
- package/launcher/bin/dsh-launcher.exe +0 -0
- package/lib/client.js +101 -19
- package/lib/index.js +226 -67
- package/package.json +1 -1
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,28 @@ 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
|
+
|
|
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
|
+
|
|
137
157
|
## 桌面应用构建与发布
|
|
138
158
|
|
|
139
159
|
- npm 包自带预编译 exe(`launcher/bin/dsh-launcher.exe`),插件安装后自动探测、装上即用;
|
|
@@ -149,6 +169,7 @@ dsh plugin --profile web remove @lenorin/dsh-tauri-launcher
|
|
|
149
169
|
|
|
150
170
|
- [docs/architecture.md](docs/architecture.md) — 架构与标记文件协议
|
|
151
171
|
- [docs/release.md](docs/release.md) — 安装、构建、发布、卸载与配置参考
|
|
172
|
+
- [CHANGELOG.md](CHANGELOG.md) — 更新日志(逐版本发版说明,GitHub/Gitee 发行版正文的来源)
|
|
152
173
|
|
|
153
174
|
## License
|
|
154
175
|
|
|
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
|
|
475
|
+
let pending = false
|
|
476
|
+
let lastSignature = ''
|
|
452
477
|
const postTheme = () => {
|
|
453
|
-
|
|
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
|
|
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
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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',
|
|
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(
|
|
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
|
|
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
|
|
619
|
+
const status = { back: nav.status().back, forward: nav.status().forward }
|
|
562
620
|
try {
|
|
563
|
-
//
|
|
564
|
-
//
|
|
565
|
-
|
|
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
|
|
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
|
-
*
|
|
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 { /*
|
|
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
|
@@ -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
|
*/
|
|
@@ -28,9 +33,42 @@ const PACKAGE_BIN = fileURLToPath(new URL('../launcher/bin', import.meta.url))
|
|
|
28
33
|
/** 未配置 launcherDirs 时的默认候选目录(仅包内随附的预编译 exe;本地自行构建的 exe 通过行配置 launcherDirs/launcherExe 指定)。 */
|
|
29
34
|
const DEFAULT_DIRS = [PACKAGE_BIN]
|
|
30
35
|
|
|
36
|
+
/**
|
|
37
|
+
* 运行中实例 / 桌面快捷方式目标的探测缓存时长(毫秒)。状态轮询与启停等待
|
|
38
|
+
* 循环都会反复取候选目录,探测结果按此窗口复用,避免每次读取都拉起 PowerShell。
|
|
39
|
+
*/
|
|
40
|
+
const DISCOVERY_TTL_MS = 5000
|
|
41
|
+
|
|
42
|
+
/** 会话内累积的候选目录上限(含已退出实例的目录)。 */
|
|
43
|
+
const MAX_KNOWN_DIRS = 8
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* PowerShell 输出编码前置:Windows PowerShell 5.1 默认按控制台代码页写重定向
|
|
47
|
+
* 输出,非 ASCII 路径会乱码;`ctx.subprocess` 的收集模式按 UTF-8 解码,故显式
|
|
48
|
+
* 钉成 UTF-8(与 `@deepseek-ai/dsh-pwsh-local` 的 ENCODING_PREAMBLE 同做法)。
|
|
49
|
+
*/
|
|
50
|
+
const PS_UTF8_PREAMBLE = '[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); $OutputEncoding = [System.Text.UTF8Encoding]::new($false); '
|
|
51
|
+
|
|
52
|
+
/** 列出运行中的桌面应用 exe 路径(同名 exe 可能有多份副本同时在跑)。 */
|
|
53
|
+
const RUNNING_EXE_SCRIPT =
|
|
54
|
+
'Get-Process -Name dsh-launcher -ErrorAction SilentlyContinue | ForEach-Object { try { $_.Path } catch {} }'
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 桌面快捷方式文件名。**单一来源**:必须与桌面应用 `src-tauri/src/settings.rs`
|
|
58
|
+
* 的 `SHORTCUT_NAME` 常量一致——两侧各自维护名字会导致插件与桌面应用各建
|
|
59
|
+
* 一个 .lnk(优化报告 v2 B3),因此不再提供行配置。
|
|
60
|
+
*/
|
|
61
|
+
const SHORTCUT_NAME = 'DeepSeek Harness.lnk'
|
|
62
|
+
|
|
31
63
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
32
64
|
const psQuote = (value) => "'" + String(value).replace(/'/g, "''") + "'"
|
|
33
65
|
|
|
66
|
+
/** exe 所在目录(Windows 反斜杠路径);无分隔符时返回空串(调用方回退)。 */
|
|
67
|
+
const dirOf = (exePath) => {
|
|
68
|
+
const slash = exePath.lastIndexOf('\\')
|
|
69
|
+
return slash > 0 ? exePath.slice(0, slash) : ''
|
|
70
|
+
}
|
|
71
|
+
|
|
34
72
|
function writeJson(res, status, body) {
|
|
35
73
|
const payload = JSON.stringify(body)
|
|
36
74
|
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
|
@@ -69,15 +107,26 @@ function isLoopback(req) {
|
|
|
69
107
|
* 3. Referer 指向 DSH 自身页面地址。
|
|
70
108
|
* 三者全缺(如 <img>/<script> 等无头请求)拒绝。
|
|
71
109
|
*/
|
|
110
|
+
/** 允许的 DSH 页面 Origin 集合(按端口缓存,避免每个请求重建 Set)。 */
|
|
111
|
+
const allowedOriginsByPort = new Map()
|
|
112
|
+
function allowedOriginsFor(port) {
|
|
113
|
+
let set = allowedOriginsByPort.get(port)
|
|
114
|
+
if (set === undefined) {
|
|
115
|
+
set = new Set([
|
|
116
|
+
'http://127.0.0.1:' + port,
|
|
117
|
+
'http://localhost:' + port,
|
|
118
|
+
'http://[::1]:' + port,
|
|
119
|
+
])
|
|
120
|
+
allowedOriginsByPort.set(port, set)
|
|
121
|
+
}
|
|
122
|
+
return set
|
|
123
|
+
}
|
|
124
|
+
|
|
72
125
|
function isTrustedRequest(req, port) {
|
|
73
126
|
if (!isLoopback(req)) return false
|
|
74
127
|
const headers = req.headers || {}
|
|
75
128
|
if (headers['x-requested-with'] === 'dsh-tauri-launcher') return true
|
|
76
|
-
const allowed =
|
|
77
|
-
'http://127.0.0.1:' + port,
|
|
78
|
-
'http://localhost:' + port,
|
|
79
|
-
'http://[::1]:' + port,
|
|
80
|
-
])
|
|
129
|
+
const allowed = allowedOriginsFor(port)
|
|
81
130
|
if (headers.origin && allowed.has(headers.origin)) return true
|
|
82
131
|
const referer = headers.referer
|
|
83
132
|
if (referer) {
|
|
@@ -101,21 +150,101 @@ export function apply(ctx, config) {
|
|
|
101
150
|
launcherDirs: Array.isArray(config && config.launcherDirs) ? config.launcherDirs.filter((d) => typeof d === 'string') : [],
|
|
102
151
|
/** 心跳“新鲜窗口”(秒)。桌面应用每秒写心跳,默认 4 秒即 4 个周期的余量。 */
|
|
103
152
|
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
153
|
}
|
|
107
154
|
|
|
108
155
|
let lastFsError = ''
|
|
109
156
|
let lastMarkerError = ''
|
|
157
|
+
let lastProbeError = ''
|
|
110
158
|
let spawnHandle = null
|
|
111
159
|
let shortcutCache = { t: 0, value: false }
|
|
112
160
|
|
|
113
|
-
|
|
114
|
-
|
|
161
|
+
/**
|
|
162
|
+
* 已在本次插件运行中发现过的候选目录(最近使用在前)。实例退出后仍保留:
|
|
163
|
+
* 该目录里的心跳文件会变成「过期」而非「不存在」,让状态稳定落在
|
|
164
|
+
* 「已停止」而不是回跳到 null(未知),退出流程的确认逻辑也不会中途丢目标。
|
|
165
|
+
*/
|
|
166
|
+
const knownDirs = []
|
|
167
|
+
/** 「运行中实例目录 + 桌面快捷方式目标目录」的探测缓存。 */
|
|
168
|
+
const discovery = { t: 0, runningDirs: [], shortcutDir: '' }
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* 运行采集模式 PowerShell 并取回 stdout 文本(短探测脚本)。
|
|
172
|
+
* 任何失败(服务缺失、spawn 失败、provider 未提供收集流)都返回空串,
|
|
173
|
+
* 调用方据此回退到既有候选目录——探测不可用不改变原有行为。
|
|
174
|
+
*/
|
|
175
|
+
async function capturePowerShell(script) {
|
|
176
|
+
if (!subprocess) return ''
|
|
177
|
+
try {
|
|
178
|
+
const handle = subprocess.spawn({
|
|
179
|
+
argv: ['powershell', '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', PS_UTF8_PREAMBLE + script],
|
|
180
|
+
cwd: process.cwd(),
|
|
181
|
+
stdio: { stdin: 'ignore', stdout: { maxBytes: 16384 }, stderr: { maxBytes: 4096 } },
|
|
182
|
+
graceMs: 3000,
|
|
183
|
+
})
|
|
184
|
+
if (handle && handle.done) await handle.done.catch(() => null)
|
|
185
|
+
const reader = handle && handle.collected ? handle.collected.stdout : null
|
|
186
|
+
if (!reader) return ''
|
|
187
|
+
return String(reader.readFrom(0).text || '')
|
|
188
|
+
} catch (error) {
|
|
189
|
+
lastProbeError = String((error && error.message) || error)
|
|
190
|
+
return ''
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** 记住一个已发现的候选目录(去重、最近使用在前、限量)。 */
|
|
195
|
+
function rememberDir(dir) {
|
|
196
|
+
if (!dir) return
|
|
197
|
+
const hit = knownDirs.indexOf(dir)
|
|
198
|
+
if (hit >= 0) knownDirs.splice(hit, 1)
|
|
199
|
+
knownDirs.unshift(dir)
|
|
200
|
+
if (knownDirs.length > MAX_KNOWN_DIRS) knownDirs.length = MAX_KNOWN_DIRS
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* 探测正在运行的桌面应用目录与桌面快捷方式目标目录(TTL 内复用)。
|
|
205
|
+
*
|
|
206
|
+
* 为什么需要:标记协议以 exe 同目录为界,而桌面应用存在多份副本(包内预编译
|
|
207
|
+
* exe、本地构建产物)。只看包内目录时,若实际运行的是另一份副本,就会读到空
|
|
208
|
+
* 心跳 → 状态「未知」、退出标记写进错误目录、开关反而拉起第二份实例。
|
|
209
|
+
*/
|
|
210
|
+
async function probe() {
|
|
211
|
+
if (Date.now() - discovery.t < DISCOVERY_TTL_MS) return discovery
|
|
212
|
+
const [runningText, targetText] = await Promise.all([
|
|
213
|
+
capturePowerShell(RUNNING_EXE_SCRIPT),
|
|
214
|
+
capturePowerShell(shortcutTargetScript()),
|
|
215
|
+
])
|
|
216
|
+
const runningDirs = []
|
|
217
|
+
for (const line of String(runningText).split(/\r?\n/)) {
|
|
218
|
+
const dir = dirOf(line.trim())
|
|
219
|
+
if (dir && !runningDirs.includes(dir)) runningDirs.push(dir)
|
|
220
|
+
}
|
|
221
|
+
const targetLine = String(targetText).split(/\r?\n/).map((line) => line.trim()).filter((line) => line !== '')[0] || ''
|
|
222
|
+
discovery.t = Date.now()
|
|
223
|
+
discovery.runningDirs = runningDirs
|
|
224
|
+
discovery.shortcutDir = dirOf(targetLine)
|
|
225
|
+
for (const dir of runningDirs) rememberDir(dir)
|
|
226
|
+
if (discovery.shortcutDir) rememberDir(discovery.shortcutDir)
|
|
227
|
+
return discovery
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* 候选 exe 目录,按启动优先级排列:
|
|
232
|
+
* 1. 行配置 launcherExe 所在目录;2. 运行中实例目录;3. 桌面快捷方式目标目录;
|
|
233
|
+
* 4. 行配置 launcherDirs;5. 本会话发现过的目录;6. 包内随附 exe 目录。
|
|
234
|
+
* 只有真的含 dsh-launcher.exe 的目录才会被 exeDirs() 采用——失效的快捷方式
|
|
235
|
+
* 目标会被自动跳过,回退到下一候选。
|
|
236
|
+
*/
|
|
237
|
+
async function baseDirs() {
|
|
238
|
+
const found = await probe()
|
|
239
|
+
const list = []
|
|
115
240
|
if (resolved.launcherExe) {
|
|
116
|
-
const
|
|
117
|
-
if (
|
|
241
|
+
const dir = dirOf(resolved.launcherExe)
|
|
242
|
+
if (dir) list.push(dir)
|
|
118
243
|
}
|
|
244
|
+
for (const dir of found.runningDirs) list.push(dir)
|
|
245
|
+
if (found.shortcutDir) list.push(found.shortcutDir)
|
|
246
|
+
for (const dir of resolved.launcherDirs) list.push(dir)
|
|
247
|
+
for (const dir of knownDirs) list.push(dir)
|
|
119
248
|
for (const dir of DEFAULT_DIRS) list.push(dir)
|
|
120
249
|
return [...new Set(list)]
|
|
121
250
|
}
|
|
@@ -133,7 +262,8 @@ export function apply(ctx, config) {
|
|
|
133
262
|
}
|
|
134
263
|
|
|
135
264
|
async function exeDirs() {
|
|
136
|
-
const
|
|
265
|
+
const dirs = await baseDirs()
|
|
266
|
+
const checked = await Promise.all(dirs.map(async (dir) => ((await exeExists(dir)) ? dir : null)))
|
|
137
267
|
return checked.filter((dir) => dir !== null)
|
|
138
268
|
}
|
|
139
269
|
|
|
@@ -156,8 +286,12 @@ export function apply(ctx, config) {
|
|
|
156
286
|
if (text === null || text === '') return null
|
|
157
287
|
const stamp = Number(text)
|
|
158
288
|
if (!Number.isFinite(stamp)) return null
|
|
159
|
-
|
|
160
|
-
|
|
289
|
+
// 单向新鲜度:只认「不早于 now - freshSecs、且不显著超前」的时间戳。
|
|
290
|
+
// 原实现取 Math.abs(),会把**未来时间戳**也判成新鲜——系统时钟回拨或
|
|
291
|
+
// 写入方时钟超前时,残留的心跳文件会被误判为「运行中」(优化报告 v2 C1)。
|
|
292
|
+
// 容 1 秒负向抖动:同一台机器共用系统时钟,正常不会超前。
|
|
293
|
+
const age = Date.now() / 1000 - stamp
|
|
294
|
+
return age > -1 && age < resolved.freshSecs
|
|
161
295
|
}
|
|
162
296
|
|
|
163
297
|
async function isRunning() {
|
|
@@ -205,29 +339,32 @@ export function apply(ctx, config) {
|
|
|
205
339
|
return null
|
|
206
340
|
}
|
|
207
341
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
if (
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
if (!subprocess) return null
|
|
342
|
+
/**
|
|
343
|
+
* 执行 PowerShell 脚本。
|
|
344
|
+
* - 默认(`wantExitCode: false`):等待执行结束,忽略退出码;spawn 失败抛出,
|
|
345
|
+
* 由调用方决定是否记录(写标记等「执行即可」的场景)。
|
|
346
|
+
* - `wantExitCode: true`:返回退出码(无法取得时为 null),失败记入
|
|
347
|
+
* `lastMarkerError` 供诊断使用(原 runPowerShellExitCode 的语义)。
|
|
348
|
+
*/
|
|
349
|
+
async function runPowerShell(script, cwd, { wantExitCode = false } = {}) {
|
|
350
|
+
if (!subprocess) {
|
|
351
|
+
if (wantExitCode) return null
|
|
352
|
+
throw new Error('subprocess 服务不可用')
|
|
353
|
+
}
|
|
221
354
|
try {
|
|
222
355
|
const handle = subprocess.spawn({
|
|
223
356
|
argv: ['powershell', '-NoProfile', '-NonInteractive', '-Command', script],
|
|
224
|
-
|
|
357
|
+
// 不带 cwd 时:需要退出码的调用多为绝对路径脚本,用进程 cwd 即可;
|
|
358
|
+
// 其余(写标记/杀进程)落在 exe 同目录,便于相对路径与权限。
|
|
359
|
+
cwd: cwd || (wantExitCode ? process.cwd() : ((await pickExe()) || '')),
|
|
225
360
|
stdio: STDIO,
|
|
226
361
|
graceMs: 3000,
|
|
227
362
|
})
|
|
228
363
|
const outcome = handle && handle.done ? await handle.done.catch(() => null) : null
|
|
364
|
+
if (!wantExitCode) return undefined
|
|
229
365
|
return outcome && typeof outcome.exitCode === 'number' ? outcome.exitCode : null
|
|
230
366
|
} catch (error) {
|
|
367
|
+
if (!wantExitCode) throw error
|
|
231
368
|
lastMarkerError = String((error && error.message) || error)
|
|
232
369
|
return null
|
|
233
370
|
}
|
|
@@ -236,7 +373,7 @@ export function apply(ctx, config) {
|
|
|
236
373
|
async function quitScriptFor(value) {
|
|
237
374
|
const exe = await pickExe()
|
|
238
375
|
if (!exe) return null
|
|
239
|
-
const dir =
|
|
376
|
+
const dir = dirOf(exe)
|
|
240
377
|
return {
|
|
241
378
|
dir,
|
|
242
379
|
// dir/value 一律经 psQuote 转义(单引号翻倍),避免含单引号的路径破坏脚本。
|
|
@@ -244,23 +381,19 @@ export function apply(ctx, config) {
|
|
|
244
381
|
}
|
|
245
382
|
}
|
|
246
383
|
|
|
247
|
-
|
|
384
|
+
/**
|
|
385
|
+
* 写退出标记(`.dsh-quit`)。
|
|
386
|
+
* - `wait: true`(默认):等待 PowerShell 结束,成功/失败都记入 `lastMarkerError`;
|
|
387
|
+
* - `wait: false`:即发即忘(退出收尾阶段不为写标记增加延迟)。
|
|
388
|
+
* 原 writeQuitMarkerNoWait 与本函数仅此一点差异,合并后由参数区分(v2 C3)。
|
|
389
|
+
*/
|
|
390
|
+
async function writeQuitMarker(value, { wait = true } = {}) {
|
|
248
391
|
const job = await quitScriptFor(value)
|
|
249
392
|
if (!job) {
|
|
250
|
-
lastMarkerError = 'writeQuitMarker: no exe dir'
|
|
393
|
+
if (wait) lastMarkerError = 'writeQuitMarker: no exe dir'
|
|
251
394
|
return
|
|
252
395
|
}
|
|
253
|
-
|
|
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
|
|
396
|
+
if (!wait) {
|
|
264
397
|
try {
|
|
265
398
|
subprocess.spawn({
|
|
266
399
|
argv: ['powershell', '-NoProfile', '-NonInteractive', '-Command', job.script],
|
|
@@ -271,29 +404,42 @@ export function apply(ctx, config) {
|
|
|
271
404
|
} catch (error) {
|
|
272
405
|
lastMarkerError = String((error && error.message) || error)
|
|
273
406
|
}
|
|
274
|
-
|
|
407
|
+
return
|
|
408
|
+
}
|
|
409
|
+
try {
|
|
410
|
+
await runPowerShell(job.script, job.dir)
|
|
411
|
+
lastMarkerError = ''
|
|
412
|
+
} catch (error) {
|
|
413
|
+
lastMarkerError = String((error && error.message) || error)
|
|
414
|
+
}
|
|
275
415
|
}
|
|
276
416
|
|
|
277
417
|
async function quitFileGone() {
|
|
278
418
|
const exe = await pickExe()
|
|
279
419
|
if (!exe) return true
|
|
280
|
-
const dir =
|
|
420
|
+
const dir = dirOf(exe)
|
|
281
421
|
return (await fileText(dir, '.dsh-quit')) === null
|
|
282
422
|
}
|
|
283
423
|
|
|
284
424
|
async function quitMarkerIsOne() {
|
|
285
425
|
const exe = await pickExe()
|
|
286
426
|
if (!exe) return false
|
|
287
|
-
const dir =
|
|
427
|
+
const dir = dirOf(exe)
|
|
288
428
|
return (await fileText(dir, '.dsh-quit')) === '1'
|
|
289
429
|
}
|
|
290
430
|
|
|
291
431
|
function shortcutExistsScript() {
|
|
292
|
-
return "$desktop=[Environment]::GetFolderPath('Desktop'); $lnk=Join-Path $desktop " + psQuote(
|
|
432
|
+
return "$desktop=[Environment]::GetFolderPath('Desktop'); $lnk=Join-Path $desktop " + psQuote(SHORTCUT_NAME) + "; if (Test-Path -LiteralPath $lnk) { exit 0 } else { exit 1 }"
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** 读桌面快捷方式的目标 exe 路径;快捷方式不存在或目标为空时无输出。 */
|
|
436
|
+
function shortcutTargetScript() {
|
|
437
|
+
return "$desktop=[Environment]::GetFolderPath('Desktop'); $lnk=Join-Path $desktop " + psQuote(SHORTCUT_NAME) + '; ' +
|
|
438
|
+
'if (Test-Path -LiteralPath $lnk) { $ws=New-Object -ComObject WScript.Shell; $sc=$ws.CreateShortcut($lnk); if ($sc.TargetPath) { $sc.TargetPath } }'
|
|
293
439
|
}
|
|
294
440
|
|
|
295
441
|
function shortcutCreateScript(exe) {
|
|
296
|
-
return "$desktop=[Environment]::GetFolderPath('Desktop'); $lnk=Join-Path $desktop " + psQuote(
|
|
442
|
+
return "$desktop=[Environment]::GetFolderPath('Desktop'); $lnk=Join-Path $desktop " + psQuote(SHORTCUT_NAME) + "; " +
|
|
297
443
|
"$exe=" + psQuote(exe) + "; " +
|
|
298
444
|
'$ws=New-Object -ComObject WScript.Shell; $sc=$ws.CreateShortcut($lnk); ' +
|
|
299
445
|
'$sc.TargetPath=$exe; $sc.WorkingDirectory=Split-Path $exe; ' +
|
|
@@ -302,7 +448,7 @@ export function apply(ctx, config) {
|
|
|
302
448
|
}
|
|
303
449
|
|
|
304
450
|
function shortcutDeleteScript() {
|
|
305
|
-
return "$desktop=[Environment]::GetFolderPath('Desktop'); $lnk=Join-Path $desktop " + psQuote(
|
|
451
|
+
return "$desktop=[Environment]::GetFolderPath('Desktop'); $lnk=Join-Path $desktop " + psQuote(SHORTCUT_NAME) + '; ' +
|
|
306
452
|
'Remove-Item -LiteralPath $lnk -Force -ErrorAction SilentlyContinue; ' +
|
|
307
453
|
'if (Test-Path -LiteralPath $lnk) { exit 1 } else { exit 0 }'
|
|
308
454
|
}
|
|
@@ -310,7 +456,7 @@ export function apply(ctx, config) {
|
|
|
310
456
|
async function shortcutExists() {
|
|
311
457
|
const now = Date.now()
|
|
312
458
|
if (now - shortcutCache.t < 5000) return shortcutCache.value
|
|
313
|
-
const code = await
|
|
459
|
+
const code = await runPowerShell(shortcutExistsScript(), process.cwd(), { wantExitCode: true })
|
|
314
460
|
shortcutCache = { t: Date.now(), value: code === 0 }
|
|
315
461
|
return shortcutCache.value
|
|
316
462
|
}
|
|
@@ -319,14 +465,14 @@ export function apply(ctx, config) {
|
|
|
319
465
|
if (await shortcutExists()) return true
|
|
320
466
|
const exe = await pickExe()
|
|
321
467
|
if (!exe) return false
|
|
322
|
-
const code = await
|
|
468
|
+
const code = await runPowerShell(shortcutCreateScript(exe), process.cwd(), { wantExitCode: true })
|
|
323
469
|
shortcutCache = { t: Date.now(), value: code === 0 }
|
|
324
470
|
return code === 0
|
|
325
471
|
}
|
|
326
472
|
|
|
327
473
|
async function removeShortcut() {
|
|
328
474
|
if (!(await shortcutExists())) return true
|
|
329
|
-
const code = await
|
|
475
|
+
const code = await runPowerShell(shortcutDeleteScript(), process.cwd(), { wantExitCode: true })
|
|
330
476
|
shortcutCache = { t: Date.now(), value: code !== 0 }
|
|
331
477
|
return code === 0
|
|
332
478
|
}
|
|
@@ -338,11 +484,15 @@ export function apply(ctx, config) {
|
|
|
338
484
|
lines.push('freshWindowSecs: ' + resolved.freshSecs)
|
|
339
485
|
lines.push('launcherExe: ' + (resolved.launcherExe || '(auto)'))
|
|
340
486
|
lines.push('shortcut: ' + (await shortcutExists()))
|
|
487
|
+
const found = await probe()
|
|
488
|
+
lines.push('runningDirs: ' + JSON.stringify(found.runningDirs))
|
|
489
|
+
lines.push('shortcutTarget: ' + (found.shortcutDir || '(none)'))
|
|
341
490
|
const dirs = await exeDirs()
|
|
342
491
|
lines.push('exeDirs: ' + JSON.stringify(dirs))
|
|
343
492
|
const exe = await pickExe()
|
|
344
493
|
lines.push('pickExe: ' + (exe || '(null)'))
|
|
345
494
|
if (lastFsError) lines.push('fsError: ' + lastFsError)
|
|
495
|
+
if (lastProbeError) lines.push('probeError: ' + lastProbeError)
|
|
346
496
|
lines.push('markerWrite: ' + (lastMarkerError || 'ok'))
|
|
347
497
|
for (const dir of dirs) {
|
|
348
498
|
lines.push('heartbeat(' + dir.split('\\').slice(-2).join('\\') + '): ' + String(await hbState(dir)))
|
|
@@ -373,7 +523,9 @@ export function apply(ctx, config) {
|
|
|
373
523
|
return { ok: false, error: 'subprocess 服务不可用,无法启动桌面应用。', diag: await buildDiag() }
|
|
374
524
|
}
|
|
375
525
|
try {
|
|
376
|
-
const dir =
|
|
526
|
+
const dir = dirOf(exe)
|
|
527
|
+
// 拉起前记住该目录:即使探测缓存仍是旧的,心跳扫描也已覆盖新实例的目录。
|
|
528
|
+
rememberDir(dir)
|
|
377
529
|
spawnHandle = subprocess.spawn({ argv: [exe], cwd: dir, stdio: STDIO, graceMs: 3000 })
|
|
378
530
|
} catch (error) {
|
|
379
531
|
return { ok: false, error: '启动桌面应用失败:' + String((error && error.message) || error), diag: await buildDiag() }
|
|
@@ -410,11 +562,11 @@ export function apply(ctx, config) {
|
|
|
410
562
|
}
|
|
411
563
|
if (confirmed) {
|
|
412
564
|
if (owned && owned.done) await Promise.race([owned.done.catch(() => {}), sleep(3000)])
|
|
413
|
-
|
|
565
|
+
void writeQuitMarker('0', { wait: false })
|
|
414
566
|
if (!(await removeShortcut())) lastMarkerError = 'shortcut sync failed (delete)'
|
|
415
567
|
return { ok: true, desktop: false, shortcut: await shortcutExists(), diag: '' }
|
|
416
568
|
}
|
|
417
|
-
|
|
569
|
+
void writeQuitMarker('0', { wait: false })
|
|
418
570
|
const state = await waitGone(20)
|
|
419
571
|
if (state === true) {
|
|
420
572
|
if (!(await removeShortcut())) lastMarkerError = 'shortcut sync failed (delete)'
|
|
@@ -423,7 +575,7 @@ export function apply(ctx, config) {
|
|
|
423
575
|
if (state === null) return { ok: true, desktop: null, shortcut: await shortcutExists(), diag: '' }
|
|
424
576
|
try {
|
|
425
577
|
const exe = await pickExe()
|
|
426
|
-
const dir = exe
|
|
578
|
+
const dir = (exe && dirOf(exe)) || process.cwd()
|
|
427
579
|
// 仅终止与探测到的 exe 路径匹配的进程,避免误杀其他同名程序。
|
|
428
580
|
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
581
|
await runPowerShell(script, dir)
|
|
@@ -445,39 +597,46 @@ export function apply(ctx, config) {
|
|
|
445
597
|
}
|
|
446
598
|
|
|
447
599
|
ctx.effect(() => {
|
|
600
|
+
/**
|
|
601
|
+
* 统一的信任边界守卫:回环 + 同源(自定义头 / Origin / Referer)校验,
|
|
602
|
+
* 不通过直接 403。原实现把这三行复制进每个 handler(优化报告 v2 C4)。
|
|
603
|
+
*/
|
|
604
|
+
const guard = (handler) => async (req, res) => {
|
|
605
|
+
if (!isTrustedRequest(req, ctx.webServer.port)) {
|
|
606
|
+
return writeJson(res, 403, { ok: false, error: 'forbidden' })
|
|
607
|
+
}
|
|
608
|
+
return handler(req, res)
|
|
609
|
+
}
|
|
610
|
+
|
|
448
611
|
const routes = [
|
|
449
612
|
{
|
|
450
613
|
kind: 'exact',
|
|
451
614
|
path: '/api/dsh-tauri-launcher/state',
|
|
452
|
-
handler: async (
|
|
453
|
-
if (!isTrustedRequest(req, ctx.webServer.port)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
|
|
615
|
+
handler: guard(async (_req, res) => {
|
|
454
616
|
writeJson(res, 200, await getState())
|
|
455
|
-
},
|
|
617
|
+
}),
|
|
456
618
|
},
|
|
457
619
|
{
|
|
458
620
|
kind: 'exact',
|
|
459
621
|
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' })
|
|
622
|
+
handler: guard(async (req, res) => {
|
|
462
623
|
const body = await readJsonBody(req)
|
|
463
624
|
writeJson(res, 200, await setDesktop(Boolean(body.enabled)))
|
|
464
|
-
},
|
|
625
|
+
}),
|
|
465
626
|
},
|
|
466
627
|
{
|
|
467
628
|
kind: 'exact',
|
|
468
629
|
path: '/api/dsh-tauri-launcher/set-shortcut',
|
|
469
|
-
handler: async (
|
|
470
|
-
if (!isTrustedRequest(req, ctx.webServer.port)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
|
|
630
|
+
handler: guard(async (_req, res) => {
|
|
471
631
|
writeJson(res, 200, await setShortcut())
|
|
472
|
-
},
|
|
632
|
+
}),
|
|
473
633
|
},
|
|
474
634
|
{
|
|
475
635
|
kind: 'exact',
|
|
476
636
|
path: '/api/dsh-tauri-launcher/diagnose',
|
|
477
|
-
handler: async (
|
|
478
|
-
if (!isTrustedRequest(req, ctx.webServer.port)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
|
|
637
|
+
handler: guard(async (_req, res) => {
|
|
479
638
|
writeJson(res, 200, await getDiag())
|
|
480
|
-
},
|
|
639
|
+
}),
|
|
481
640
|
},
|
|
482
641
|
]
|
|
483
642
|
const disposers = routes.map((route) => ctx.webServer.register(route))
|