@luoshuizhiwei/dsh-desktop-restart 0.1.0
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/LICENSE +21 -0
- package/README.md +124 -0
- package/cordis.patch.yml +11 -0
- package/lib/client.js +265 -0
- package/lib/helper.cjs +229 -0
- package/lib/index.js +285 -0
- package/package.json +60 -0
- package/scripts/smoke.mjs +265 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 luoshuizhiwei
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# dsh-desktop-restart
|
|
2
|
+
|
|
3
|
+
给 **DeepSeek Harness 桌面版(Electron)** 用的重启插件:在 设置 → 通用 里加一行按钮,点一下重启整个应用。
|
|
4
|
+
|
|
5
|
+
和插件市场里那类「重启 dsh web」的插件不同 —— 那些在桌面版里不能用,原因见下。
|
|
6
|
+
|
|
7
|
+
## 为什么需要它
|
|
8
|
+
|
|
9
|
+
桌面版的进程模型和终端里的 `dsh web` 完全不同:
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
DeepSeek Harness.exe ← Electron 主进程:窗口、托盘、自动更新
|
|
13
|
+
└── DeepSeek Harness.exe ← host 子进程(同一个 exe + ELECTRON_RUN_AS_NODE=1)
|
|
14
|
+
└── 本插件 跑 dsh-desktop-host,监听 127.0.0.1:19387
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
于是两件事同时成立:
|
|
18
|
+
|
|
19
|
+
1. **host 自己退出 ≠ 重启应用。** 主进程把子进程退出当成故障,弹「启动失败」恢复框(默认按钮是「重启应用」)——能用,但每次都要人点一下。
|
|
20
|
+
2. **主进程没给插件留重启通道。** 完整的 IPC 只有 `shutdown` / `quit-inspection` / `update-tasks`(主进程 → host)与 `ready` / `fatal` / `shutdown-complete` / `platform-session`(host → 主进程);渲染进程侧的 `DESKTOP_IPC` 表里也没有 restart。内置的「重启应用与 Host」菜单项只在 development 下加入菜单,而 Windows 上应用菜单栏只有 devTools,托盘菜单只有「打开应用」和「退出」。
|
|
21
|
+
|
|
22
|
+
所以一键重启只能由**宿主之外**的进程完成。
|
|
23
|
+
|
|
24
|
+
## 它怎么工作
|
|
25
|
+
|
|
26
|
+
1. 设置页那一行向本插件自己的宿主路由发一个同源 POST。
|
|
27
|
+
2. 宿主半校验请求、写下交接描述,然后用 `process.execPath`(就是桌面应用本身)+ `ELECTRON_RUN_AS_NODE=1` 拉起一个**分离的 helper**,并立刻回 202。helper 因此不依赖系统 Node,也不依赖 `dsh` 在 PATH 里。
|
|
28
|
+
3. helper 等 1.5 秒让响应先送达浏览器,然后结束 **Electron 主进程** —— 故意不带 `/T`:host 子进程必须活下来,才能在 IPC 断开后走自己的优雅关闭(`application.shutdown`),把会话日志正常落盘。带 `/T` 会把 host 一起强杀,那才会真丢数据。
|
|
29
|
+
4. helper 等主进程消失,再等监听端口释放(用「连接」探测,不用「绑定」—— 试探性绑定恰恰会占住替代进程要用的那个端口)。
|
|
30
|
+
5. 端口迟迟不放时兜底:按映像名清掉残留的桌面进程(helper 自己排除在外)。
|
|
31
|
+
6. helper 用干净的环境拉起新实例 —— 必须去掉 `ELECTRON_RUN_AS_NODE`,否则新的「应用」会以 Node 模式启动,变成一个没有窗口的进程。
|
|
32
|
+
|
|
33
|
+
## 安装
|
|
34
|
+
|
|
35
|
+
### 从 npm
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
dsh plugin add @luoshuizhiwei/dsh-desktop-restart
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### 从源码
|
|
42
|
+
|
|
43
|
+
包放在 `~/.dsh/local-plugins/dsh-desktop-restart`,通过 profile 清单挂载:
|
|
44
|
+
|
|
45
|
+
```jsonc
|
|
46
|
+
// ~/.dsh/profiles/desktop/package.json
|
|
47
|
+
{
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"@luoshuizhiwei/dsh-desktop-restart": "link:../../local-plugins/dsh-desktop-restart"
|
|
50
|
+
},
|
|
51
|
+
"dsh": { "profile": { "bundles": [ "...", "@luoshuizhiwei/dsh-desktop-restart" ] } }
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
在较早的桌面版上,`desktop` profile 由 Electron 应用独占管理,`dsh plugin --profile desktop add` 会被拒绝(`profile "desktop" is managed exclusively by the Electron application`),此时需要手工声明依赖与 bundle;`node_modules` 下的对应项是一个指向源码目录的 junction。
|
|
56
|
+
|
|
57
|
+
装好后**重启一次应用**才会加载 —— 这第一次得手动(插件还没跑起来)。
|
|
58
|
+
|
|
59
|
+
## 使用
|
|
60
|
+
|
|
61
|
+
三个入口,都走同一条受守卫的路径:
|
|
62
|
+
|
|
63
|
+
1. **会话标题栏按钮** —— 会话页抬头右侧的「⟳ 重启」,常驻可见,最顺手;
|
|
64
|
+
2. **斜杠命令** —— 在输入框里打 `/restart-desktop` 回车;
|
|
65
|
+
3. **设置 → 通用 → 重启桌面应用** —— 备用入口。
|
|
66
|
+
|
|
67
|
+
按钮类入口是两步确认:第一次点击武装(`确认重启?`),5 秒内第二次点击才执行;斜杠命令直接执行。随后页面断开,应用重启,页面在新进程就绪后自行恢复。
|
|
68
|
+
|
|
69
|
+
## 安全模型
|
|
70
|
+
|
|
71
|
+
`POST /dsh-desktop-restart/api/restart` 在动手之前完成全部校验:
|
|
72
|
+
|
|
73
|
+
| 检查 | 拒绝方式 |
|
|
74
|
+
| --- | --- |
|
|
75
|
+
| 方法不是 POST | `405`,带 `Allow: POST` |
|
|
76
|
+
| TCP 对端不是回环地址 | `403` |
|
|
77
|
+
| `Host` 不是回环名字 | `403` |
|
|
78
|
+
| 带 `forwarded` / `x-forwarded-for` / `x-real-ip` | `403` |
|
|
79
|
+
| 无 `Origin` 时 `Sec-Fetch-Site` 不是 `same-origin` | `403` |
|
|
80
|
+
| 有 `Origin` 但其 host 不等于 `Host` | `403` |
|
|
81
|
+
| 当前宿主不是 Electron 桌面版 | `409` |
|
|
82
|
+
| `config.enabled` 为 false | `403` |
|
|
83
|
+
| 已有一次重启在交接中 | `409` |
|
|
84
|
+
|
|
85
|
+
**为什么无 `Origin` 也放行**:桌面版的页面以 `dsh-app://` 协议装载,页面里的 `fetch('/…')` 由壳层转发到宿主端口,这种转发的请求**不带 `Origin`**。若直接要求 `Origin`,桌面版里这个按钮会永远返回 403 —— 这也正是插件市场里 `dsh-simple-restart` 在桌面版失效的原因之一。所以无 `Origin` 时改为要求其余每一条都成立(回环对端、回环 Host、无代理痕迹、`Sec-Fetch-Site` 缺省或 `same-origin`)。
|
|
86
|
+
|
|
87
|
+
## 已知限制
|
|
88
|
+
|
|
89
|
+
- **只适用于 Electron 桌面版。** 在 `dsh web` 上状态路由会如实报 `desktop: false`,重启按钮禁用;这是设计如此,不是故障。
|
|
90
|
+
- **强杀主进程。** 主进程自身的清理(窗口状态、托盘)不会执行。真正要紧的会话数据在 host 进程里,它会优雅关闭。这是「一键」必须付的代价 —— 主进程没暴露任何可请求的重启通道。
|
|
91
|
+
- **DSH 升级后可能失效。** 实现依赖当前的进程模型与 `ELECTRON_RUN_AS_NODE` 约定;若上游改变 host 的启动方式或给主进程加上重启 IPC,应改用官方通道。
|
|
92
|
+
- **只在 Windows 上验证过。**
|
|
93
|
+
|
|
94
|
+
## 诊断
|
|
95
|
+
|
|
96
|
+
每次重启的交接描述与 helper 日志写在 `~/.dsh/desktop-restart/`:
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
restart-<时间戳>.log helper 的完整诊断
|
|
100
|
+
handoff-<时间戳>.json 这次交接的目标与参数
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
正常的一次重启,日志形如:
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
helper started: helper=… host=… main=… port=19387 exe=…\DeepSeek Harness.exe
|
|
107
|
+
ending the Electron main process … without /T, so the host can close gracefully
|
|
108
|
+
taskkill /PID … /F ok
|
|
109
|
+
main process exited
|
|
110
|
+
port 19387 released
|
|
111
|
+
relaunched "…\DeepSeek Harness.exe" as pid …
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## 开发
|
|
115
|
+
|
|
116
|
+
```sh
|
|
117
|
+
node scripts/smoke.mjs
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
离线冒烟测试:宿主半用桩上下文加载,只验证路由守卫与全部拒绝路径;helper 只走「映像名不匹配」的拒绝分支。**它不会重启任何东西。**
|
|
121
|
+
|
|
122
|
+
## 许可证
|
|
123
|
+
|
|
124
|
+
MIT
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# dsh-desktop-restart bundle patch —— 挂载 host 半。
|
|
2
|
+
#
|
|
3
|
+
# client 半不需要在这里登记:它经 package.json 的 dsh.client 清单被
|
|
4
|
+
# client-modules 扫描发现(platform: web,./client 导出)。
|
|
5
|
+
#
|
|
6
|
+
# 一行,一次挂载。不要在 profile 自己的 cordis.patch.yml 里再 insert 同一个
|
|
7
|
+
# id,否则 boot 会因重复的 loader entry id 失败。
|
|
8
|
+
- insert:
|
|
9
|
+
- id: dsh-desktop-restart
|
|
10
|
+
name: "@luoshuizhiwei/dsh-desktop-restart"
|
|
11
|
+
config: {}
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client half of dsh-desktop-restart.
|
|
3
|
+
*
|
|
4
|
+
* 提供两个入口,都走宿主半同一条受守卫的重启路径:
|
|
5
|
+
*
|
|
6
|
+
* 1. **会话标题栏按钮**(`conversation.session.header.actions`)—— 常驻可见,
|
|
7
|
+
* 抬头就能按;
|
|
8
|
+
* 2. **设置 → 通用 那一行** —— 留作备用入口。
|
|
9
|
+
*
|
|
10
|
+
* 另外宿主半还注册了 `/restart-desktop` 斜杠命令,在输入框里直接打字即可。
|
|
11
|
+
*
|
|
12
|
+
* 和「重启 dsh web」那类插件不同,这里要重启的是 Electron 应用本身,因为
|
|
13
|
+
* 桌面版里 host 只是主进程的一个子进程 —— host 自己退出只会换来一个
|
|
14
|
+
* 「启动失败」恢复框,而主进程没有给插件留任何重启通道。宿主半因此把交接
|
|
15
|
+
* 交给一个分离的 helper(见 lib/helper.cjs)。
|
|
16
|
+
*
|
|
17
|
+
* 本文件是包里的 `./client` bundle,格式与所有 web 插件一致:交给
|
|
18
|
+
* `window.__ModuleLoader__` 的工厂形式,无 import,`require("react")` 由
|
|
19
|
+
* 打包器提供,导出 plain-Cordis 插件 `{ name, inject, apply }`。
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
window.__ModuleLoader__.load({
|
|
23
|
+
id: "dsh-desktop-restart",
|
|
24
|
+
factory: (require) => {
|
|
25
|
+
var module = { exports: {} };
|
|
26
|
+
var exports = module.exports;
|
|
27
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
28
|
+
|
|
29
|
+
const React = require("react");
|
|
30
|
+
|
|
31
|
+
/** 本包自己的两条宿主路由。 */
|
|
32
|
+
const RESTART_ROUTE = "/dsh-desktop-restart/api/restart";
|
|
33
|
+
const STATUS_ROUTE = "/dsh-desktop-restart/api/status";
|
|
34
|
+
/** 第一次点击「武装」按钮的有效期。 */
|
|
35
|
+
const CONFIRM_MS = 5000;
|
|
36
|
+
/** 会话标题栏的加性座位。 */
|
|
37
|
+
const HEADER_SLOT = "conversation.session.header.actions";
|
|
38
|
+
/** 通用设置页里加性一行的座位。 */
|
|
39
|
+
const SETTINGS_SLOT = "settings.general.item";
|
|
40
|
+
/** 本插件在这两个座位里的 key。 */
|
|
41
|
+
const ID = "dsh-desktop-restart";
|
|
42
|
+
|
|
43
|
+
const FOCUS_RING = { outline: "2px solid var(--dsw-alias-interactive-bg-hover-accent, rgba(77,107,254,.75))", outlineOffset: "-1px" };
|
|
44
|
+
|
|
45
|
+
/** 通用设置区自带的行的配方(对齐 Language / Permission 这类内置行)。 */
|
|
46
|
+
const S = {
|
|
47
|
+
row: { display: "flex", alignItems: "center", gap: "8px", padding: "16px 0", borderBottom: ".5px solid var(--dsw-alias-border-l2, rgba(127,127,127,.2))" },
|
|
48
|
+
text: { display: "flex", flexDirection: "column", flex: 1, gap: "4px", minWidth: 0, paddingRight: "48px" },
|
|
49
|
+
label: { color: "var(--dsw-alias-label-primary, inherit)", fontSize: "14px", fontWeight: 400, lineHeight: "22px" },
|
|
50
|
+
hint: { fontSize: "12px", lineHeight: "18px", color: "var(--dsw-alias-label-tertiary, rgba(140,140,150,1))" },
|
|
51
|
+
hintError: { fontSize: "12px", lineHeight: "18px", color: "var(--dsw-alias-state-error-primary, #f85149)" },
|
|
52
|
+
hintOk: { fontSize: "12px", lineHeight: "18px", color: "var(--dsw-alias-state-success-primary, #3fb950)" },
|
|
53
|
+
btn: { display: "inline-flex", alignItems: "center", gap: "12px", flex: "none", boxSizing: "border-box", height: "36px", padding: "0 14px", border: "none", borderRadius: "18px", background: "var(--dsw-alias-bg-module-platform, var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.12)))", color: "var(--dsw-alias-label-primary, inherit)", font: "inherit", fontSize: "14px", lineHeight: "22px", cursor: "pointer", transition: "background .12s ease, color .12s ease" },
|
|
54
|
+
btnHover: { background: "var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.18))" },
|
|
55
|
+
btnDanger: { color: "var(--dsw-alias-state-error-primary, #f85149)" },
|
|
56
|
+
btnDangerHover: { background: "var(--dsw-alias-interactive-bg-hover-danger, rgba(248,81,73,.14))" },
|
|
57
|
+
btnBusy: { opacity: .6, cursor: "default" },
|
|
58
|
+
btnDisabled: { opacity: .45, cursor: "not-allowed" },
|
|
59
|
+
// 标题栏是紧凑的横排,按钮因此做成小胶囊,不用设置页那种 36px 控件。
|
|
60
|
+
headerBtn: { display: "inline-flex", alignItems: "center", gap: "5px", flex: "none", boxSizing: "border-box", height: "26px", padding: "0 9px", border: "none", borderRadius: "13px", background: "transparent", color: "var(--dsw-alias-label-secondary, inherit)", font: "inherit", fontSize: "12px", lineHeight: "18px", cursor: "pointer", whiteSpace: "nowrap", transition: "background .12s ease, color .12s ease" },
|
|
61
|
+
headerBtnHover: { background: "var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.18))", color: "var(--dsw-alias-label-primary, inherit)" },
|
|
62
|
+
headerBtnDanger: { background: "var(--dsw-alias-interactive-bg-hover-danger, rgba(248,81,73,.14))", color: "var(--dsw-alias-state-error-primary, #f85149)" },
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** 默认提示文案:说明这一行和「重启 dsh web」的区别。 */
|
|
66
|
+
const DEFAULT_HINT = "重启整个 DeepSeek Harness(桌面应用与它的 host 进程)。改插件或配置后需要重启才生效时用这个。";
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 问一次宿主:这是不是 Electron 桌面版。
|
|
70
|
+
* @returns {Promise<{ desktop: boolean }>} 状态。
|
|
71
|
+
*/
|
|
72
|
+
async function fetchStatus() {
|
|
73
|
+
try {
|
|
74
|
+
const response = await fetch(STATUS_ROUTE, { method: "GET" });
|
|
75
|
+
const body = await response.json().catch(() => null);
|
|
76
|
+
if (response.ok && body) return { desktop: body.desktop === true };
|
|
77
|
+
return { desktop: false };
|
|
78
|
+
} catch {
|
|
79
|
+
return { desktop: false };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 请宿主排程一次重启。
|
|
85
|
+
* @returns {Promise<{ ok: boolean, text: string }>} 要显示的结果。
|
|
86
|
+
*/
|
|
87
|
+
async function requestRestart() {
|
|
88
|
+
let response;
|
|
89
|
+
try {
|
|
90
|
+
response = await fetch(RESTART_ROUTE, { method: "POST" });
|
|
91
|
+
} catch (error) {
|
|
92
|
+
return { ok: false, text: "请求失败:" + String((error && error.message) || error) };
|
|
93
|
+
}
|
|
94
|
+
const body = await response.json().catch(() => null);
|
|
95
|
+
if (response.ok && body && body.ok === true) {
|
|
96
|
+
return {
|
|
97
|
+
ok: true,
|
|
98
|
+
text: "已排程重启:宿主 pid " + String(body.hostPid) + ",主进程 pid " + String(body.mainPid) + ",交接 helper " + String(body.helperPid) + "。本页即将断开,稍后会自动回来。",
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return { ok: false, text: String((body && body.error) || ("HTTP " + response.status)) };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 两个入口共用的重启动作:挂载时问一次是否桌面版,第一次按下武装,
|
|
106
|
+
* 第二次按下才真的重启。
|
|
107
|
+
* @param {object | undefined} timer - 客户端 timer 服务,用于让武装态过期。
|
|
108
|
+
* @returns {{ phase: string, message: string, desktop: boolean | null, press: Function }} 动作状态。
|
|
109
|
+
*/
|
|
110
|
+
function useRestart(timer) {
|
|
111
|
+
const phasePair = React.useState("idle");
|
|
112
|
+
const phase = phasePair[0];
|
|
113
|
+
const setPhase = phasePair[1];
|
|
114
|
+
const messagePair = React.useState("");
|
|
115
|
+
const message = messagePair[0];
|
|
116
|
+
const setMessage = messagePair[1];
|
|
117
|
+
const desktopPair = React.useState(null);
|
|
118
|
+
const desktop = desktopPair[0];
|
|
119
|
+
const setDesktop = desktopPair[1];
|
|
120
|
+
|
|
121
|
+
React.useEffect(() => {
|
|
122
|
+
let live = true;
|
|
123
|
+
fetchStatus().then((status) => {
|
|
124
|
+
if (live) setDesktop(status.desktop);
|
|
125
|
+
}, () => {
|
|
126
|
+
if (live) setDesktop(false);
|
|
127
|
+
});
|
|
128
|
+
return () => { live = false; };
|
|
129
|
+
}, []);
|
|
130
|
+
|
|
131
|
+
const press = () => {
|
|
132
|
+
if (phase === "restarting" || desktop === false) return;
|
|
133
|
+
if (phase !== "confirm") {
|
|
134
|
+
setPhase("confirm");
|
|
135
|
+
if (timer !== undefined && typeof timer.timeout === "function") {
|
|
136
|
+
timer.timeout(() => setPhase((current) => (current === "confirm" ? "idle" : current)), CONFIRM_MS);
|
|
137
|
+
}
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
setPhase("restarting");
|
|
141
|
+
setMessage("");
|
|
142
|
+
requestRestart().then((result) => {
|
|
143
|
+
setPhase(result.ok ? "done" : "error");
|
|
144
|
+
setMessage(result.text);
|
|
145
|
+
}, (failure) => {
|
|
146
|
+
setPhase("error");
|
|
147
|
+
setMessage(String((failure && failure.message) || failure));
|
|
148
|
+
});
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
return { phase, message, desktop, press };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 插件主体:注册会话标题栏按钮与设置页那一行,都挂在本插件自己的
|
|
156
|
+
* fiber 上,卸载时随之移除。
|
|
157
|
+
* @param {object} ctx - 客户端 Cordis 上下文。
|
|
158
|
+
*/
|
|
159
|
+
function apply(ctx) {
|
|
160
|
+
const slots = ctx.get("slots");
|
|
161
|
+
if (slots === undefined) return;
|
|
162
|
+
const timer = ctx.get("timer");
|
|
163
|
+
|
|
164
|
+
/** 会话标题栏上的常驻按钮:抬头就能按。 */
|
|
165
|
+
function HeaderRestartButton() {
|
|
166
|
+
const action = useRestart(timer);
|
|
167
|
+
const hoverPair = React.useState(false);
|
|
168
|
+
const hovered = hoverPair[0];
|
|
169
|
+
const setHovered = hoverPair[1];
|
|
170
|
+
const focusPair = React.useState(false);
|
|
171
|
+
const focused = focusPair[0];
|
|
172
|
+
const setFocused = focusPair[1];
|
|
173
|
+
|
|
174
|
+
const unavailable = action.desktop === false;
|
|
175
|
+
const busy = action.phase === "restarting";
|
|
176
|
+
const danger = action.phase === "confirm";
|
|
177
|
+
const style = Object.assign({}, S.headerBtn,
|
|
178
|
+
danger ? S.headerBtnDanger : null,
|
|
179
|
+
busy ? S.btnBusy : null,
|
|
180
|
+
unavailable ? S.btnDisabled : null,
|
|
181
|
+
focused ? FOCUS_RING : null,
|
|
182
|
+
(!busy && !unavailable && !danger && hovered) ? S.headerBtnHover : null);
|
|
183
|
+
const label = busy ? "重启中…" : (danger ? "确认重启?" : (action.phase === "error" ? "重试" : "重启"));
|
|
184
|
+
const title = action.message !== ""
|
|
185
|
+
? action.message
|
|
186
|
+
: (unavailable ? "仅 Electron 桌面版可用" : "重启整个 DeepSeek Harness 桌面应用");
|
|
187
|
+
|
|
188
|
+
return React.createElement("button", {
|
|
189
|
+
type: "button",
|
|
190
|
+
style: style,
|
|
191
|
+
disabled: busy || unavailable,
|
|
192
|
+
title: title,
|
|
193
|
+
onClick: action.press,
|
|
194
|
+
onMouseEnter: () => setHovered(true),
|
|
195
|
+
onMouseLeave: () => setHovered(false),
|
|
196
|
+
onFocus: () => setFocused(true),
|
|
197
|
+
onBlur: () => setFocused(false),
|
|
198
|
+
}, React.createElement("span", { "aria-hidden": "true", style: { fontSize: "13px", lineHeight: 1 } }, "⟳"), label);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** 设置页那一行:标题、说明和两步确认的重启按钮。 */
|
|
202
|
+
function SettingsRestartRow() {
|
|
203
|
+
const action = useRestart(timer);
|
|
204
|
+
const hoverPair = React.useState(false);
|
|
205
|
+
const hovered = hoverPair[0];
|
|
206
|
+
const setHovered = hoverPair[1];
|
|
207
|
+
const focusPair = React.useState(false);
|
|
208
|
+
const focused = focusPair[0];
|
|
209
|
+
const setFocused = focusPair[1];
|
|
210
|
+
|
|
211
|
+
const unavailable = action.desktop === false;
|
|
212
|
+
const busy = action.phase === "restarting";
|
|
213
|
+
const danger = action.phase === "confirm";
|
|
214
|
+
const style = Object.assign({}, S.btn,
|
|
215
|
+
danger ? S.btnDanger : null,
|
|
216
|
+
busy ? S.btnBusy : null,
|
|
217
|
+
unavailable ? S.btnDisabled : null,
|
|
218
|
+
focused ? FOCUS_RING : null,
|
|
219
|
+
(!busy && !unavailable && hovered) ? (danger ? S.btnDangerHover : S.btnHover) : null);
|
|
220
|
+
const hintStyle = action.phase === "error" ? S.hintError : (action.phase === "done" ? S.hintOk : S.hint);
|
|
221
|
+
const hint = busy
|
|
222
|
+
? "正在交接重启…本页马上会断开。"
|
|
223
|
+
: (action.message !== ""
|
|
224
|
+
? action.message
|
|
225
|
+
: (unavailable
|
|
226
|
+
? "当前宿主不是 Electron 桌面版,这一行只在桌面应用里可用。"
|
|
227
|
+
: DEFAULT_HINT));
|
|
228
|
+
const label = busy ? "重启中…" : (danger ? "确认重启?" : (action.phase === "error" ? "重试" : "重启"));
|
|
229
|
+
|
|
230
|
+
return React.createElement("div", { style: S.row },
|
|
231
|
+
React.createElement("div", { style: S.text },
|
|
232
|
+
React.createElement("span", { style: S.label }, "重启桌面应用"),
|
|
233
|
+
React.createElement("span", { style: hintStyle }, hint)),
|
|
234
|
+
React.createElement("button", {
|
|
235
|
+
type: "button",
|
|
236
|
+
style: style,
|
|
237
|
+
disabled: busy || unavailable,
|
|
238
|
+
title: unavailable ? "仅 Electron 桌面版可用" : "结束 Electron 主进程并拉起新的应用实例(仅同源 loopback 可用)",
|
|
239
|
+
onClick: action.press,
|
|
240
|
+
onMouseEnter: () => setHovered(true),
|
|
241
|
+
onMouseLeave: () => setHovered(false),
|
|
242
|
+
onFocus: () => setFocused(true),
|
|
243
|
+
onBlur: () => setFocused(false),
|
|
244
|
+
}, label));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// 会话标题栏:常驻可见的主入口。
|
|
248
|
+
ctx.effect(() => slots.inject(HEADER_SLOT, () => slots.register({
|
|
249
|
+
name: HEADER_SLOT,
|
|
250
|
+
id: ID,
|
|
251
|
+
order: 60,
|
|
252
|
+
}, HeaderRestartButton)));
|
|
253
|
+
|
|
254
|
+
// 设置 → 通用:备用入口。
|
|
255
|
+
ctx.effect(() => slots.inject(SETTINGS_SLOT, () => slots.register({
|
|
256
|
+
name: SETTINGS_SLOT,
|
|
257
|
+
id: ID,
|
|
258
|
+
order: 91,
|
|
259
|
+
}, SettingsRestartRow)));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
module.exports = { name: ID, inject: ["slots"], apply };
|
|
263
|
+
return module.exports;
|
|
264
|
+
}
|
|
265
|
+
});
|
package/lib/helper.cjs
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* dsh-desktop-restart — 分离 helper。
|
|
5
|
+
*
|
|
6
|
+
* 由 host 半用「桌面应用本身 + ELECTRON_RUN_AS_NODE=1」启动,因此不依赖系统
|
|
7
|
+
* Node,也不依赖 `dsh` 在 PATH 里。它是交接期间唯一能活过宿主的东西,所以
|
|
8
|
+
* 整件事由它做完:
|
|
9
|
+
*
|
|
10
|
+
* 1. 等一小会儿,让 host 的 HTTP 响应先送达浏览器;
|
|
11
|
+
* 2. 结束 Electron 主进程 —— 故意不带 /T:host 子进程必须活下来,才能在
|
|
12
|
+
* IPC 断开后走自己的优雅关闭(`application.shutdown`),把会话日志
|
|
13
|
+
* 正常落盘。带 /T 会把 host 一起强杀,那才是真的会丢数据;
|
|
14
|
+
* 3. 等主进程消失,再等监听端口释放(用「连接」探测,不用「绑定」——
|
|
15
|
+
* 试探性绑定恰恰会占住替代进程要用的那个端口);
|
|
16
|
+
* 4. 端口迟迟不放时兜底:按映像名清掉残留的桌面进程(helper 自己排除在外);
|
|
17
|
+
* 5. 用干净的环境拉起新实例 —— 必须去掉 ELECTRON_RUN_AS_NODE,否则新的
|
|
18
|
+
* 「应用」会以 Node 模式启动,变成一个没有窗口的进程。
|
|
19
|
+
*
|
|
20
|
+
* 用法:node helper.cjs <handoff.json>
|
|
21
|
+
* 所有诊断写入 handoff 里指定的 logPath;这个进程的 stdio 是 ignore。
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const { execFileSync, spawn } = require('node:child_process')
|
|
25
|
+
const fs = require('node:fs')
|
|
26
|
+
const net = require('node:net')
|
|
27
|
+
const path = require('node:path')
|
|
28
|
+
|
|
29
|
+
/** 主进程消失的等待上限。 */
|
|
30
|
+
const MAIN_EXIT_TIMEOUT_MS = 15000
|
|
31
|
+
/** 端口释放的等待上限。 */
|
|
32
|
+
const PORT_FREE_TIMEOUT_MS = 30000
|
|
33
|
+
/** 默认的交接前等待。 */
|
|
34
|
+
const DEFAULT_DELAY_MS = 1500
|
|
35
|
+
|
|
36
|
+
const handoffPath = process.argv[2]
|
|
37
|
+
if (typeof handoffPath !== 'string' || handoffPath === '') {
|
|
38
|
+
process.exit(1)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let handoff
|
|
42
|
+
try {
|
|
43
|
+
handoff = JSON.parse(fs.readFileSync(handoffPath, 'utf8'))
|
|
44
|
+
} catch {
|
|
45
|
+
process.exit(1)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const mainPid = Number(handoff.mainPid)
|
|
49
|
+
const exe = String(handoff.exe ?? '')
|
|
50
|
+
const hostPid = Number(handoff.hostPid)
|
|
51
|
+
const port = Number(handoff.port)
|
|
52
|
+
const logPath = String(handoff.logPath ?? '')
|
|
53
|
+
const delayMs = Number(handoff.delayMs) > 0 ? Number(handoff.delayMs) : DEFAULT_DELAY_MS
|
|
54
|
+
|
|
55
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
56
|
+
|
|
57
|
+
/** 追加一行诊断。日志不可写时静默——诊断失败不该拖垮重启本身。 */
|
|
58
|
+
function note(line) {
|
|
59
|
+
try {
|
|
60
|
+
fs.appendFileSync(logPath, `[${new Date().toISOString()}] ${line}\n`)
|
|
61
|
+
} catch {
|
|
62
|
+
/* 没有日志也要继续 */
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 进程是否还活着(信号 0 只做存在性检查)。 */
|
|
67
|
+
function alive(pid) {
|
|
68
|
+
try {
|
|
69
|
+
process.kill(pid, 0)
|
|
70
|
+
return true
|
|
71
|
+
} catch {
|
|
72
|
+
return false
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 端口上是否有人监听。
|
|
78
|
+
* @param {number} p - 端口。
|
|
79
|
+
* @returns {Promise<boolean>} 有人应答时为 true。
|
|
80
|
+
*/
|
|
81
|
+
function listening(p) {
|
|
82
|
+
return new Promise((resolve) => {
|
|
83
|
+
let settled = false
|
|
84
|
+
const probe = net.connect({ host: '127.0.0.1', port: p })
|
|
85
|
+
const finish = (value) => {
|
|
86
|
+
if (settled) return
|
|
87
|
+
settled = true
|
|
88
|
+
probe.destroy()
|
|
89
|
+
resolve(value)
|
|
90
|
+
}
|
|
91
|
+
probe.on('connect', () => finish(true))
|
|
92
|
+
probe.on('error', () => finish(false))
|
|
93
|
+
probe.setTimeout(500, () => finish(false))
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 强杀一个 pid。
|
|
99
|
+
* @param {number} pid - 目标。
|
|
100
|
+
* @returns {boolean} taskkill 是否成功。
|
|
101
|
+
*/
|
|
102
|
+
function killPid(pid) {
|
|
103
|
+
try {
|
|
104
|
+
execFileSync('taskkill', ['/PID', String(pid), '/F'], { stdio: 'ignore', windowsHide: true })
|
|
105
|
+
return true
|
|
106
|
+
} catch {
|
|
107
|
+
return false
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 解析 tasklist 的 CSV 输出。只取 `"名字","pid"` 这种 ASCII 结构,
|
|
113
|
+
* 因此中文系统上的提示行不会干扰解析。
|
|
114
|
+
* @param {string} args - 传给 tasklist 的参数。
|
|
115
|
+
* @returns {{ image: string, pid: number }[]} 进程列表。
|
|
116
|
+
*/
|
|
117
|
+
function tasklist(args) {
|
|
118
|
+
try {
|
|
119
|
+
const out = execFileSync('tasklist', args, { encoding: 'utf8', windowsHide: true })
|
|
120
|
+
return [...out.matchAll(/"([^"]*)","(\d+)"/gu)]
|
|
121
|
+
.map((match) => ({ image: match[1], pid: Number(match[2]) }))
|
|
122
|
+
.filter((entry) => Number.isInteger(entry.pid) && entry.pid > 0)
|
|
123
|
+
} catch {
|
|
124
|
+
return []
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 目标 pid 当前的映像名,用于确认它确实是我们要结束的那个进程。
|
|
130
|
+
* @param {number} pid - 目标。
|
|
131
|
+
* @returns {string | null} 映像名,或 null。
|
|
132
|
+
*/
|
|
133
|
+
function imageNameOf(pid) {
|
|
134
|
+
const found = tasklist(['/FI', `PID eq ${String(pid)}`, '/FO', 'CSV', '/NH'])
|
|
135
|
+
.find((entry) => entry.pid === pid)
|
|
136
|
+
return found === undefined ? null : found.image
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 当前所有桌面应用进程(含 helper 自己)。 */
|
|
140
|
+
function applicationPids() {
|
|
141
|
+
if (exe === '') return []
|
|
142
|
+
return tasklist(['/FI', `IMAGENAME eq ${path.basename(exe)}`, '/FO', 'CSV', '/NH'])
|
|
143
|
+
.map((entry) => entry.pid)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* 交接并重启。
|
|
148
|
+
* @returns {Promise<void>} 完成(无论成功或已记录失败)。
|
|
149
|
+
*/
|
|
150
|
+
async function main() {
|
|
151
|
+
note(`helper started: helper=${process.pid} host=${hostPid} main=${mainPid} port=${port} exe=${exe}`)
|
|
152
|
+
|
|
153
|
+
if (!Number.isInteger(mainPid) || mainPid <= 1) {
|
|
154
|
+
note('refusing: the handoff carries no usable main process id')
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
if (exe === '') {
|
|
158
|
+
note('refusing: the handoff carries no application executable')
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// 1. 先让浏览器拿到「已排程重启」的响应。
|
|
163
|
+
await sleep(delayMs)
|
|
164
|
+
|
|
165
|
+
// 2. 确认目标身份,然后结束主进程(不带 /T)。
|
|
166
|
+
const expected = path.basename(exe).toLowerCase()
|
|
167
|
+
const image = imageNameOf(mainPid)
|
|
168
|
+
if (image !== null && image.toLowerCase() !== expected) {
|
|
169
|
+
note(`refusing: pid ${mainPid} is "${image}", not "${expected}"`)
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
if (image === null) {
|
|
173
|
+
note(`main process ${mainPid} is already gone; skipping the kill`)
|
|
174
|
+
} else {
|
|
175
|
+
note(`ending the Electron main process ${mainPid} ("${image}") without /T, so the host can close gracefully`)
|
|
176
|
+
note(killPid(mainPid) ? `taskkill /PID ${mainPid} /F ok` : `taskkill /PID ${mainPid} /F reported an error`)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 3. 等主进程消失。
|
|
180
|
+
const goneBy = Date.now() + MAIN_EXIT_TIMEOUT_MS
|
|
181
|
+
while (Date.now() < goneBy && alive(mainPid)) await sleep(200)
|
|
182
|
+
note(alive(mainPid) ? `main process still alive after ${MAIN_EXIT_TIMEOUT_MS}ms` : 'main process exited')
|
|
183
|
+
|
|
184
|
+
// 4. 等端口释放;迟迟不放就兜底清理残留进程。
|
|
185
|
+
if (Number.isInteger(port) && port > 0) {
|
|
186
|
+
const freeBy = Date.now() + PORT_FREE_TIMEOUT_MS
|
|
187
|
+
while (Date.now() < freeBy && await listening(port)) await sleep(300)
|
|
188
|
+
if (await listening(port)) {
|
|
189
|
+
note(`port ${port} is still held; terminating leftover application processes`)
|
|
190
|
+
for (const pid of applicationPids()) {
|
|
191
|
+
if (pid === process.pid || pid === mainPid) continue
|
|
192
|
+
note(`killing leftover pid ${pid}`)
|
|
193
|
+
killPid(pid)
|
|
194
|
+
}
|
|
195
|
+
await sleep(1500)
|
|
196
|
+
} else {
|
|
197
|
+
note(`port ${port} released`)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 5. 给文件系统和端口一点收尾时间,然后拉起新实例。
|
|
202
|
+
await sleep(800)
|
|
203
|
+
|
|
204
|
+
const env = { ...process.env }
|
|
205
|
+
for (const key of Object.keys(env)) {
|
|
206
|
+
const upper = key.toUpperCase()
|
|
207
|
+
// Node 模式下这两个变量会跟着继承;不清掉,新实例会变成一个没有窗口的进程。
|
|
208
|
+
if (upper === 'ELECTRON_RUN_AS_NODE' || upper === 'DSH_DESKTOP_NODE_EXECUTABLE') delete env[key]
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
const child = spawn(exe, [], {
|
|
213
|
+
detached: true,
|
|
214
|
+
stdio: 'ignore',
|
|
215
|
+
windowsHide: false,
|
|
216
|
+
cwd: path.dirname(exe),
|
|
217
|
+
env,
|
|
218
|
+
})
|
|
219
|
+
child.on('error', (error) => note(`relaunch failed: ${String((error && error.message) || error)}`))
|
|
220
|
+
child.unref()
|
|
221
|
+
note(`relaunched "${exe}" as pid ${String(child.pid)}`)
|
|
222
|
+
} catch (error) {
|
|
223
|
+
note(`relaunch threw: ${String((error && error.stack) || error)}`)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
main().catch((error) => {
|
|
228
|
+
note(`helper failed: ${String((error && error.stack) || error)}`)
|
|
229
|
+
})
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-desktop-restart — Host half.
|
|
3
|
+
*
|
|
4
|
+
* 桌面版(Electron)的进程模型和 `dsh web` 完全不同:
|
|
5
|
+
*
|
|
6
|
+
* DeepSeek Harness.exe ← Electron 主进程:窗口、托盘、更新、spawn host
|
|
7
|
+
* └── DeepSeek Harness.exe ← host 子进程:同一个 exe,ELECTRON_RUN_AS_NODE=1,
|
|
8
|
+
* (ELECTRON_RUN_AS_NODE=1) 跑 dsh-desktop-host,监听 127.0.0.1:19387
|
|
9
|
+
* └── 本插件就在这里
|
|
10
|
+
*
|
|
11
|
+
* 这带来两个后果,它们决定了本插件为什么长这样:
|
|
12
|
+
*
|
|
13
|
+
* 1. host 自己退出并不能重启应用。主进程会把子进程退出当成故障,弹出
|
|
14
|
+
* 「启动失败」恢复框(默认按钮是「重启应用」)——能用,但每次都要人点一下。
|
|
15
|
+
*
|
|
16
|
+
* 2. 主进程没有暴露任何「重启」通道给插件。完整的 IPC 表只有
|
|
17
|
+
* shutdown / quit-inspection / update-tasks(主进程 → host),
|
|
18
|
+
* 以及 ready / fatal / shutdown-complete / platform-session(host → 主进程);
|
|
19
|
+
* 渲染进程侧的 DESKTOP_IPC 里也没有 restart。内置的「重启应用与 Host」
|
|
20
|
+
* 菜单项只在 development 下加入菜单,而 Windows 上应用菜单栏只有 devTools,
|
|
21
|
+
* 托盘菜单只有「打开应用」和「退出」。
|
|
22
|
+
*
|
|
23
|
+
* 所以真正的一键重启只能由宿主之外的进程完成:本半只做校验、写一份交接描述,
|
|
24
|
+
* 然后把工作交给一个分离的 helper(见 lib/helper.cjs),由它结束 Electron
|
|
25
|
+
* 主进程 —— host 会因 IPC 断开而自行优雅关闭(会话日志正常落盘)—— 等端口
|
|
26
|
+
* 释放后,用干净的启动环境拉起新的应用实例。
|
|
27
|
+
*
|
|
28
|
+
* 安全模型:路由只接受同源 loopback 的 POST(带转发头一律拒绝),非桌面宿主
|
|
29
|
+
* 直接拒绝并说明原因,同一时刻只允许一次重启交接。
|
|
30
|
+
*
|
|
31
|
+
* @module dsh-desktop-restart
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { spawn } from 'node:child_process'
|
|
35
|
+
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
36
|
+
import { homedir } from 'node:os'
|
|
37
|
+
import { basename, dirname, join } from 'node:path'
|
|
38
|
+
import { fileURLToPath } from 'node:url'
|
|
39
|
+
|
|
40
|
+
/** 没有 web carrier 就没有路由:这是唯一的硬依赖。 */
|
|
41
|
+
export const inject = ['webServer']
|
|
42
|
+
|
|
43
|
+
/** 本插件拥有的两个路由。 */
|
|
44
|
+
const ROUTE_RESTART = '/dsh-desktop-restart/api/restart'
|
|
45
|
+
const ROUTE_STATUS = '/dsh-desktop-restart/api/status'
|
|
46
|
+
|
|
47
|
+
/** 分离 helper 的绝对路径。 */
|
|
48
|
+
const HELPER_PATH = fileURLToPath(new URL('./helper.cjs', import.meta.url))
|
|
49
|
+
|
|
50
|
+
/** 交接描述与 helper 日志的落盘位置(与 DSH 自己的 home 保持一致)。 */
|
|
51
|
+
const DSH_HOME = process.env.DSH_HOME ?? join(homedir(), '.dsh')
|
|
52
|
+
const STATE_DIR = join(DSH_HOME, 'desktop-restart')
|
|
53
|
+
|
|
54
|
+
/** helper 在动手前的等待时间,让 HTTP 响应先送达浏览器。 */
|
|
55
|
+
const HANDOFF_DELAY_MS = 1500
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 判断当前 host 是否跑在 Electron 桌面壳下,并取出重启所需的目标。
|
|
59
|
+
*
|
|
60
|
+
* 判据全部来自进程本身,不依赖任何环境变量约定:
|
|
61
|
+
* - `ELECTRON_RUN_AS_NODE=1`:说明这个「node」其实是一个 Electron 可执行文件;
|
|
62
|
+
* - `process.execPath` 的 basename 不是 node:排除普通的 Node 宿主;
|
|
63
|
+
* - `process.ppid` 是主进程:桌面壳 spawn host 时就是父子关系。
|
|
64
|
+
*
|
|
65
|
+
* 任何一条不成立都返回 null,插件于是只注册状态路由、拒绝重启,绝不误杀进程。
|
|
66
|
+
* @returns {{ mainPid: number, exe: string, hostPid: number } | null} 重启目标,或 null。
|
|
67
|
+
*/
|
|
68
|
+
function desktopTarget() {
|
|
69
|
+
if (process.env.ELECTRON_RUN_AS_NODE !== '1') return null
|
|
70
|
+
const exe = process.execPath
|
|
71
|
+
if (typeof exe !== 'string' || exe === '') return null
|
|
72
|
+
const name = basename(exe).toLowerCase()
|
|
73
|
+
if (name === 'node' || name === 'node.exe') return null
|
|
74
|
+
const mainPid = process.ppid
|
|
75
|
+
if (!Number.isInteger(mainPid) || mainPid <= 1) return null
|
|
76
|
+
return { mainPid, exe, hostPid: process.pid }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Host 头是否指向回环地址(含 IPv6 字面量写法)。 */
|
|
80
|
+
function isLoopbackHost(host) {
|
|
81
|
+
const name = host.startsWith('[') ? host.slice(0, host.indexOf(']') + 1) : host.split(':')[0]
|
|
82
|
+
return name === '127.0.0.1' || name === 'localhost' || name === '[::1]' || name === '::1'
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** TCP 对端是否就是本机。 */
|
|
86
|
+
function loopbackPeer(req) {
|
|
87
|
+
const address = req.socket.remoteAddress
|
|
88
|
+
return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1'
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 桌面壳转发过来的请求是否可以信任。
|
|
93
|
+
*
|
|
94
|
+
* 桌面版的页面是以 `dsh-app://` 协议装载的,页面里的 `fetch('/…')` 由壳层
|
|
95
|
+
* 转发到 host 的 HTTP 端口 —— 这种转发的请求**没有 Origin 头**。所以无
|
|
96
|
+
* Origin 不能直接判为不可信,而是要求其余每一条都成立:没有代理痕迹、
|
|
97
|
+
* Host 是回环名字、TCP 对端是回环地址,且 `Sec-Fetch-Site` 缺省或就是
|
|
98
|
+
* same-origin(跨站请求会被浏览器标成 cross-site,据此挡掉)。
|
|
99
|
+
* @param {import('node:http').IncomingMessage} req - 请求。
|
|
100
|
+
* @returns {boolean} 可信任时为 true。
|
|
101
|
+
*/
|
|
102
|
+
function desktopForwarded(req) {
|
|
103
|
+
const site = req.headers['sec-fetch-site']
|
|
104
|
+
if (site !== undefined && site !== 'same-origin') return false
|
|
105
|
+
if (req.headers.forwarded !== undefined
|
|
106
|
+
|| req.headers['x-forwarded-for'] !== undefined
|
|
107
|
+
|| req.headers['x-real-ip'] !== undefined) return false
|
|
108
|
+
const host = req.headers.host
|
|
109
|
+
if (host === undefined || !isLoopbackHost(host)) return false
|
|
110
|
+
return loopbackPeer(req)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 请求是否来自可信的调用方。
|
|
115
|
+
*
|
|
116
|
+
* 两种可信形态:浏览器直接发来的同源请求(Origin 正好是本次请求的
|
|
117
|
+
* authority),或桌面壳转发来的请求(见 {@link desktopForwarded})。
|
|
118
|
+
* 两者都要求 TCP 对端是回环地址。
|
|
119
|
+
* @param {import('node:http').IncomingMessage} req - 请求。
|
|
120
|
+
* @returns {boolean} 可信时为 true。
|
|
121
|
+
*/
|
|
122
|
+
function trustedRequest(req) {
|
|
123
|
+
if (!loopbackPeer(req)) return false
|
|
124
|
+
const host = req.headers.host
|
|
125
|
+
if (host === undefined) return false
|
|
126
|
+
const origin = req.headers.origin
|
|
127
|
+
if (origin === undefined) return desktopForwarded(req)
|
|
128
|
+
try {
|
|
129
|
+
const parsed = new URL(origin)
|
|
130
|
+
return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && parsed.host === host
|
|
131
|
+
} catch {
|
|
132
|
+
return false
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 写交接描述,并拉起分离的 helper。
|
|
138
|
+
*
|
|
139
|
+
* helper 用 `process.execPath`(就是桌面应用本身)加 `ELECTRON_RUN_AS_NODE=1`
|
|
140
|
+
* 启动,因此不依赖系统 Node、也不依赖 `dsh` 在 PATH 里。
|
|
141
|
+
* @param {{ mainPid: number, exe: string, hostPid: number }} target - 重启目标。
|
|
142
|
+
* @param {number | null} port - host 正在监听的端口,helper 会等它释放。
|
|
143
|
+
* @returns {{ hostPid: number, mainPid: number, helperPid: number | undefined, logPath: string }} 交接结果。
|
|
144
|
+
*/
|
|
145
|
+
function scheduleRestart(target, port) {
|
|
146
|
+
mkdirSync(STATE_DIR, { recursive: true })
|
|
147
|
+
const stamp = new Date().toISOString().replace(/[:.]/gu, '-').slice(0, 19)
|
|
148
|
+
const logPath = join(STATE_DIR, `restart-${stamp}.log`)
|
|
149
|
+
const handoffPath = join(STATE_DIR, `handoff-${stamp}.json`)
|
|
150
|
+
writeFileSync(handoffPath, JSON.stringify({
|
|
151
|
+
mainPid: target.mainPid,
|
|
152
|
+
exe: target.exe,
|
|
153
|
+
hostPid: target.hostPid,
|
|
154
|
+
port: port ?? null,
|
|
155
|
+
logPath,
|
|
156
|
+
handoffPath,
|
|
157
|
+
delayMs: HANDOFF_DELAY_MS,
|
|
158
|
+
startedAt: new Date().toISOString(),
|
|
159
|
+
}, null, 2), 'utf8')
|
|
160
|
+
|
|
161
|
+
const helper = spawn(process.execPath, [HELPER_PATH, handoffPath], {
|
|
162
|
+
detached: true,
|
|
163
|
+
stdio: 'ignore',
|
|
164
|
+
windowsHide: true,
|
|
165
|
+
cwd: dirname(HELPER_PATH),
|
|
166
|
+
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
|
|
167
|
+
})
|
|
168
|
+
helper.unref()
|
|
169
|
+
return {
|
|
170
|
+
hostPid: target.hostPid,
|
|
171
|
+
mainPid: target.mainPid,
|
|
172
|
+
helperPid: helper.pid,
|
|
173
|
+
logPath,
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* 挂载插件:一条状态路由 + 一条受守卫的重启路由。
|
|
179
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx - host 上下文。
|
|
180
|
+
* @param {{ enabled?: boolean }} [config] - 本插件那一行的 config。
|
|
181
|
+
*/
|
|
182
|
+
export function apply(ctx, config = {}) {
|
|
183
|
+
const target = desktopTarget()
|
|
184
|
+
const enabled = config.enabled !== false
|
|
185
|
+
let scheduled = false
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* 排程一次重启。路由与斜杠命令共用这一条路径,因此守卫只需要维护一份。
|
|
189
|
+
* @returns {{ status: number, body: Record<string, unknown> }} 给调用方的结果。
|
|
190
|
+
*/
|
|
191
|
+
function scheduleOnce() {
|
|
192
|
+
if (!enabled) {
|
|
193
|
+
return { status: 403, body: { ok: false, error: '本插件那一行的 config.enabled 为 false,重启已停用' } }
|
|
194
|
+
}
|
|
195
|
+
if (target === null) {
|
|
196
|
+
return {
|
|
197
|
+
status: 409,
|
|
198
|
+
body: { ok: false, error: '当前宿主不是 Electron 桌面版;本插件只负责重启桌面应用,dsh web 请用别的重启方式' },
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (scheduled) {
|
|
202
|
+
return { status: 409, body: { ok: false, error: '已经有一次重启在交接中' } }
|
|
203
|
+
}
|
|
204
|
+
scheduled = true
|
|
205
|
+
try {
|
|
206
|
+
const result = scheduleRestart(target, ctx.webServer.port ?? null)
|
|
207
|
+
try {
|
|
208
|
+
ctx.logger('dsh-desktop-restart').info(
|
|
209
|
+
'restart scheduled: host pid %d, main pid %d, helper pid %s',
|
|
210
|
+
result.hostPid, result.mainPid, String(result.helperPid),
|
|
211
|
+
)
|
|
212
|
+
} catch {
|
|
213
|
+
/* 日志不可用不影响交接 */
|
|
214
|
+
}
|
|
215
|
+
return { status: 202, body: { ok: true, ...result } }
|
|
216
|
+
} catch (error) {
|
|
217
|
+
scheduled = false
|
|
218
|
+
return { status: 500, body: { ok: false, error: String((error && error.message) || error) } }
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
ctx.effect(() => ctx.webServer.register({
|
|
223
|
+
kind: 'exact',
|
|
224
|
+
path: ROUTE_STATUS,
|
|
225
|
+
handler: (req, res) => {
|
|
226
|
+
const send = (status, body) => {
|
|
227
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
228
|
+
res.end(JSON.stringify(body))
|
|
229
|
+
}
|
|
230
|
+
if (req.method !== 'GET') {
|
|
231
|
+
res.writeHead(405, { allow: 'GET' })
|
|
232
|
+
res.end()
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
send(200, {
|
|
236
|
+
desktop: target !== null,
|
|
237
|
+
enabled,
|
|
238
|
+
hostPid: process.pid,
|
|
239
|
+
mainPid: target?.mainPid ?? null,
|
|
240
|
+
exe: target?.exe ?? null,
|
|
241
|
+
})
|
|
242
|
+
},
|
|
243
|
+
}), 'dsh-desktop-restart: status route')
|
|
244
|
+
|
|
245
|
+
ctx.effect(() => ctx.webServer.register({
|
|
246
|
+
kind: 'exact',
|
|
247
|
+
path: ROUTE_RESTART,
|
|
248
|
+
handler: (req, res) => {
|
|
249
|
+
const send = (status, body) => {
|
|
250
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
251
|
+
res.end(JSON.stringify(body))
|
|
252
|
+
}
|
|
253
|
+
if (req.method !== 'POST') {
|
|
254
|
+
res.writeHead(405, { allow: 'POST' })
|
|
255
|
+
res.end()
|
|
256
|
+
return
|
|
257
|
+
}
|
|
258
|
+
if (!trustedRequest(req)) {
|
|
259
|
+
send(403, { ok: false, error: '重启只接受同源 loopback 请求' })
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
const { status, body } = scheduleOnce()
|
|
263
|
+
send(status, body)
|
|
264
|
+
},
|
|
265
|
+
}), 'dsh-desktop-restart: restart route')
|
|
266
|
+
|
|
267
|
+
// 斜杠命令:在输入框里打 /restart-desktop 就能重启,不必翻到设置页。
|
|
268
|
+
// commands 是可选的 —— 没有它时插件照常提供上面两条路由。
|
|
269
|
+
ctx.inject(['commands'], (commandCtx) => {
|
|
270
|
+
commandCtx.effect(() => commandCtx.commands.register({
|
|
271
|
+
name: 'restart-desktop',
|
|
272
|
+
description: '重启整个 DeepSeek Harness 桌面应用(Electron 应用与它的 host 进程)',
|
|
273
|
+
handler: () => {
|
|
274
|
+
const { status, body } = scheduleOnce()
|
|
275
|
+
if (body.ok === true) {
|
|
276
|
+
return {
|
|
277
|
+
kind: 'success',
|
|
278
|
+
text: `已排程重启:宿主 pid ${String(body.hostPid)},主进程 pid ${String(body.mainPid)}。窗口会关闭,随后自动打开。`,
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return { kind: 'error', text: String(body.error ?? `重启被拒绝(HTTP ${String(status)})`) }
|
|
282
|
+
},
|
|
283
|
+
}), 'dsh-desktop-restart: /restart-desktop command')
|
|
284
|
+
})
|
|
285
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@luoshuizhiwei/dsh-desktop-restart",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "DeepSeek Harness 桌面版重启插件:会话标题栏按钮 / 斜杠命令 / 设置页入口,经分离的 helper 重启整个 Electron 应用(而不是只重启 dsh web 进程)。",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"dsh",
|
|
7
|
+
"dsh-plugin",
|
|
8
|
+
"deepseek-harness",
|
|
9
|
+
"restart",
|
|
10
|
+
"desktop",
|
|
11
|
+
"electron"
|
|
12
|
+
],
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "lib/index.js",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"default": "./lib/index.js"
|
|
18
|
+
},
|
|
19
|
+
"./client": {
|
|
20
|
+
"default": "./lib/client.js"
|
|
21
|
+
},
|
|
22
|
+
"./package.json": "./package.json"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"lib",
|
|
26
|
+
"scripts",
|
|
27
|
+
"cordis.patch.yml",
|
|
28
|
+
"README.md"
|
|
29
|
+
],
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=20",
|
|
32
|
+
"dsh": ">=0.2.0-rc.1"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"test": "node scripts/smoke.mjs",
|
|
36
|
+
"prepublishOnly": "npm test"
|
|
37
|
+
},
|
|
38
|
+
"dsh": {
|
|
39
|
+
"bundle": {
|
|
40
|
+
"patch": "./cordis.patch.yml"
|
|
41
|
+
},
|
|
42
|
+
"client": {
|
|
43
|
+
"inject": [
|
|
44
|
+
"@deepseek-ai/dsh-client-ui-conversation",
|
|
45
|
+
"@deepseek-ai/dsh-client-ui-settings"
|
|
46
|
+
],
|
|
47
|
+
"platform": "web"
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"license": "MIT",
|
|
51
|
+
"author": "luoshuizhiwei",
|
|
52
|
+
"repository": {
|
|
53
|
+
"type": "git",
|
|
54
|
+
"url": "git+https://github.com/luoshuizhiwei/dsh-desktop-restart.git"
|
|
55
|
+
},
|
|
56
|
+
"bugs": {
|
|
57
|
+
"url": "https://github.com/luoshuizhiwei/dsh-desktop-restart/issues"
|
|
58
|
+
},
|
|
59
|
+
"homepage": "https://github.com/luoshuizhiwei/dsh-desktop-restart#readme"
|
|
60
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-desktop-restart 的离线冒烟测试。
|
|
3
|
+
*
|
|
4
|
+
* 刻意不重启任何东西:
|
|
5
|
+
* - 宿主半用桩上下文加载,只验证路由的守卫与拒绝路径;
|
|
6
|
+
* - helper 只走「映像名不匹配」的拒绝路径,绝不会走到 taskkill / spawn。
|
|
7
|
+
*
|
|
8
|
+
* 运行:node scripts/smoke.mjs
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import assert from 'node:assert/strict'
|
|
12
|
+
import { spawnSync } from 'node:child_process'
|
|
13
|
+
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
|
14
|
+
import { tmpdir } from 'node:os'
|
|
15
|
+
import { join } from 'node:path'
|
|
16
|
+
import { fileURLToPath } from 'node:url'
|
|
17
|
+
|
|
18
|
+
const libDir = fileURLToPath(new URL('../lib/', import.meta.url))
|
|
19
|
+
const helperPath = join(libDir, 'helper.cjs')
|
|
20
|
+
|
|
21
|
+
let passed = 0
|
|
22
|
+
/** 跑一个断言块并计数。 */
|
|
23
|
+
function ok(name, fn) {
|
|
24
|
+
fn()
|
|
25
|
+
passed += 1
|
|
26
|
+
console.log(' ok ' + name)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 一个最小可用的 node:http 响应桩。 */
|
|
30
|
+
function makeRes() {
|
|
31
|
+
const res = { statusCode: null, headers: null, body: '' }
|
|
32
|
+
res.writeHead = (code, headers) => {
|
|
33
|
+
res.statusCode = code
|
|
34
|
+
res.headers = headers ?? null
|
|
35
|
+
}
|
|
36
|
+
res.end = (chunk) => {
|
|
37
|
+
res.body = chunk === undefined ? '' : String(chunk)
|
|
38
|
+
}
|
|
39
|
+
return res
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 一个最小可用的请求桩,默认是可信的同源 loopback POST。 */
|
|
43
|
+
function makeReq(method, overrides = {}) {
|
|
44
|
+
return {
|
|
45
|
+
method,
|
|
46
|
+
socket: { remoteAddress: overrides.remoteAddress ?? '127.0.0.1' },
|
|
47
|
+
headers: {
|
|
48
|
+
origin: overrides.origin ?? 'http://127.0.0.1:19387',
|
|
49
|
+
host: overrides.host ?? '127.0.0.1:19387',
|
|
50
|
+
...(overrides.headers ?? {}),
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
console.log('host half')
|
|
56
|
+
|
|
57
|
+
const routes = new Map()
|
|
58
|
+
const commands = []
|
|
59
|
+
/** 桩:命令服务只记录注册,绝不执行任何调度。 */
|
|
60
|
+
const commandsService = {
|
|
61
|
+
register: (definition) => {
|
|
62
|
+
commands.push(definition)
|
|
63
|
+
return () => {}
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
const ctx = {
|
|
67
|
+
effect: (fn) => {
|
|
68
|
+
const dispose = fn()
|
|
69
|
+
return typeof dispose === 'function' ? dispose : undefined
|
|
70
|
+
},
|
|
71
|
+
webServer: {
|
|
72
|
+
port: 19387,
|
|
73
|
+
register: (spec) => {
|
|
74
|
+
routes.set(spec.path, spec)
|
|
75
|
+
return () => routes.delete(spec.path)
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
logger: () => ({ info() {}, warn() {}, error() {} }),
|
|
79
|
+
inject: (deps, cb) => {
|
|
80
|
+
if (deps.includes('commands')) {
|
|
81
|
+
cb({
|
|
82
|
+
effect: (fn) => {
|
|
83
|
+
const dispose = fn()
|
|
84
|
+
return typeof dispose === 'function' ? dispose : undefined
|
|
85
|
+
},
|
|
86
|
+
commands: commandsService,
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const hostHalf = await import(new URL('../lib/index.js', import.meta.url).href)
|
|
93
|
+
hostHalf.apply(ctx, {})
|
|
94
|
+
|
|
95
|
+
const statusRoute = routes.get('/dsh-desktop-restart/api/status')
|
|
96
|
+
const restartRoute = routes.get('/dsh-desktop-restart/api/restart')
|
|
97
|
+
|
|
98
|
+
ok('注册了状态与重启两条路由', () => {
|
|
99
|
+
assert.ok(statusRoute, '缺少状态路由')
|
|
100
|
+
assert.ok(restartRoute, '缺少重启路由')
|
|
101
|
+
assert.equal(routes.size, 2)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
ok('host 半声明 webServer 依赖', () => {
|
|
105
|
+
assert.deepEqual(hostHalf.inject, ['webServer'])
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
ok('斜杠命令:注册了 /restart-desktop', () => {
|
|
109
|
+
assert.equal(commands.length, 1, '应当恰好注册一个命令')
|
|
110
|
+
assert.equal(commands[0].name, 'restart-desktop')
|
|
111
|
+
assert.ok(commands[0].description.length > 0, '描述不能为空')
|
|
112
|
+
assert.equal(typeof commands[0].handler, 'function')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
ok('斜杠命令:非桌面宿主下返回 error 结果', () => {
|
|
116
|
+
const result = commands[0].handler({})
|
|
117
|
+
assert.equal(result.kind, 'error')
|
|
118
|
+
assert.match(result.text, /桌面版/u)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
ok('斜杠命令:命令名符合 DSH 的命名规则', () => {
|
|
122
|
+
assert.match(commands[0].name, /^[a-z][a-z0-9_-]*$/u)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
ok('状态路由:GET 返回 desktop=false(当前不是桌面宿主)', () => {
|
|
126
|
+
const res = makeRes()
|
|
127
|
+
statusRoute.handler(makeReq('GET'), res)
|
|
128
|
+
assert.equal(res.statusCode, 200)
|
|
129
|
+
const body = JSON.parse(res.body)
|
|
130
|
+
assert.equal(body.desktop, false)
|
|
131
|
+
assert.equal(body.enabled, true)
|
|
132
|
+
assert.equal(body.hostPid, process.pid)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
ok('状态路由:非 GET 返回 405', () => {
|
|
136
|
+
const res = makeRes()
|
|
137
|
+
statusRoute.handler(makeReq('POST'), res)
|
|
138
|
+
assert.equal(res.statusCode, 405)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
ok('重启路由:非 POST 返回 405', () => {
|
|
142
|
+
const res = makeRes()
|
|
143
|
+
restartRoute.handler(makeReq('GET'), res)
|
|
144
|
+
assert.equal(res.statusCode, 405)
|
|
145
|
+
assert.equal(res.headers.allow, 'POST')
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
ok('重启路由:非 loopback 来源返回 403', () => {
|
|
149
|
+
const res = makeRes()
|
|
150
|
+
restartRoute.handler(makeReq('POST', { remoteAddress: '10.1.2.3' }), res)
|
|
151
|
+
assert.equal(res.statusCode, 403)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
ok('重启路由:带 Origin 时以 Origin 为准(回环对端仍是硬前提)', () => {
|
|
155
|
+
const res = makeRes()
|
|
156
|
+
restartRoute.handler(makeReq('POST', { headers: { 'x-forwarded-for': '203.0.113.9' } }), res)
|
|
157
|
+
assert.equal(res.statusCode, 409)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
ok('重启路由:Origin 与 Host 不一致返回 403', () => {
|
|
161
|
+
const res = makeRes()
|
|
162
|
+
restartRoute.handler(makeReq('POST', { origin: 'http://evil.example' }), res)
|
|
163
|
+
assert.equal(res.statusCode, 403)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
ok('重启路由:桌面壳转发的无 Origin 请求被放行(走到非桌面判定)', () => {
|
|
167
|
+
const res = makeRes()
|
|
168
|
+
const req = makeReq('POST')
|
|
169
|
+
delete req.headers.origin
|
|
170
|
+
restartRoute.handler(req, res)
|
|
171
|
+
assert.equal(res.statusCode, 409)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
ok('重启路由:无 Origin 且带转发头返回 403', () => {
|
|
175
|
+
const res = makeRes()
|
|
176
|
+
const req = makeReq('POST', { headers: { 'x-forwarded-for': '203.0.113.9' } })
|
|
177
|
+
delete req.headers.origin
|
|
178
|
+
restartRoute.handler(req, res)
|
|
179
|
+
assert.equal(res.statusCode, 403)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
ok('重启路由:无 Origin 且 Sec-Fetch-Site 为 cross-site 返回 403', () => {
|
|
183
|
+
const res = makeRes()
|
|
184
|
+
const req = makeReq('POST', { headers: { 'sec-fetch-site': 'cross-site' } })
|
|
185
|
+
delete req.headers.origin
|
|
186
|
+
restartRoute.handler(req, res)
|
|
187
|
+
assert.equal(res.statusCode, 403)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
ok('重启路由:无 Origin 且 Host 非回环返回 403', () => {
|
|
191
|
+
const res = makeRes()
|
|
192
|
+
const req = makeReq('POST', { host: 'example.com' })
|
|
193
|
+
delete req.headers.origin
|
|
194
|
+
restartRoute.handler(req, res)
|
|
195
|
+
assert.equal(res.statusCode, 403)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
ok('重启路由:Origin 的 host 与 Host 一致时被放行', () => {
|
|
199
|
+
const res = makeRes()
|
|
200
|
+
restartRoute.handler(makeReq('POST', { origin: 'http://localhost:19387', host: 'localhost:19387' }), res)
|
|
201
|
+
assert.equal(res.statusCode, 409)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
ok('重启路由:可信但非桌面宿主返回 409 并说明原因', () => {
|
|
205
|
+
const res = makeRes()
|
|
206
|
+
restartRoute.handler(makeReq('POST'), res)
|
|
207
|
+
assert.equal(res.statusCode, 409)
|
|
208
|
+
assert.match(JSON.parse(res.body).error, /桌面版/u)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
ok('重启路由:config.enabled=false 时返回 403', () => {
|
|
212
|
+
const disabled = new Map()
|
|
213
|
+
const disabledCtx = {
|
|
214
|
+
...ctx,
|
|
215
|
+
webServer: {
|
|
216
|
+
port: 19387,
|
|
217
|
+
register: (spec) => {
|
|
218
|
+
disabled.set(spec.path, spec)
|
|
219
|
+
return () => disabled.delete(spec.path)
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
}
|
|
223
|
+
hostHalf.apply(disabledCtx, { enabled: false })
|
|
224
|
+
const res = makeRes()
|
|
225
|
+
disabled.get('/dsh-desktop-restart/api/restart').handler(makeReq('POST'), res)
|
|
226
|
+
assert.equal(res.statusCode, 403)
|
|
227
|
+
assert.match(JSON.parse(res.body).error, /enabled/u)
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
console.log('helper')
|
|
231
|
+
|
|
232
|
+
ok('helper:缺参数时以退出码 1 结束', () => {
|
|
233
|
+
const result = spawnSync(process.execPath, [helperPath], { encoding: 'utf8' })
|
|
234
|
+
assert.equal(result.status, 1)
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
ok('helper:handoff 文件不存在时以退出码 1 结束', () => {
|
|
238
|
+
const result = spawnSync(process.execPath, [helperPath, join(tmpdir(), 'dsh-desktop-restart-missing.json')], { encoding: 'utf8' })
|
|
239
|
+
assert.equal(result.status, 1)
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
ok('helper:目标映像名不符时拒绝,且不启动任何进程', () => {
|
|
243
|
+
const dir = mkdtempSync(join(tmpdir(), 'dsh-desktop-restart-smoke-'))
|
|
244
|
+
const logPath = join(dir, 'helper.log')
|
|
245
|
+
const handoffPath = join(dir, 'handoff.json')
|
|
246
|
+
writeFileSync(handoffPath, JSON.stringify({
|
|
247
|
+
mainPid: process.pid,
|
|
248
|
+
// 故意声明一个与当前进程映像名不同的目标:helper 必须拒绝。
|
|
249
|
+
exe: 'C:\\Windows\\System32\\notepad.exe',
|
|
250
|
+
hostPid: 1,
|
|
251
|
+
port: 0,
|
|
252
|
+
logPath,
|
|
253
|
+
delayMs: 50,
|
|
254
|
+
}), 'utf8')
|
|
255
|
+
|
|
256
|
+
const result = spawnSync(process.execPath, [helperPath, handoffPath], { encoding: 'utf8' })
|
|
257
|
+
assert.equal(result.status, 0)
|
|
258
|
+
|
|
259
|
+
const log = readFileSync(logPath, 'utf8')
|
|
260
|
+
assert.match(log, /refusing/u, 'helper 应当记录拒绝原因')
|
|
261
|
+
assert.doesNotMatch(log, /relaunched/u, 'helper 不应启动任何进程')
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
console.log('')
|
|
265
|
+
console.log('all ' + String(passed) + ' checks passed')
|