@lenorin/dsh-tauri-launcher 1.0.7 → 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 +6 -1
- package/README.md +5 -1
- package/launcher/bin/dsh-launcher.exe +0 -0
- package/lib/client.js +442 -239
- package/lib/index.js +103 -64
- 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,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
|
@@ -3,6 +3,16 @@
|
|
|
3
3
|
* 在设置面板注册「桌面启动」分区:桌面端启动开关(含快速确认与乐观反馈)、
|
|
4
4
|
* 添加快捷方式按钮、关闭确认弹窗、诊断信息(仅出错时显示)。
|
|
5
5
|
* 与宿主通信走 /api/dsh-tauri-launcher/*(同源 fetch,仅回环)。
|
|
6
|
+
*
|
|
7
|
+
* 除设置分区外,本文件还承载两个与桌面端外壳(Tauri 主窗口)协作的功能,
|
|
8
|
+
* 仅在页面被 iframe 内嵌(桌面端)时启用:
|
|
9
|
+
* 1. 标题栏主题中继:把 DSH 主题方案与真实生效的 token 发给外壳(installThemeRelay);
|
|
10
|
+
* 2. 会话导航栈 + 系统主题跟随(installSessionNav / installSystemThemeFollow)。
|
|
11
|
+
*
|
|
12
|
+
* 模块级结构(2026-09 阶段一拆分,见优化报告 v2):
|
|
13
|
+
* - createSessionNavStack:应用层会话导航栈(纯逻辑,无 DOM/服务依赖,可单测)
|
|
14
|
+
* - retryLadder:服务就绪重试梯子(theme/change 事件、sessions 服务可能后于插件就绪)
|
|
15
|
+
* - installDesktopSection / installThemeRelay / installSystemThemeFollow / installSessionNav
|
|
6
16
|
*/
|
|
7
17
|
window.__ModuleLoader__.load({
|
|
8
18
|
id: '@lenorin/dsh-tauri-launcher',
|
|
@@ -114,12 +124,153 @@ body [class*='centerCol'] {
|
|
|
114
124
|
},
|
|
115
125
|
}
|
|
116
126
|
|
|
127
|
+
/**
|
|
128
|
+
* 服务就绪重试梯子:目标服务(theme/change 事件、sessions)可能在插件
|
|
129
|
+
* 之后才就绪,且 preference 等于默认值时不会发变化事件——用短重试保证
|
|
130
|
+
* 初始状态一定送达(成功或达到 maxTries 后停止)。返回清理函数。
|
|
131
|
+
*/
|
|
132
|
+
function retryLadder(attempt, { immediate = false, intervalMs = 500, maxTries = 12 } = {}) {
|
|
133
|
+
let done = false
|
|
134
|
+
let tries = 0
|
|
135
|
+
const run = () => {
|
|
136
|
+
if (done) return
|
|
137
|
+
try {
|
|
138
|
+
if (attempt()) done = true
|
|
139
|
+
} catch { /* 服务未就绪,下一轮重试 */ }
|
|
140
|
+
}
|
|
141
|
+
if (immediate) run()
|
|
142
|
+
const timer = setInterval(() => {
|
|
143
|
+
tries += 1
|
|
144
|
+
if (done || tries >= maxTries) {
|
|
145
|
+
clearInterval(timer)
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
run()
|
|
149
|
+
}, intervalMs)
|
|
150
|
+
return () => clearInterval(timer)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* 应用层会话导航栈(纯逻辑,无 DOM / DSH 服务依赖,可单测)。
|
|
155
|
+
* DSH 是 React SPA:切会话不产生浏览器历史条目,history.back() 无效,
|
|
156
|
+
* 前进/后退等价于在「最近访问会话」双栈上移动(对标浏览器语义):
|
|
157
|
+
* - 非跳转来源的 current 变化 → 上一个会话压 backStack、清空 forwardStack;
|
|
158
|
+
* - goBack/goForward 弹栈跳转,当前会话压对向栈;栈条目 { id, address }
|
|
159
|
+
* (address 供 catalog 子会话 openSubagent);上限 cap、连续重复跳过;
|
|
160
|
+
* - 跳转前置 pendingJump 锁,防止自身触发的变化被当作用户切换重复压栈,
|
|
161
|
+
* 超时(jumpTimeoutMs)兜底解锁,由调用方传入 onUnlock 回调同步最新状态。
|
|
162
|
+
*/
|
|
163
|
+
function createSessionNavStack({ cap = 50, jumpTimeoutMs = 1500 } = {}) {
|
|
164
|
+
let backStack = []
|
|
165
|
+
let forwardStack = []
|
|
166
|
+
let currentId = undefined
|
|
167
|
+
let currentAddress = undefined
|
|
168
|
+
let pendingJump = null
|
|
169
|
+
let jumpTimer = null
|
|
170
|
+
|
|
171
|
+
const pushStack = (stack, entry) => {
|
|
172
|
+
const top = stack[stack.length - 1]
|
|
173
|
+
if (top && top.id === entry.id && top.address === entry.address) return
|
|
174
|
+
stack.push(entry)
|
|
175
|
+
if (stack.length > cap) stack.shift()
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const clearJumpTimer = () => {
|
|
179
|
+
if (jumpTimer) {
|
|
180
|
+
clearTimeout(jumpTimer)
|
|
181
|
+
jumpTimer = null
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
/** 前进/后退两个方向当前是否可走(供外壳置灰按钮)。 */
|
|
187
|
+
status: () => ({ back: backStack.length > 0, forward: forwardStack.length > 0 }),
|
|
188
|
+
/** 以快照初始化当前会话(订阅前先取一次快照;跳转解锁后同步最新状态)。 */
|
|
189
|
+
init: (id, address) => {
|
|
190
|
+
currentId = id
|
|
191
|
+
currentAddress = address
|
|
192
|
+
},
|
|
193
|
+
/** 跳转锁是否生效中。 */
|
|
194
|
+
isPending: () => pendingJump !== null,
|
|
195
|
+
/**
|
|
196
|
+
* 非跳转来源的 current 变化。返回 true 表示状态对外可见变化(调用方应上报)。
|
|
197
|
+
* 规则:current 变 undefined 只清 address;同 id 只更新 address(不压栈);
|
|
198
|
+
* 真正切换才压 backStack 并清空 forwardStack。
|
|
199
|
+
*/
|
|
200
|
+
onExternalChange(snap) {
|
|
201
|
+
if (snap.id === undefined) {
|
|
202
|
+
currentAddress = undefined
|
|
203
|
+
return true
|
|
204
|
+
}
|
|
205
|
+
if (snap.id === currentId) {
|
|
206
|
+
if (snap.address !== undefined) currentAddress = snap.address
|
|
207
|
+
return false
|
|
208
|
+
}
|
|
209
|
+
if (currentId !== undefined) {
|
|
210
|
+
pushStack(backStack, { id: currentId, address: currentAddress })
|
|
211
|
+
forwardStack = []
|
|
212
|
+
}
|
|
213
|
+
currentId = snap.id
|
|
214
|
+
currentAddress = snap.address
|
|
215
|
+
return true
|
|
216
|
+
},
|
|
217
|
+
/**
|
|
218
|
+
* 跳转锁生效期间的变化。返回 true 表示跳转已被确认(命中目标)或被
|
|
219
|
+
* 用户的新切换取代(二者都应解锁并同步状态后上报)。
|
|
220
|
+
*/
|
|
221
|
+
onPendingChange(snap) {
|
|
222
|
+
if (snap.id === pendingJump.id || (snap.id !== undefined && snap.id !== currentId)) {
|
|
223
|
+
clearJumpTimer()
|
|
224
|
+
pendingJump = null
|
|
225
|
+
currentId = snap.id
|
|
226
|
+
currentAddress = snap.address
|
|
227
|
+
return true
|
|
228
|
+
}
|
|
229
|
+
return false
|
|
230
|
+
},
|
|
231
|
+
/** 后退:弹出目标(当前会话压 forwardStack)。无可退时返回 null。 */
|
|
232
|
+
goBack() {
|
|
233
|
+
if (backStack.length === 0) return null
|
|
234
|
+
const target = backStack.pop()
|
|
235
|
+
if (currentId !== undefined) pushStack(forwardStack, { id: currentId, address: currentAddress })
|
|
236
|
+
return target
|
|
237
|
+
},
|
|
238
|
+
/** 前进:对称于 goBack。 */
|
|
239
|
+
goForward() {
|
|
240
|
+
if (forwardStack.length === 0) return null
|
|
241
|
+
const target = forwardStack.pop()
|
|
242
|
+
if (currentId !== undefined) pushStack(backStack, { id: currentId, address: currentAddress })
|
|
243
|
+
return target
|
|
244
|
+
},
|
|
245
|
+
/**
|
|
246
|
+
* 准备一次跳转:置 pendingJump 锁、同步 current,并启动超时兜底 timer。
|
|
247
|
+
* 超时先解锁再回调 onUnlock(调用方应读最新快照并上报);跳转动作抛出
|
|
248
|
+
* 异常时调用 abortJump 回滚锁(timer 保留:到期仍会同步一次状态)。
|
|
249
|
+
*/
|
|
250
|
+
jumpTo(entry, onUnlock) {
|
|
251
|
+
pendingJump = { id: entry.id, address: entry.address }
|
|
252
|
+
currentId = entry.id
|
|
253
|
+
currentAddress = entry.address
|
|
254
|
+
clearJumpTimer()
|
|
255
|
+
jumpTimer = setTimeout(() => {
|
|
256
|
+
pendingJump = null
|
|
257
|
+
onUnlock()
|
|
258
|
+
}, jumpTimeoutMs)
|
|
259
|
+
},
|
|
260
|
+
/** 跳转动作抛出异常时回滚锁。 */
|
|
261
|
+
abortJump: () => {
|
|
262
|
+
pendingJump = null
|
|
263
|
+
},
|
|
264
|
+
/** 释放 timer(插件卸载时)。 */
|
|
265
|
+
dispose: clearJumpTimer,
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
117
269
|
exports.inject = ['slots']
|
|
118
270
|
|
|
119
|
-
|
|
120
|
-
const slots = ctx.get('slots')
|
|
121
|
-
if (slots === undefined) return
|
|
271
|
+
/* ---------- 设置分区「桌面启动」 ---------- */
|
|
122
272
|
|
|
273
|
+
function installDesktopSection(ctx, slots) {
|
|
123
274
|
const styleEl = document.createElement('style')
|
|
124
275
|
styleEl.dataset.plugin = '@lenorin/dsh-tauri-launcher'
|
|
125
276
|
styleEl.textContent = CSS
|
|
@@ -269,14 +420,43 @@ body [class*='centerCol'] {
|
|
|
269
420
|
{ name: 'settings.section', id: 'desktop-launch', order: 30, label: '桌面启动' },
|
|
270
421
|
() => React.createElement(DesktopSection, null),
|
|
271
422
|
))
|
|
423
|
+
}
|
|
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
|
+
}
|
|
272
440
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
+
|
|
447
|
+
/* ---------- 标题栏主题中继(DSH → 外壳) ---------- */
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* 桌面端把 DSH 嵌在 iframe 里;这里读取官方主题服务解析出的浅/深方案,
|
|
451
|
+
* 并把 DSH 页面真实生效的设计 token(计算样式)发给启动器外壳,
|
|
452
|
+
* 让标题栏与 DSH 配色逐像素一致。仅当本页面被嵌入(iframe)时才有意义,
|
|
453
|
+
* 普通浏览器标签(window.parent === window)直接跳过。
|
|
454
|
+
* 注意:内置主题的快照 tokens 是空表,真实颜色在 CSS 变量里
|
|
455
|
+
* (body[data-ds-dark-theme] 切换),必须经 getComputedStyle 取。
|
|
456
|
+
* ⚠️ 所有读取都在当前任务之后(见下方 schedulePost 的时序说明)——
|
|
457
|
+
* 同步读会拿到上一个主题的 DOM 状态,标题栏会滞后一个主题。
|
|
458
|
+
*/
|
|
459
|
+
function installThemeRelay(ctx) {
|
|
280
460
|
const readVar = (name) => {
|
|
281
461
|
try {
|
|
282
462
|
const style = getComputedStyle(document.body)
|
|
@@ -292,9 +472,11 @@ body [class*='centerCol'] {
|
|
|
292
472
|
}
|
|
293
473
|
}
|
|
294
474
|
|
|
295
|
-
let
|
|
475
|
+
let pending = false
|
|
476
|
+
let lastSignature = ''
|
|
296
477
|
const postTheme = () => {
|
|
297
|
-
|
|
478
|
+
pending = false
|
|
479
|
+
if (window.parent === window) return true
|
|
298
480
|
let scheme = null
|
|
299
481
|
try {
|
|
300
482
|
const theme = ctx.get('theme')
|
|
@@ -307,8 +489,7 @@ body [class*='centerCol'] {
|
|
|
307
489
|
if (!scheme) {
|
|
308
490
|
scheme = document.body.hasAttribute('data-ds-dark-theme') ? 'dark' : 'light'
|
|
309
491
|
}
|
|
310
|
-
const
|
|
311
|
-
__dshLauncherTheme: 1,
|
|
492
|
+
const theme = {
|
|
312
493
|
scheme,
|
|
313
494
|
bg: readVar('--dsw-specific-sidebar-fill'),
|
|
314
495
|
fg: readVar('--dsw-alias-label-primary'),
|
|
@@ -317,257 +498,279 @@ body [class*='centerCol'] {
|
|
|
317
498
|
sep: readVar('--dsw-alias-border-l1'),
|
|
318
499
|
danger: readVar('--dsw-alias-state-error-primary'),
|
|
319
500
|
}
|
|
320
|
-
|
|
321
|
-
|
|
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)
|
|
531
|
+
return true
|
|
322
532
|
}
|
|
323
533
|
|
|
324
534
|
try {
|
|
325
|
-
ctx.on('theme/change',
|
|
535
|
+
ctx.on('theme/change', schedulePost)
|
|
326
536
|
} catch { /* 事件不可用时依赖下面的重试梯子 */ }
|
|
327
537
|
|
|
328
|
-
//
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
clearInterval(themeTimer)
|
|
335
|
-
return
|
|
336
|
-
}
|
|
337
|
-
postTheme()
|
|
338
|
-
}, 500)
|
|
339
|
-
ctx.effect(() => () => clearInterval(themeTimer), 'dsh-tauri-launcher: theme relay timer')
|
|
340
|
-
|
|
341
|
-
// ---------- 会话导航(应用层会话栈,等价浏览器前进/后退) ----------
|
|
342
|
-
// DSH 是 React SPA:切会话不产生浏览器历史条目,history.back() 无效。
|
|
343
|
-
// 这里维护"最近访问会话"栈:非本插件发起的 current 变化 → 上一个会话压
|
|
344
|
-
// backStack、清空 forwardStack;◀ 弹 backStack 跳转(当前压 forwardStack),
|
|
345
|
-
// ▶ 反向。跳转前设 pendingJump 锁,防止自身触发的变化被重复压栈,超时兜底解锁。
|
|
346
|
-
// sessions 服务存在就绪时序:一次性 ctx.get 可能拿到 undefined(静默失效),
|
|
347
|
-
// 故用重试梯子初始化;外壳每 3 秒 ping 一次,状态周期性回报实现自愈。
|
|
348
|
-
if (window.parent !== window) {
|
|
349
|
-
let navDispose = null
|
|
350
|
-
let navReady = false
|
|
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
|
+
}
|
|
351
544
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
// ---------- 桌面端系统主题跟随 ----------
|
|
359
|
-
// WebView2 页面里的 prefers-color-scheme 只有宿主显式设置
|
|
360
|
-
// PreferredColorScheme 才会随 Windows 变化,而 tauri/wry 未暴露该能力
|
|
361
|
-
// (wry#806),DSH 的 `system` 偏好会被冻结在启动时的取值。启动器轮询
|
|
362
|
-
// 到系统主题变化后经外壳 postMessage 转发到这里:偏好为 `system` 时把
|
|
363
|
-
// ThemeRuntime 持有的 MediaQueryList.matches(冻结值)覆盖为真实系统值
|
|
364
|
-
// 并触发重发布——偏好保持「跟随系统」不变,界面随真实系统主题切换;
|
|
365
|
-
// 用户手动选了固定主题时不干预。浏览器直开不进入本块(Chromium 原生跟随)。
|
|
366
|
-
// ⚠️ 兼容层 hack:依赖 ThemeRuntime 的 media/publish 运行时成员(无真
|
|
367
|
-
// 私有化),外壳升级若重命名则静默退化为现状(跟随失效,无副作用)。
|
|
368
|
-
const applySystemTheme = (scheme) => {
|
|
369
|
-
try {
|
|
370
|
-
const theme = ctx.get('theme')
|
|
371
|
-
if (!theme || !(theme.media instanceof MediaQueryList)) return
|
|
372
|
-
if (typeof theme.publish !== 'function') return
|
|
373
|
-
const snap = typeof theme.getTheme === 'function' ? theme.getTheme() : null
|
|
374
|
-
if (!snap || snap.preference !== 'system') return
|
|
375
|
-
if (snap.active && snap.active.colorScheme === scheme) return
|
|
376
|
-
const desc = Object.getOwnPropertyDescriptor(MediaQueryList.prototype, 'matches')
|
|
377
|
-
if (!desc || typeof desc.get !== 'function') return
|
|
378
|
-
const wantDark = scheme === 'dark'
|
|
379
|
-
Object.defineProperty(theme.media, 'matches', {
|
|
380
|
-
configurable: true,
|
|
381
|
-
get() { return wantDark }
|
|
382
|
-
})
|
|
383
|
-
theme.publish()
|
|
384
|
-
} catch { /* 主题服务异常:忽略,等待下次通知 */ }
|
|
385
|
-
}
|
|
545
|
+
// 主题服务可能在本插件之后才就绪,且 preference 等于默认值时不会发
|
|
546
|
+
// theme/change:用重试梯子保证初始状态一定送达(成功或 6 秒后停止)。
|
|
547
|
+
const disposeTimer = retryLadder(schedulePost, {})
|
|
548
|
+
ctx.effect(() => disposeTimer, 'dsh-tauri-launcher: theme relay timer')
|
|
549
|
+
}
|
|
386
550
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
551
|
+
/* ---------- 桌面端系统主题跟随(外壳 → DSH) ---------- */
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* WebView2 页面里的 prefers-color-scheme 只有宿主显式设置
|
|
555
|
+
* PreferredColorScheme 才会随 Windows 变化,而 tauri/wry 未暴露该能力
|
|
556
|
+
* (wry#806),DSH 的 `system` 偏好会被冻结在启动时的取值。启动器轮询
|
|
557
|
+
* 到系统主题变化后经外壳 postMessage 转发到这里:偏好为 `system` 时把
|
|
558
|
+
* ThemeRuntime 持有的 MediaQueryList.matches(冻结值)覆盖为真实系统值
|
|
559
|
+
* 并触发重发布——偏好保持「跟随系统」不变,界面随真实系统主题切换;
|
|
560
|
+
* 用户手动选了固定主题时不干预。
|
|
561
|
+
* ⚠️ 兼容层 hack:依赖 ThemeRuntime 的 media/publish 运行时成员(无真
|
|
562
|
+
* 私有化),外壳升级若重命名则静默退化为现状(跟随失效,无副作用)。
|
|
563
|
+
*/
|
|
564
|
+
function installSystemThemeFollow(ctx, parentOrigin) {
|
|
565
|
+
const applySystemTheme = (scheme) => {
|
|
566
|
+
try {
|
|
567
|
+
const theme = ctx.get('theme')
|
|
568
|
+
if (!theme || !(theme.media instanceof MediaQueryList)) return
|
|
569
|
+
if (typeof theme.publish !== 'function') return
|
|
570
|
+
const snap = typeof theme.getTheme === 'function' ? theme.getTheme() : null
|
|
571
|
+
if (!snap || snap.preference !== 'system') return
|
|
572
|
+
if (snap.active && snap.active.colorScheme === scheme) return
|
|
573
|
+
const desc = Object.getOwnPropertyDescriptor(MediaQueryList.prototype, 'matches')
|
|
574
|
+
if (!desc || typeof desc.get !== 'function') return
|
|
575
|
+
const wantDark = scheme === 'dark'
|
|
576
|
+
Object.defineProperty(theme.media, 'matches', {
|
|
577
|
+
configurable: true,
|
|
578
|
+
get() { return wantDark }
|
|
579
|
+
})
|
|
580
|
+
theme.publish()
|
|
581
|
+
} catch { /* 主题服务异常:忽略,等待下次通知 */ }
|
|
582
|
+
}
|
|
396
583
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
584
|
+
const onSystemThemeMessage = (event) => {
|
|
585
|
+
const data = event.data || {}
|
|
586
|
+
if (!isShellMessage(event, parentOrigin)) return
|
|
587
|
+
const scheme = data.__tbSystemTheme
|
|
588
|
+
if (scheme === 'light' || scheme === 'dark') applySystemTheme(scheme)
|
|
589
|
+
}
|
|
590
|
+
window.addEventListener('message', onSystemThemeMessage)
|
|
591
|
+
ctx.effect(() => () => window.removeEventListener('message', onSystemThemeMessage), 'dsh-tauri-launcher: system theme follow')
|
|
592
|
+
}
|
|
403
593
|
|
|
404
|
-
|
|
405
|
-
let backStack = []
|
|
406
|
-
let forwardStack = []
|
|
407
|
-
let currentId = undefined
|
|
408
|
-
let currentAddress = undefined
|
|
409
|
-
let pendingJump = null
|
|
410
|
-
let jumpTimer = null
|
|
411
|
-
|
|
412
|
-
const snapCurrent = () => {
|
|
413
|
-
const snap = store.getSnapshot()
|
|
414
|
-
return { id: snap.current, address: snap.currentAddress }
|
|
415
|
-
}
|
|
594
|
+
/* ---------- 会话导航(应用层会话栈,等价浏览器前进/后退) ---------- */
|
|
416
595
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
window.parent.postMessage(payload, '*')
|
|
424
|
-
} catch { /* 忽略 */ }
|
|
425
|
-
}
|
|
596
|
+
/**
|
|
597
|
+
* sessions 服务存在就绪时序:一次性 ctx.get 可能拿到 undefined(静默失效),
|
|
598
|
+
* 故用重试梯子初始化;外壳每 3 秒 ping 一次,状态周期性回报实现自愈。
|
|
599
|
+
*/
|
|
600
|
+
function installSessionNav(ctx, parentOrigin) {
|
|
601
|
+
let navDispose = null
|
|
426
602
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
603
|
+
const initNav = () => {
|
|
604
|
+
const sessions = ctx.get('sessions')
|
|
605
|
+
const store = sessions && sessions.list
|
|
606
|
+
if (!store || typeof store.subscribe !== 'function') {
|
|
607
|
+
return false
|
|
608
|
+
}
|
|
433
609
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
clearTimeout(jumpTimer)
|
|
439
|
-
jumpTimer = setTimeout(() => {
|
|
440
|
-
pendingJump = null
|
|
441
|
-
const snap = snapCurrent()
|
|
442
|
-
currentId = snap.id
|
|
443
|
-
currentAddress = snap.address
|
|
444
|
-
sendStatus()
|
|
445
|
-
}, 1500)
|
|
446
|
-
try {
|
|
447
|
-
if (entry.address) sessions.openSubagent(entry.address)
|
|
448
|
-
else sessions.open(entry.id)
|
|
449
|
-
} catch {
|
|
450
|
-
pendingJump = null
|
|
451
|
-
}
|
|
452
|
-
}
|
|
610
|
+
const snapCurrent = () => {
|
|
611
|
+
const snap = store.getSnapshot()
|
|
612
|
+
return { id: snap.current, address: snap.currentAddress }
|
|
613
|
+
}
|
|
453
614
|
|
|
454
|
-
|
|
455
|
-
if (backStack.length === 0) return
|
|
456
|
-
const target = backStack.pop()
|
|
457
|
-
if (currentId !== undefined) pushStack(forwardStack, { id: currentId, address: currentAddress })
|
|
458
|
-
sendStatus()
|
|
459
|
-
jumpTo(target)
|
|
460
|
-
}
|
|
615
|
+
const nav = createSessionNavStack()
|
|
461
616
|
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
617
|
+
const sendStatus = () => {
|
|
618
|
+
if (window.parent === window) return
|
|
619
|
+
const status = { back: nav.status().back, forward: nav.status().forward }
|
|
620
|
+
try {
|
|
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
|
+
)
|
|
628
|
+
} catch { /* 忽略 */ }
|
|
629
|
+
}
|
|
469
630
|
|
|
470
|
-
|
|
631
|
+
const jumpTo = (entry) => {
|
|
632
|
+
nav.jumpTo(entry, () => {
|
|
633
|
+
// 超时兜底解锁:读最新快照同步当前会话并上报。
|
|
471
634
|
const snap = snapCurrent()
|
|
472
|
-
|
|
473
|
-
if (snap.id === pendingJump.id || snap.id !== undefined && snap.id !== currentId) {
|
|
474
|
-
pendingJump = null
|
|
475
|
-
clearTimeout(jumpTimer)
|
|
476
|
-
currentId = snap.id
|
|
477
|
-
currentAddress = snap.address
|
|
478
|
-
sendStatus()
|
|
479
|
-
}
|
|
480
|
-
return
|
|
481
|
-
}
|
|
482
|
-
if (snap.id === undefined) {
|
|
483
|
-
currentAddress = undefined
|
|
484
|
-
sendStatus()
|
|
485
|
-
return
|
|
486
|
-
}
|
|
487
|
-
if (snap.id === currentId) {
|
|
488
|
-
if (snap.address !== undefined) currentAddress = snap.address
|
|
489
|
-
return
|
|
490
|
-
}
|
|
491
|
-
if (currentId !== undefined) {
|
|
492
|
-
pushStack(backStack, { id: currentId, address: currentAddress })
|
|
493
|
-
forwardStack = []
|
|
494
|
-
}
|
|
495
|
-
currentId = snap.id
|
|
496
|
-
currentAddress = snap.address
|
|
635
|
+
nav.init(snap.id, snap.address)
|
|
497
636
|
sendStatus()
|
|
637
|
+
})
|
|
638
|
+
try {
|
|
639
|
+
if (entry.address) sessions.openSubagent(entry.address)
|
|
640
|
+
else sessions.open(entry.id)
|
|
641
|
+
} catch {
|
|
642
|
+
nav.abortJump()
|
|
498
643
|
}
|
|
644
|
+
}
|
|
499
645
|
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
else if (dir === 'forward') goForward()
|
|
507
|
-
else if (dir === 'ping') sendStatus()
|
|
508
|
-
}
|
|
509
|
-
window.addEventListener('message', onNavMessage)
|
|
510
|
-
|
|
511
|
-
const onKeydown = (event) => {
|
|
512
|
-
if (!event.altKey || event.ctrlKey || event.shiftKey) return
|
|
513
|
-
if (event.key === 'ArrowLeft') {
|
|
514
|
-
event.preventDefault()
|
|
515
|
-
goBack()
|
|
516
|
-
} else if (event.key === 'ArrowRight') {
|
|
517
|
-
event.preventDefault()
|
|
518
|
-
goForward()
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
|
-
window.addEventListener('keydown', onKeydown, true)
|
|
522
|
-
|
|
523
|
-
// 鼠标侧键:DOM button 3 = 后退(XButton1)、4 = 前进(XButton2)
|
|
524
|
-
const onMouseup = (event) => {
|
|
525
|
-
if (event.button === 3) {
|
|
526
|
-
event.preventDefault()
|
|
527
|
-
goBack()
|
|
528
|
-
} else if (event.button === 4) {
|
|
529
|
-
event.preventDefault()
|
|
530
|
-
goForward()
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
window.addEventListener('mouseup', onMouseup, true)
|
|
646
|
+
const goBack = () => {
|
|
647
|
+
const target = nav.goBack()
|
|
648
|
+
if (!target) return
|
|
649
|
+
sendStatus()
|
|
650
|
+
jumpTo(target)
|
|
651
|
+
}
|
|
534
652
|
|
|
535
|
-
|
|
536
|
-
const
|
|
537
|
-
|
|
538
|
-
currentAddress = init.address
|
|
653
|
+
const goForward = () => {
|
|
654
|
+
const target = nav.goForward()
|
|
655
|
+
if (!target) return
|
|
539
656
|
sendStatus()
|
|
657
|
+
jumpTo(target)
|
|
658
|
+
}
|
|
540
659
|
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
window.removeEventListener('mouseup', onMouseup, true)
|
|
660
|
+
const onListChange = () => {
|
|
661
|
+
const snap = snapCurrent()
|
|
662
|
+
if (nav.isPending()) {
|
|
663
|
+
if (nav.onPendingChange(snap)) sendStatus()
|
|
664
|
+
return
|
|
547
665
|
}
|
|
548
|
-
|
|
666
|
+
if (nav.onExternalChange(snap)) sendStatus()
|
|
549
667
|
}
|
|
550
668
|
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
669
|
+
const onNavMessage = (event) => {
|
|
670
|
+
const data = event.data || {}
|
|
671
|
+
if (!isShellMessage(event, parentOrigin)) return
|
|
672
|
+
const dir = data.__tbNav
|
|
673
|
+
if (dir === 'back') goBack()
|
|
674
|
+
else if (dir === 'forward') goForward()
|
|
675
|
+
else if (dir === 'ping') sendStatus()
|
|
676
|
+
}
|
|
677
|
+
window.addEventListener('message', onNavMessage)
|
|
678
|
+
|
|
679
|
+
const onKeydown = (event) => {
|
|
680
|
+
if (!event.altKey || event.ctrlKey || event.shiftKey) return
|
|
681
|
+
if (event.key === 'ArrowLeft') {
|
|
682
|
+
event.preventDefault()
|
|
683
|
+
goBack()
|
|
684
|
+
} else if (event.key === 'ArrowRight') {
|
|
685
|
+
event.preventDefault()
|
|
686
|
+
goForward()
|
|
561
687
|
}
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
688
|
+
}
|
|
689
|
+
window.addEventListener('keydown', onKeydown, true)
|
|
690
|
+
|
|
691
|
+
// 鼠标侧键:DOM button 3 = 后退(XButton1)、4 = 前进(XButton2)
|
|
692
|
+
const onMouseup = (event) => {
|
|
693
|
+
if (event.button === 3) {
|
|
694
|
+
event.preventDefault()
|
|
695
|
+
goBack()
|
|
696
|
+
} else if (event.button === 4) {
|
|
697
|
+
event.preventDefault()
|
|
698
|
+
goForward()
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
window.addEventListener('mouseup', onMouseup, true)
|
|
702
|
+
|
|
703
|
+
const unsubscribe = store.subscribe(onListChange)
|
|
704
|
+
const init = snapCurrent()
|
|
705
|
+
nav.init(init.id, init.address)
|
|
706
|
+
sendStatus()
|
|
707
|
+
|
|
708
|
+
navDispose = () => {
|
|
709
|
+
if (typeof unsubscribe === 'function') unsubscribe()
|
|
710
|
+
nav.dispose()
|
|
711
|
+
window.removeEventListener('message', onNavMessage)
|
|
712
|
+
window.removeEventListener('keydown', onKeydown, true)
|
|
713
|
+
window.removeEventListener('mouseup', onMouseup, true)
|
|
714
|
+
}
|
|
715
|
+
return true
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// 立即尝试一次,再走重试梯子(服务可能在之后才就绪)
|
|
719
|
+
const disposeTimer = retryLadder(initNav, { immediate: true })
|
|
720
|
+
|
|
721
|
+
ctx.effect(() => () => {
|
|
722
|
+
disposeTimer()
|
|
723
|
+
if (navDispose) navDispose()
|
|
724
|
+
}, 'dsh-tauri-launcher: session nav')
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* 父窗口 origin:跨源读不到 window.parent.location,用 document.referrer
|
|
729
|
+
* 推导。**注意 referrer 不可靠**:DSH 0.1.5 + WebView2 实测,iframe 经
|
|
730
|
+
* token 303 握手后 `document.referrer` 为空,推导结果退化为兜底值,
|
|
731
|
+
* 与外壳实际 origin 不符——因此校验一律走 [`isShellMessage`] 的白名单。
|
|
732
|
+
*/
|
|
733
|
+
function deriveParentOrigin() {
|
|
734
|
+
try {
|
|
735
|
+
if (document.referrer) return new URL(document.referrer).origin
|
|
736
|
+
} catch { /* 兜底见 isShellMessage 的白名单 */ }
|
|
737
|
+
return 'http://tauri.localhost'
|
|
738
|
+
}
|
|
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
|
+
|
|
759
|
+
exports.apply = function (ctx) {
|
|
760
|
+
const slots = ctx.get('slots')
|
|
761
|
+
if (slots === undefined) return
|
|
762
|
+
|
|
763
|
+
installDesktopSection(ctx, slots)
|
|
764
|
+
|
|
765
|
+
// 主题中继内部自带 window.parent 检查(浏览器直开自动不启用)。
|
|
766
|
+
installThemeRelay(ctx)
|
|
767
|
+
|
|
768
|
+
// 以下功能仅在 iframe 内嵌(桌面端)时有意义;浏览器直开时
|
|
769
|
+
// Chromium 原生跟随系统主题、且无标题栏,直接跳过。
|
|
770
|
+
if (window.parent !== window) {
|
|
771
|
+
const parentOrigin = deriveParentOrigin()
|
|
772
|
+
installSystemThemeFollow(ctx, parentOrigin)
|
|
773
|
+
installSessionNav(ctx, parentOrigin)
|
|
571
774
|
}
|
|
572
775
|
}
|
|
573
776
|
|
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 =
|
|
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
|
|
117
|
-
if (
|
|
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
|
-
|
|
160
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
if (
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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))
|