dsh-shutdown 0.1.0 → 0.2.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/README.md +28 -9
- package/README_EN.md +26 -7
- package/lib/client.js +62 -37
- package/lib/index.js +66 -24
- package/package.json +7 -2
package/README.md
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
<!-- Hero -->
|
|
4
4
|
<div align="center">
|
|
5
|
-
<b style="font-size: 1.15em;"
|
|
6
|
-
Settings
|
|
7
|
-
<b
|
|
5
|
+
<b style="font-size: 1.15em;">一键关闭 / 重启 DSH 服务</b><br /><br />
|
|
6
|
+
Settings「服务开关」页 · 二次确认 · 优雅退出 / 自动重启<br /><br />
|
|
7
|
+
<b>关掉浏览器不再留下后台进程,也能一键重启服务</b>。
|
|
8
8
|
</div>
|
|
9
9
|
|
|
10
10
|
<div align="center">
|
|
@@ -13,13 +13,14 @@
|
|
|
13
13
|
|
|
14
14
|
## ✨ 为什么需要
|
|
15
15
|
|
|
16
|
-
关闭浏览器**不会**结束 dsh
|
|
16
|
+
关闭浏览器**不会**结束 dsh 的后台服务进程——服务继续占用端口与内存。本插件在页面内提供一键关机/重启入口,无需命令行。
|
|
17
17
|
|
|
18
18
|
## ✨ 功能一览
|
|
19
19
|
|
|
20
|
-
- **🖥️ Settings
|
|
21
|
-
- **🛡️ 二次确认**:点「关闭 DSH 服务」→
|
|
22
|
-
- **✅
|
|
20
|
+
- **🖥️ Settings「服务开关」页**:侧栏最底部新增「服务开关」页签
|
|
21
|
+
- **🛡️ 二次确认**:点「关闭 DSH 服务」或「重启 DSH 服务」→ 出现「确认 / 取消」双按钮,防误触
|
|
22
|
+
- **✅ 成功反馈**:确认后页面立即显示绿色「关闭成功 / 重启中」面板(并尝试自动关闭标签页)
|
|
23
|
+
- **🔁 自动重启**:重启请求会让宿主进程以特殊退出码退出,由 `launcher/dsh-web.ps1` 启动脚本捕获后自动重新拉起(根目录 `dsh-web.cmd`/`dsh-web.ps1` 为快捷入口)
|
|
23
24
|
- **🎨 原生风格**:按钮与配色全部引用 DSH 主题 token(`--dsw-alias-*`),明暗自适应
|
|
24
25
|
|
|
25
26
|
## 📦 安装
|
|
@@ -32,8 +33,9 @@ dsh plugin --profile web add <本仓库 / npm 包名>
|
|
|
32
33
|
## 🧠 设计要点
|
|
33
34
|
|
|
34
35
|
- 关机端点:`/_dsh/shutdown`(仅 POST)——服务先返回「正在关闭」响应,500ms 后 `process.exit(0)` 优雅退出
|
|
35
|
-
-
|
|
36
|
-
-
|
|
36
|
+
- 重启端点:`/_dsh/restart`(仅 POST)——服务先返回「正在重启」响应,500ms 后以退出码 42 退出;`dsh-web.ps1` 检测到该退出码会自动重新启动 `dsh web`
|
|
37
|
+
- 会话日志为逐事件落盘,空闲时退出安全;正在进行的对话请等待其完成后再操作
|
|
38
|
+
- 浏览器安全策略下脚本只能关闭自己打开的窗口——手动打开的标签页会保留成功面板,两种情况下你都能确认结果
|
|
37
39
|
|
|
38
40
|
## 🧩 生态
|
|
39
41
|
|
|
@@ -42,3 +44,20 @@ dsh plugin --profile web add <本仓库 / npm 包名>
|
|
|
42
44
|
## 📄 许可
|
|
43
45
|
|
|
44
46
|
[MIT](./LICENSE)
|
|
47
|
+
|
|
48
|
+
## 🔒 安全说明
|
|
49
|
+
|
|
50
|
+
关机端点会终止整个服务,是破坏性最强的操作,因此端点自身带有跨站请求(CSRF)防护:
|
|
51
|
+
|
|
52
|
+
- **浏览器发起的请求**:校验 `Origin` 头。来自外部站点的一律返回 `403`,防止任意网页在后台关闭你本机的 DSH——浏览器不会为这类跨源简单 POST 做预检,所以端点必须自己把关
|
|
53
|
+
- **同源访问**:`127.0.0.1` / `localhost` / `::1` 直接放行;通过局域网 IP 或自定义主机名访问(`dsh web --host 0.0.0.0`)时,要求 `Origin` 与 `Host` 一致
|
|
54
|
+
- **无 `Origin` 的请求**:`curl`、启动脚本等非浏览器客户端放行,不影响脚本化使用
|
|
55
|
+
|
|
56
|
+
## 📝 更新日志
|
|
57
|
+
|
|
58
|
+
### v0.2.0
|
|
59
|
+
|
|
60
|
+
- **新增** CSRF 防护(详见上方安全说明)
|
|
61
|
+
- **修复** 前端在端点返回 500 等错误时仍显示「关闭成功」——此前只要 `fetch` 未抛异常就当作成功,现在区分网络层失败、响应解析失败与 HTTP 错误三类情况
|
|
62
|
+
- **修复** 插件卸载 / 热重载时未撤销 `slots.inject` 注册,可能导致设置页残留重复条目
|
|
63
|
+
- **新增** 17 个行为测试(host 10 + client 7),覆盖 CSRF 守卫、退出码语义、卸载清理与响应判定
|
package/README_EN.md
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
<!-- Hero -->
|
|
4
4
|
<div align="center">
|
|
5
|
-
<b style="font-size: 1.15em;">One-click shutdown for DeepSeek Harness</b><br /><br />
|
|
6
|
-
Settings page · double confirmation · graceful exit<br /><br />
|
|
7
|
-
<b>Closing the browser no longer leaves a background process behind</b>.
|
|
5
|
+
<b style="font-size: 1.15em;">One-click shutdown / restart for DeepSeek Harness</b><br /><br />
|
|
6
|
+
Settings page · double confirmation · graceful exit / auto restart<br /><br />
|
|
7
|
+
<b>Closing the browser no longer leaves a background process behind, and you can restart with one click</b>.
|
|
8
8
|
</div>
|
|
9
9
|
|
|
10
10
|
<div align="center">
|
|
@@ -13,13 +13,14 @@
|
|
|
13
13
|
|
|
14
14
|
## ✨ Why
|
|
15
15
|
|
|
16
|
-
Closing the browser does **not** stop the dsh background service——it keeps holding the port and memory. This plugin adds an in-page shutdown entry, no CLI needed.
|
|
16
|
+
Closing the browser does **not** stop the dsh background service——it keeps holding the port and memory. This plugin adds an in-page shutdown/restart entry, no CLI needed.
|
|
17
17
|
|
|
18
18
|
## ✨ Features
|
|
19
19
|
|
|
20
|
-
- **🖥️ Settings page**: a new "
|
|
21
|
-
- **🛡️ Double confirmation**: click "Shut down
|
|
22
|
-
- **✅ Success feedback**: a green "shut down successfully" panel renders immediately (and tries to close the tab)
|
|
20
|
+
- **🖥️ Settings page**: a new "Service Control" section at the bottom of the sidebar
|
|
21
|
+
- **🛡️ Double confirmation**: click "Shut down" or "Restart" → "Confirm / Cancel" buttons appear, preventing accidents
|
|
22
|
+
- **✅ Success feedback**: a green "shut down successfully / restarting" panel renders immediately (and tries to close the tab)
|
|
23
|
+
- **🔁 Auto restart**: the restart request makes the host process exit with a special code; the `launcher/dsh-web.ps1` launcher catches it (root `dsh-web.cmd`/`dsh-web.ps1` are shortcuts) and relaunches `dsh web`
|
|
23
24
|
- **🎨 Native look**: all colors reference DSH theme tokens (`--dsw-alias-*`), light/dark adaptive
|
|
24
25
|
|
|
25
26
|
## 📦 Install
|
|
@@ -32,6 +33,7 @@ dsh plugin --profile web add <this repo / npm package>
|
|
|
32
33
|
## 🧠 Design notes
|
|
33
34
|
|
|
34
35
|
- Shutdown endpoint: `/_dsh/shutdown` (POST only) —— responds "shutting down" first, then `process.exit(0)` after 500ms
|
|
36
|
+
- Restart endpoint: `/_dsh/restart` (POST only) —— responds "restarting" first, then exits with code 42 after 500ms; `dsh-web.ps1` catches that code and relaunches `dsh web`
|
|
35
37
|
- Session logs are appended per event; exiting while idle is safe. Let an in-flight turn finish first
|
|
36
38
|
- Browsers only allow scripts to close windows they opened——manually opened tabs keep the success panel, so the result is always visible
|
|
37
39
|
|
|
@@ -42,3 +44,20 @@ Tag your own plugins with <a href="https://github.com/topics/dsh-shutdown"><code
|
|
|
42
44
|
## 📄 License
|
|
43
45
|
|
|
44
46
|
[MIT](./LICENSE)
|
|
47
|
+
|
|
48
|
+
## 🔒 Security
|
|
49
|
+
|
|
50
|
+
The shutdown endpoint terminates the whole service, so it guards itself against cross-site requests:
|
|
51
|
+
|
|
52
|
+
- **Browser requests**: the `Origin` header is validated. Requests from external sites receive `403`, preventing any web page from shutting down your local DSH in the background — browsers do not preflight such cross-origin simple POSTs, so the endpoint has to check for itself
|
|
53
|
+
- **Same-origin access**: `127.0.0.1` / `localhost` / `::1` pass directly; when accessed via a LAN IP or a custom hostname (`dsh web --host 0.0.0.0`), `Origin` must equal `Host`
|
|
54
|
+
- **Requests without `Origin`**: non-browser clients such as `curl` and launch scripts pass, so scripting keeps working
|
|
55
|
+
|
|
56
|
+
## 📝 Changelog
|
|
57
|
+
|
|
58
|
+
### v0.2.0
|
|
59
|
+
|
|
60
|
+
- **Added** CSRF protection (see Security above)
|
|
61
|
+
- **Fixed** the UI reporting "shut down successfully" when the endpoint returned an error such as 500 — previously any non-throwing `fetch` counted as success; network failure, response parsing failure and HTTP errors are now distinguished
|
|
62
|
+
- **Fixed** `slots.inject` registration not being disposed on plugin unload or hot reload, which could leave duplicate entries in the Settings page
|
|
63
|
+
- **Added** 17 behavioural tests (10 host + 7 client) covering the CSRF guard, exit-code semantics, teardown and response handling
|
package/lib/client.js
CHANGED
|
@@ -5,9 +5,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
5
5
|
exports.apply = apply;
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* dsh-shutdown 浏览器半部:Settings
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* dsh-shutdown 浏览器半部:Settings「服务开关」页面。
|
|
9
|
+
* 支持「关闭 DSH 服务」和「重启 DSH 服务」两个操作;
|
|
10
|
+
* 都使用相同的确认模式:点击操作 → 出现「确认 / 取消」双按钮。
|
|
11
|
+
* 确认后请求对应端点并渲染成功面板。样式引用 DSH 主题 token。
|
|
11
12
|
*/
|
|
12
13
|
const react_1 = require("react");
|
|
13
14
|
|
|
@@ -28,81 +29,97 @@ const CSS = '' +
|
|
|
28
29
|
'.sd-err{color:var(--dsw-alias-state-error-primary)}';
|
|
29
30
|
|
|
30
31
|
function ShutdownSection() {
|
|
31
|
-
const [
|
|
32
|
-
const [
|
|
33
|
-
const [done, setDone] = react_1.useState(
|
|
32
|
+
const [action, setAction] = react_1.useState(null); // 'shutdown' | 'restart' | null
|
|
33
|
+
const [busy, setBusy] = react_1.useState(false);
|
|
34
|
+
const [done, setDone] = react_1.useState(null); // 'shutdown' | 'restart' | null
|
|
34
35
|
const [error, setError] = react_1.useState(null);
|
|
35
36
|
|
|
36
37
|
function handleCancel() {
|
|
37
|
-
|
|
38
|
+
setAction(null);
|
|
38
39
|
setError(null);
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
async function handleConfirm() {
|
|
42
|
-
if (
|
|
43
|
-
|
|
43
|
+
if (busy) return;
|
|
44
|
+
const currentAction = action;
|
|
45
|
+
setBusy(true);
|
|
44
46
|
setError(null);
|
|
47
|
+
const path = currentAction === 'restart' ? '/_dsh/restart' : '/_dsh/shutdown';
|
|
48
|
+
let res;
|
|
45
49
|
try {
|
|
46
|
-
|
|
50
|
+
res = await fetch(path, {
|
|
47
51
|
method: 'POST',
|
|
48
52
|
headers: { accept: 'application/json' },
|
|
49
53
|
});
|
|
50
|
-
const body = await res.json();
|
|
51
|
-
if (body && body.ok) {
|
|
52
|
-
setDone(true);
|
|
53
|
-
// 尝试关闭标签页(仅对脚本打开的窗口生效,手动打开的标签页静默失败)
|
|
54
|
-
window.setTimeout(function () { try { window.close(); } catch (e) { /* 忽略 */ } }, 800);
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
57
|
-
setError(String((body && body.error) || '关机请求失败'));
|
|
58
|
-
setClosing(false);
|
|
59
|
-
setConfirmOpen(false);
|
|
60
54
|
} catch (e) {
|
|
61
|
-
//
|
|
62
|
-
|
|
55
|
+
// 只有网络层失败才说明进程已经退出——这恰恰意味着操作成功。
|
|
56
|
+
// 注意范围:响应解析失败不走这条分支,否则 500 错误页会被误报成「关闭成功」。
|
|
57
|
+
setDone(currentAction);
|
|
58
|
+
return;
|
|
63
59
|
}
|
|
60
|
+
|
|
61
|
+
let body = null;
|
|
62
|
+
try {
|
|
63
|
+
body = await res.json();
|
|
64
|
+
} catch (e) {
|
|
65
|
+
body = null; // 非 JSON 响应(错误页等):按失败处理
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (res.ok && body && body.ok) {
|
|
69
|
+
setDone(currentAction);
|
|
70
|
+
// 尝试关闭标签页(仅对脚本打开的窗口生效,手动打开的标签页静默失败)
|
|
71
|
+
window.setTimeout(function () { try { window.close(); } catch (e) { /* 忽略 */ } }, 800);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const fallback = currentAction === 'restart' ? '重启请求失败' : '关机请求失败';
|
|
76
|
+
setError(String((body && body.error) || ('HTTP ' + res.status + ':' + fallback)));
|
|
77
|
+
setBusy(false);
|
|
78
|
+
setAction(null);
|
|
64
79
|
}
|
|
65
80
|
|
|
66
81
|
if (done) {
|
|
67
|
-
const
|
|
82
|
+
const isRestart = done === 'restart';
|
|
83
|
+
const okTitle = react_1.createElement('h3', { className: 'sd-title' }, isRestart ? '重启' : '关机');
|
|
68
84
|
const okLine = react_1.createElement(
|
|
69
85
|
'div',
|
|
70
86
|
{ className: 'sd-ok' },
|
|
71
|
-
'✅ 关闭成功——DSH 服务已退出。'
|
|
87
|
+
isRestart ? '✅ 重启中——DSH 服务正在重新启动。' : '✅ 关闭成功——DSH 服务已退出。'
|
|
72
88
|
);
|
|
73
89
|
const okDesc = react_1.createElement(
|
|
74
90
|
'div',
|
|
75
91
|
{ className: 'sd-sub' },
|
|
76
|
-
'本标签页可以安全关闭。如需再次使用,请重新启动 dsh web。'
|
|
92
|
+
isRestart ? '稍后请刷新本页面以连接新的 DSH 服务。' : '本标签页可以安全关闭。如需再次使用,请重新启动 dsh web。'
|
|
77
93
|
);
|
|
78
94
|
return react_1.createElement('div', { className: 'sd-root' }, okTitle, okLine, okDesc);
|
|
79
95
|
}
|
|
80
96
|
|
|
81
|
-
const title = react_1.createElement('h3', { className: 'sd-title' }, '
|
|
97
|
+
const title = react_1.createElement('h3', { className: 'sd-title' }, '服务开关');
|
|
82
98
|
const desc = react_1.createElement(
|
|
83
99
|
'div',
|
|
84
100
|
{ className: 'sd-sub' },
|
|
85
|
-
'
|
|
101
|
+
'关闭或重启 DSH 服务进程。重启后请稍等片刻再刷新页面。'
|
|
86
102
|
);
|
|
87
103
|
|
|
88
104
|
const errLine = error
|
|
89
105
|
? react_1.createElement('div', { className: 'sd-err' }, '错误:' + error)
|
|
90
106
|
: null;
|
|
91
107
|
|
|
92
|
-
if (
|
|
108
|
+
if (action) {
|
|
109
|
+
const isRestart = action === 'restart';
|
|
93
110
|
const confirmLine = react_1.createElement(
|
|
94
111
|
'div',
|
|
95
112
|
{ className: 'sd-confirm' },
|
|
96
|
-
'确定要关闭 DSH 服务吗?正在进行的对话将被中断。'
|
|
113
|
+
isRestart ? '确定要重启 DSH 服务吗?正在进行的对话将被中断。' : '确定要关闭 DSH 服务吗?正在进行的对话将被中断。'
|
|
97
114
|
);
|
|
98
115
|
const confirmBtn = react_1.createElement(
|
|
99
116
|
'button',
|
|
100
|
-
{ className: 'sd-btn sd-btn-danger', onClick: handleConfirm, disabled:
|
|
101
|
-
|
|
117
|
+
{ className: 'sd-btn sd-btn-danger', onClick: handleConfirm, disabled: busy },
|
|
118
|
+
busy ? (isRestart ? '正在重启…' : '正在关闭…') : (isRestart ? '确认重启' : '确认关闭')
|
|
102
119
|
);
|
|
103
120
|
const cancelBtn = react_1.createElement(
|
|
104
121
|
'button',
|
|
105
|
-
{ className: 'sd-btn sd-btn-plain', onClick: handleCancel, disabled:
|
|
122
|
+
{ className: 'sd-btn sd-btn-plain', onClick: handleCancel, disabled: busy },
|
|
106
123
|
'取消'
|
|
107
124
|
);
|
|
108
125
|
const btnRow = react_1.createElement('div', { className: 'sd-row' }, confirmBtn, cancelBtn);
|
|
@@ -119,16 +136,22 @@ function ShutdownSection() {
|
|
|
119
136
|
|
|
120
137
|
const shutdownBtn = react_1.createElement(
|
|
121
138
|
'button',
|
|
122
|
-
{ className: 'sd-btn sd-btn-danger', onClick: function () {
|
|
139
|
+
{ className: 'sd-btn sd-btn-danger', onClick: function () { setAction('shutdown'); } },
|
|
123
140
|
'关闭 DSH 服务'
|
|
124
141
|
);
|
|
142
|
+
const restartBtn = react_1.createElement(
|
|
143
|
+
'button',
|
|
144
|
+
{ className: 'sd-btn sd-btn-plain', onClick: function () { setAction('restart'); } },
|
|
145
|
+
'重启 DSH 服务'
|
|
146
|
+
);
|
|
147
|
+
const btnRow = react_1.createElement('div', { className: 'sd-row' }, shutdownBtn, restartBtn);
|
|
125
148
|
|
|
126
149
|
return react_1.createElement(
|
|
127
150
|
'div',
|
|
128
151
|
{ className: 'sd-root' },
|
|
129
152
|
title,
|
|
130
153
|
desc,
|
|
131
|
-
|
|
154
|
+
btnRow,
|
|
132
155
|
errLine
|
|
133
156
|
);
|
|
134
157
|
}
|
|
@@ -142,14 +165,16 @@ function apply(ctx) {
|
|
|
142
165
|
styleEl.textContent = CSS;
|
|
143
166
|
document.head.appendChild(styleEl);
|
|
144
167
|
|
|
145
|
-
slots.inject('settings.section', () => slots.register(
|
|
146
|
-
{ name: 'settings.section', id: 'dsh-shutdown', order: 999, label: () => '
|
|
168
|
+
const offSlot = slots.inject('settings.section', () => slots.register(
|
|
169
|
+
{ name: 'settings.section', id: 'dsh-shutdown', order: 999, label: () => '服务开关' },
|
|
147
170
|
() => react_1.createElement(ShutdownSection, null)
|
|
148
171
|
));
|
|
149
172
|
|
|
150
173
|
ctx.effect(() => () => {
|
|
174
|
+
// slots.inject 返回 disposer,必须调用——否则插件卸载 / 热重载后设置页会残留条目
|
|
175
|
+
if (typeof offSlot === 'function') offSlot();
|
|
151
176
|
if (styleEl.parentNode) styleEl.parentNode.removeChild(styleEl);
|
|
152
177
|
}, NS + ':css');
|
|
153
178
|
}
|
|
154
179
|
|
|
155
|
-
return module.exports; } });
|
|
180
|
+
return module.exports; } });
|
package/lib/index.js
CHANGED
|
@@ -1,40 +1,82 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-shutdown — DeepSeek Harness 关机插件(Host 半部)。
|
|
3
3
|
*
|
|
4
|
-
* 提供 `/_dsh/shutdown` 端点:收到 POST
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* 提供 `/_dsh/shutdown` 与 `/_dsh/restart` 端点:收到 POST 后先返回确认响应
|
|
5
|
+
* (让浏览器拿到"正在关闭/重启"的反馈),500ms 后退出宿主进程。
|
|
6
|
+
* 关闭使用退出码 0;重启使用特殊退出码 42,由外部启动脚本捕获后自动重新拉起。
|
|
7
|
+
* 会话日志为逐事件落盘,空闲时退出安全;正在进行的 turn 请等待其完成后再操作。
|
|
7
8
|
* @module dsh-shutdown
|
|
8
9
|
*/
|
|
9
10
|
export const name = 'dsh-shutdown';
|
|
10
11
|
export const inject = [];
|
|
11
12
|
|
|
12
13
|
const EXIT_DELAY_MS = 500;
|
|
14
|
+
const RESTART_EXIT_CODE = 42;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 判断请求是否来自本机同源页面(CSRF 守卫)。
|
|
18
|
+
*
|
|
19
|
+
* 关机会终止整个服务,是破坏性最强的动作。任意网页都能向
|
|
20
|
+
* http://127.0.0.1:3080/_dsh/shutdown 发一个「简单 POST」,浏览器不会为此做预检,
|
|
21
|
+
* 所以端点自身必须把关:浏览器发 POST 一定会带 Origin,凡带外部 Origin 的一律拒绝;
|
|
22
|
+
* 无 Origin 的请求(curl、启动脚本)不是浏览器发起的,放行。
|
|
23
|
+
*/
|
|
24
|
+
const LOOPBACK_HOSTS = ['127.0.0.1', 'localhost', '::1', '[::1]'];
|
|
25
|
+
|
|
26
|
+
function isTrustedOrigin(req) {
|
|
27
|
+
const origin = req.headers && req.headers.origin;
|
|
28
|
+
if (origin === undefined || origin === null || origin === '') return true;
|
|
29
|
+
let parsed;
|
|
30
|
+
try {
|
|
31
|
+
parsed = new URL(origin);
|
|
32
|
+
} catch {
|
|
33
|
+
return false; // Origin 无法解析:保守拒绝
|
|
34
|
+
}
|
|
35
|
+
if (LOOPBACK_HOSTS.includes(parsed.hostname)) return true;
|
|
36
|
+
// 通过局域网 IP / 自定义主机名访问(dsh web --host 0.0.0.0)时,
|
|
37
|
+
// Origin 与 Host 头一致即视为同源;不一致则说明是伪造的同源声明。
|
|
38
|
+
const host = req.headers && req.headers.host;
|
|
39
|
+
return typeof host === 'string' && host !== '' && parsed.host === host;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 通用动作端点注册。 */
|
|
43
|
+
function registerAction(webServer, path, message, exitCode) {
|
|
44
|
+
return webServer.register({
|
|
45
|
+
kind: 'exact',
|
|
46
|
+
path,
|
|
47
|
+
handler: (req, res) => {
|
|
48
|
+
if (req.method !== 'POST') {
|
|
49
|
+
res.statusCode = 405;
|
|
50
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
51
|
+
res.end(JSON.stringify({ ok: false, error: 'method-not-allowed' }));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (!isTrustedOrigin(req)) {
|
|
55
|
+
res.statusCode = 403;
|
|
56
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
57
|
+
res.end(JSON.stringify({ ok: false, error: 'cross-origin-forbidden' }));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
res.statusCode = 200;
|
|
61
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
62
|
+
res.end(JSON.stringify({ ok: true, message }));
|
|
63
|
+
// 留出响应送达浏览器的时间,再退出进程
|
|
64
|
+
setTimeout(() => {
|
|
65
|
+
process.exit(exitCode);
|
|
66
|
+
}, EXIT_DELAY_MS);
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
}
|
|
13
70
|
|
|
14
71
|
/** Plugin 入口。 */
|
|
15
72
|
export async function apply(ctx) {
|
|
16
73
|
ctx.inject(['webServer'], (webCtx) => {
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
if (req.method !== 'POST') {
|
|
22
|
-
res.statusCode = 405;
|
|
23
|
-
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
24
|
-
res.end(JSON.stringify({ ok: false, error: 'method-not-allowed' }));
|
|
25
|
-
return;
|
|
26
|
-
}
|
|
27
|
-
res.statusCode = 200;
|
|
28
|
-
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
29
|
-
res.end(JSON.stringify({ ok: true, message: '正在关闭 DSH 服务' }));
|
|
30
|
-
// 留出响应送达浏览器的时间,再退出进程
|
|
31
|
-
setTimeout(() => {
|
|
32
|
-
process.exit(0);
|
|
33
|
-
}, EXIT_DELAY_MS);
|
|
34
|
-
},
|
|
35
|
-
});
|
|
74
|
+
const disposers = [
|
|
75
|
+
registerAction(webCtx.webServer, '/_dsh/shutdown', '正在关闭 DSH 服务', 0),
|
|
76
|
+
registerAction(webCtx.webServer, '/_dsh/restart', '正在重启 DSH 服务', RESTART_EXIT_CODE),
|
|
77
|
+
];
|
|
36
78
|
return () => {
|
|
37
|
-
|
|
79
|
+
for (const dispose of disposers) dispose();
|
|
38
80
|
};
|
|
39
81
|
});
|
|
40
|
-
}
|
|
82
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-shutdown",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "DeepSeek Harness 关机插件:Settings 增加「关机」页,一键优雅退出 DSH 服务(先响应页面再退出进程)。",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek",
|
|
@@ -17,12 +17,17 @@
|
|
|
17
17
|
"./package.json": "./package.json"
|
|
18
18
|
},
|
|
19
19
|
"files": [
|
|
20
|
-
"lib",
|
|
20
|
+
"lib/index.js",
|
|
21
|
+
"lib/client.js",
|
|
21
22
|
"cordis.patch.yml",
|
|
22
23
|
"README.md",
|
|
23
24
|
"README_EN.md",
|
|
24
25
|
"dsh.plugin.json"
|
|
25
26
|
],
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/knlght/DSH-shutdown.git"
|
|
30
|
+
},
|
|
26
31
|
"engines": {
|
|
27
32
|
"node": "^22.19.0 || >=24.0.0"
|
|
28
33
|
},
|