@kesike/dsh-exit 0.2.3 → 0.2.5
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 +2 -2
- package/README.zh-CN.md +2 -2
- package/lib/client.js +183 -52
- package/lib/index.js +232 -6
- package/package.json +13 -2
package/README.md
CHANGED
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
|
|
32
32
|
## 🎬 GitHub showcase
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
Follow the exit flow below, then see the plugin in a running dsh settings panel.
|
|
35
35
|
|
|
36
36
|
<p align="center">
|
|
37
37
|
<img src="https://raw.githubusercontent.com/KeS1Ke/dsh-exit/main/docs/dsh-exit-landscape.webp" alt="Landscape animated dsh-exit flow generated by Archify" width="720">
|
|
@@ -62,7 +62,7 @@ The capture below is a current dsh settings view with `dsh-exit` installed. The
|
|
|
62
62
|
| Bundle patch | [`cordis.patch.yml`](cordis.patch.yml) | Inserts the plugin row into the dsh profile. |
|
|
63
63
|
| Package metadata | [`package.json`](package.json) | Declares the host entry, web client, and bundle patch. |
|
|
64
64
|
|
|
65
|
-
The
|
|
65
|
+
The package stays focused on the runtime files and package metadata. Additional screenshots and source materials are provided below:
|
|
66
66
|
|
|
67
67
|
| Showcase asset | Location |
|
|
68
68
|
| --- | --- |
|
package/README.zh-CN.md
CHANGED
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
|
|
32
32
|
## 🎬 GitHub 展示区
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
下面展示退出流程,以及插件在 dsh 设置面板中的实际效果。
|
|
35
35
|
|
|
36
36
|
<p align="center">
|
|
37
37
|
<img src="https://raw.githubusercontent.com/KeS1Ke/dsh-exit/main/docs/dsh-exit-landscape.webp" alt="由 Archify 生成的横版 dsh-exit 动态流程图" width="720">
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
| Bundle 补丁 | [`cordis.patch.yml`](cordis.patch.yml) | 向 dsh profile 插入插件行。 |
|
|
63
63
|
| 包元数据 | [`package.json`](package.json) | 声明宿主入口、Web 客户端和 Bundle 补丁。 |
|
|
64
64
|
|
|
65
|
-
npm
|
|
65
|
+
npm 包聚焦于运行所需的源码和包元数据,下面同时提供截图和流程源文件:
|
|
66
66
|
|
|
67
67
|
| 展示素材 | 位置 |
|
|
68
68
|
| --- | --- |
|
package/lib/client.js
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* dsh-exit 客户端:
|
|
4
4
|
* - 在 dsh 设置菜单中注册一个始终排在最后的“退出”栏目
|
|
5
|
-
* -
|
|
6
|
-
*
|
|
5
|
+
* - 提供对齐展示的退出与重新启动操作;确认后分别调用
|
|
6
|
+
* remote.dshExit.exit() / remote.dshExit.restart()
|
|
7
|
+
* - 重启成功后等待后台服务恢复并自动刷新页面
|
|
7
8
|
*
|
|
8
9
|
* 页面使用 dsh 已提供的 React、slots 和 locale 运行时,不引入新的运行时依赖。
|
|
9
10
|
*/
|
|
@@ -19,9 +20,11 @@ window.__ModuleLoader__.load({
|
|
|
19
20
|
|
|
20
21
|
/** 与 archive-manager「删除会话」相同的错误红(设计令牌)。 */
|
|
21
22
|
const ERROR_RED = "var(--dsw-alias-state-error-primary)";
|
|
23
|
+
const RESTART_BLUE = "var(--dsw-alias-state-business-primary)";
|
|
22
24
|
|
|
23
25
|
/** Lucide power 图标(ISC 许可,免费),16px 线性风格贴合现有按钮。 */
|
|
24
|
-
const
|
|
26
|
+
const EXIT_ICON_SVG = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2v10"/><path d="M18.4 6.6a9 9 0 1 1-12.77.04"/></svg>';
|
|
27
|
+
const RESTART_ICON_SVG = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 0 0-15.5-6.2L3 8"/><path d="M3 3v5h5"/><path d="M3 12a9 9 0 0 0 15.5 6.2L21 16"/><path d="M16 16h5v5"/></svg>';
|
|
25
28
|
|
|
26
29
|
const CSS_TEXT = [
|
|
27
30
|
".dshExitSettings{box-sizing:border-box;max-width:720px;padding:28px 32px 40px;color:var(--dsw-alias-label-primary)}",
|
|
@@ -30,12 +33,22 @@ window.__ModuleLoader__.load({
|
|
|
30
33
|
".dshExitSettingsIcon svg{width:20px;height:20px}",
|
|
31
34
|
".dshExitSettingsTitle{margin:0;font-size:20px;line-height:28px;font-weight:650;color:var(--dsw-alias-label-primary)}",
|
|
32
35
|
".dshExitSettingsDescription{margin:6px 0 0;max-width:560px;font-size:13px;line-height:20px;color:var(--dsw-alias-label-secondary)}",
|
|
33
|
-
".
|
|
36
|
+
".dshExitSettingsActions{display:flex;flex-direction:column;gap:12px;margin-top:24px}",
|
|
37
|
+
".dshExitSettingsCard{display:grid;grid-template-columns:34px minmax(0,1fr) auto;align-items:center;gap:14px;padding:20px;border:1px solid var(--dsw-alias-border-l2);border-radius:14px;background:var(--dsw-alias-bg-layer-2)}",
|
|
38
|
+
".dshExitActionIcon{box-sizing:border-box;width:34px;height:34px;display:grid;place-items:center;border:1px solid currentColor;border-radius:10px;background:var(--dsw-alias-button-elevated-fill)}",
|
|
39
|
+
".dshExitActionIcon--exit{color:" + ERROR_RED + "}",
|
|
40
|
+
".dshExitActionIcon--restart{color:" + RESTART_BLUE + "}",
|
|
41
|
+
".dshExitActionIcon svg{width:18px;height:18px}",
|
|
42
|
+
".dshExitSettingsCopy{min-width:0}",
|
|
43
|
+
".dshExitSettingsActionTitle{margin:0 0 3px;font-size:14px;line-height:20px;font-weight:650;color:var(--dsw-alias-label-primary)}",
|
|
34
44
|
".dshExitSettingsWarning{margin:0;max-width:430px;font-size:13px;line-height:20px;color:var(--dsw-alias-label-secondary)}",
|
|
35
|
-
".dshExitSettingsButton{display:inline-flex;align-items:center;justify-content:center;gap:8px;flex:none;min-width:112px;height:36px;padding:0 16px;cursor:pointer;border:0;border-radius:10px;
|
|
45
|
+
".dshExitSettingsButton{display:inline-flex;align-items:center;justify-content:center;gap:8px;flex:none;min-width:112px;height:36px;padding:0 16px;cursor:pointer;border:0;border-radius:10px;color:#fff;font-size:13px;font-weight:650;transition:filter .12s ease,opacity .12s ease}",
|
|
46
|
+
".dshExitSettingsButton--exit{background:" + ERROR_RED + "}",
|
|
47
|
+
".dshExitSettingsButton--restart{background:var(--dsw-alias-button-primary-fill);color:var(--dsw-alias-label-primary-foreground)}",
|
|
36
48
|
".dshExitSettingsButton svg{width:16px;height:16px}",
|
|
37
49
|
".dshExitSettingsButton:hover:not(:disabled){filter:brightness(1.08)}",
|
|
38
|
-
".dshExitSettingsButton:
|
|
50
|
+
".dshExitSettingsButton--restart:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover)}",
|
|
51
|
+
".dshExitSettingsButton:focus-visible{outline:2px solid var(--dsw-alias-border-l3);outline-offset:2px}",
|
|
39
52
|
".dshExitSettingsButton:disabled{cursor:default;opacity:.6}",
|
|
40
53
|
".dshExitNavCell{color:" + ERROR_RED + " !important}",
|
|
41
54
|
".dshExitNavCell .dshExitNavLabel{color:" + ERROR_RED + " !important;font-weight:650}",
|
|
@@ -53,8 +66,11 @@ window.__ModuleLoader__.load({
|
|
|
53
66
|
".dshExitCancel:hover{background:var(--dsw-alias-interactive-bg-hover)}",
|
|
54
67
|
".dshExitCancel:disabled{cursor:default;opacity:.6}",
|
|
55
68
|
".dshExitConfirm{cursor:pointer;height:32px;padding:0 14px;border-radius:10px;border:none;background:" + ERROR_RED + ";color:#fff;font-size:13px;font-weight:600}",
|
|
69
|
+
".dshExitConfirm--restart{background:var(--dsw-alias-button-primary-fill);color:var(--dsw-alias-label-primary-foreground)}",
|
|
56
70
|
".dshExitConfirm:hover:not(:disabled){filter:brightness(1.08)}",
|
|
57
|
-
".dshExitConfirm:disabled{
|
|
71
|
+
".dshExitConfirm--restart:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover)}",
|
|
72
|
+
".dshExitConfirm:disabled{cursor:default;opacity:.6}",
|
|
73
|
+
"@media (max-width:620px){.dshExitSettings{padding:24px 20px 32px}.dshExitSettingsCard{grid-template-columns:34px minmax(0,1fr)}.dshExitSettingsButton{grid-column:1 / -1;justify-self:start}}"
|
|
58
74
|
].join("\n");
|
|
59
75
|
|
|
60
76
|
/** 客户端严格编解码 shim:与服务端 exitedSchema 对应。 */
|
|
@@ -81,6 +97,19 @@ window.__ModuleLoader__.load({
|
|
|
81
97
|
typeSymbol: "@kesike/dsh-exit/types#Exited",
|
|
82
98
|
schema: exitedClientSchema
|
|
83
99
|
}
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
id: "@kesike/dsh-exit#dshExit/restart",
|
|
103
|
+
service: "dshExit",
|
|
104
|
+
namespace: "dshExit",
|
|
105
|
+
method: "restart",
|
|
106
|
+
invocation: { kind: "direct" },
|
|
107
|
+
parameters: [],
|
|
108
|
+
result: {
|
|
109
|
+
mode: "strict",
|
|
110
|
+
typeSymbol: "@kesike/dsh-exit/types#Restarted",
|
|
111
|
+
schema: exitedClientSchema
|
|
112
|
+
}
|
|
84
113
|
}
|
|
85
114
|
]
|
|
86
115
|
};
|
|
@@ -89,35 +118,57 @@ window.__ModuleLoader__.load({
|
|
|
89
118
|
const DICT = {
|
|
90
119
|
zh: {
|
|
91
120
|
nav: "退出",
|
|
92
|
-
title: "
|
|
93
|
-
description: "
|
|
94
|
-
|
|
95
|
-
|
|
121
|
+
title: "退出与重新启动 dsh",
|
|
122
|
+
description: "从这里安全结束或重新启动 dsh 宿主进程,并管理它占用的端口。",
|
|
123
|
+
exitActionTitle: "结束 dsh",
|
|
124
|
+
exitWarning: "确认后将结束所有运行中的会话并停止后台服务,当前 dsh Web 端口 {endpoint} 会随之释放。",
|
|
125
|
+
exitButton: "确认退出",
|
|
126
|
+
restartActionTitle: "重新启动 dsh",
|
|
127
|
+
restartWarning: "重新启动会短暂中断所有运行中的会话;后台服务恢复后,页面将自动刷新。",
|
|
128
|
+
restartButton: "重新启动",
|
|
96
129
|
dialogTitle: "退出 DeepSeek Harness?",
|
|
97
130
|
dialogBody: "退出后将结束所有运行中的会话并停止后台服务,当前 dsh Web 端口 {endpoint} 会随之释放。",
|
|
131
|
+
restartDialogTitle: "重新启动 DeepSeek Harness?",
|
|
132
|
+
restartDialogBody: "这会结束当前运行中的会话,后台服务将释放并重新监听 {endpoint}。服务恢复后页面将自动刷新。",
|
|
98
133
|
cancel: "取消",
|
|
99
134
|
confirm: "确认退出",
|
|
135
|
+
restartConfirm: "确认重启",
|
|
100
136
|
exiting: "正在退出…",
|
|
101
|
-
|
|
137
|
+
restarting: "正在重新启动…",
|
|
138
|
+
restartWaiting: "后台服务正在重新启动,恢复后页面将自动刷新。",
|
|
139
|
+
restartTimeout: "后台服务尚未恢复,请稍后手动刷新页面。",
|
|
140
|
+
exitErrorPrefix: "退出失败:",
|
|
141
|
+
restartErrorPrefix: "重启失败:"
|
|
102
142
|
},
|
|
103
143
|
en: {
|
|
104
144
|
nav: "Exit",
|
|
105
|
-
title: "Exit dsh",
|
|
106
|
-
description: "Safely terminate the dsh host process and
|
|
107
|
-
|
|
108
|
-
|
|
145
|
+
title: "Exit or restart dsh",
|
|
146
|
+
description: "Safely terminate or restart the dsh host process and manage the ports it owns.",
|
|
147
|
+
exitActionTitle: "Exit dsh",
|
|
148
|
+
exitWarning: "Confirming ends all running sessions and stops the background service, releasing the current dsh Web port {endpoint}.",
|
|
149
|
+
exitButton: "Confirm exit",
|
|
150
|
+
restartActionTitle: "Restart dsh",
|
|
151
|
+
restartWarning: "Restarting briefly interrupts all running sessions; this page refreshes automatically after the service recovers.",
|
|
152
|
+
restartButton: "Restart",
|
|
109
153
|
dialogTitle: "Exit DeepSeek Harness?",
|
|
110
154
|
dialogBody: "This ends all running sessions and stops the background service, releasing the current dsh Web port {endpoint}.",
|
|
155
|
+
restartDialogTitle: "Restart DeepSeek Harness?",
|
|
156
|
+
restartDialogBody: "This ends the current sessions while the background service releases and reclaims {endpoint}. The page refreshes automatically after recovery.",
|
|
111
157
|
cancel: "Cancel",
|
|
112
158
|
confirm: "Confirm exit",
|
|
159
|
+
restartConfirm: "Confirm restart",
|
|
113
160
|
exiting: "Exiting…",
|
|
114
|
-
|
|
161
|
+
restarting: "Restarting…",
|
|
162
|
+
restartWaiting: "The background service is restarting. This page will refresh after it recovers.",
|
|
163
|
+
restartTimeout: "The background service has not recovered yet. Please refresh this page later.",
|
|
164
|
+
exitErrorPrefix: "Exit failed: ",
|
|
165
|
+
restartErrorPrefix: "Restart failed: "
|
|
115
166
|
}
|
|
116
167
|
};
|
|
117
168
|
|
|
118
|
-
let exiting = false;
|
|
119
169
|
let overlayEl = null;
|
|
120
170
|
let escHandler = null;
|
|
171
|
+
let restartPollTimer = null;
|
|
121
172
|
|
|
122
173
|
function injectCss() {
|
|
123
174
|
if (document.querySelector("style[data-plugin-css='dsh-exit']")) return;
|
|
@@ -150,7 +201,7 @@ window.__ModuleLoader__.load({
|
|
|
150
201
|
const originalIcon = cell.firstElementChild;
|
|
151
202
|
const icon = document.createElement("span");
|
|
152
203
|
icon.className = "dshExitNavIcon";
|
|
153
|
-
icon.innerHTML =
|
|
204
|
+
icon.innerHTML = EXIT_ICON_SVG;
|
|
154
205
|
if (originalIcon !== null && originalIcon !== label) {
|
|
155
206
|
originalIcon.dataset.dshExitOriginalIcon = "true";
|
|
156
207
|
originalIcon.style.display = "none";
|
|
@@ -173,6 +224,31 @@ window.__ModuleLoader__.load({
|
|
|
173
224
|
|
|
174
225
|
function ExitSettingsSection({ ctx, t }) {
|
|
175
226
|
const endpoint = currentDshEndpoint();
|
|
227
|
+
const actionCard = ({ action, icon, title, warning, button }) => h("div", {
|
|
228
|
+
className: "dshExitSettingsCard",
|
|
229
|
+
key: action
|
|
230
|
+
}, [
|
|
231
|
+
h("span", {
|
|
232
|
+
className: "dshExitActionIcon dshExitActionIcon--" + action,
|
|
233
|
+
dangerouslySetInnerHTML: { __html: icon },
|
|
234
|
+
"aria-hidden": "true",
|
|
235
|
+
key: "icon"
|
|
236
|
+
}),
|
|
237
|
+
h("div", { className: "dshExitSettingsCopy", key: "copy" }, [
|
|
238
|
+
h("h3", { className: "dshExitSettingsActionTitle", key: "title" }, textOf(t, title)),
|
|
239
|
+
h("p", { className: "dshExitSettingsWarning", key: "warning" }, textOf(t, warning, { endpoint }))
|
|
240
|
+
]),
|
|
241
|
+
h("button", {
|
|
242
|
+
type: "button",
|
|
243
|
+
className: "dshExitSettingsButton dshExitSettingsButton--" + action,
|
|
244
|
+
"aria-label": textOf(t, button),
|
|
245
|
+
onClick: (event) => openModal(ctx, event.currentTarget, t, action),
|
|
246
|
+
key: "button"
|
|
247
|
+
}, [
|
|
248
|
+
h("span", { dangerouslySetInnerHTML: { __html: icon }, "aria-hidden": "true", key: "icon" }),
|
|
249
|
+
textOf(t, button)
|
|
250
|
+
])
|
|
251
|
+
]);
|
|
176
252
|
return h("section", {
|
|
177
253
|
className: "dshExitSettings",
|
|
178
254
|
"aria-labelledby": "dsh-exit-settings-title"
|
|
@@ -180,7 +256,7 @@ window.__ModuleLoader__.load({
|
|
|
180
256
|
h("div", { className: "dshExitSettingsHeader", key: "header" }, [
|
|
181
257
|
h("span", {
|
|
182
258
|
className: "dshExitSettingsIcon",
|
|
183
|
-
dangerouslySetInnerHTML: { __html:
|
|
259
|
+
dangerouslySetInnerHTML: { __html: EXIT_ICON_SVG },
|
|
184
260
|
key: "icon"
|
|
185
261
|
}),
|
|
186
262
|
h("div", { key: "copy" }, [
|
|
@@ -188,23 +264,30 @@ window.__ModuleLoader__.load({
|
|
|
188
264
|
h("p", { className: "dshExitSettingsDescription", key: "description" }, textOf(t, "description"))
|
|
189
265
|
])
|
|
190
266
|
]),
|
|
191
|
-
h("div", { className: "
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
267
|
+
h("div", { className: "dshExitSettingsActions", key: "actions" }, [
|
|
268
|
+
actionCard({
|
|
269
|
+
action: "exit",
|
|
270
|
+
icon: EXIT_ICON_SVG,
|
|
271
|
+
title: "exitActionTitle",
|
|
272
|
+
warning: "exitWarning",
|
|
273
|
+
button: "exitButton"
|
|
274
|
+
}),
|
|
275
|
+
actionCard({
|
|
276
|
+
action: "restart",
|
|
277
|
+
icon: RESTART_ICON_SVG,
|
|
278
|
+
title: "restartActionTitle",
|
|
279
|
+
warning: "restartWarning",
|
|
280
|
+
button: "restartButton"
|
|
281
|
+
})
|
|
203
282
|
])
|
|
204
283
|
]);
|
|
205
284
|
}
|
|
206
285
|
|
|
207
286
|
function closeModal() {
|
|
287
|
+
if (restartPollTimer !== null) {
|
|
288
|
+
clearTimeout(restartPollTimer);
|
|
289
|
+
restartPollTimer = null;
|
|
290
|
+
}
|
|
208
291
|
if (escHandler) {
|
|
209
292
|
document.removeEventListener("keydown", escHandler);
|
|
210
293
|
escHandler = null;
|
|
@@ -215,8 +298,53 @@ window.__ModuleLoader__.load({
|
|
|
215
298
|
}
|
|
216
299
|
}
|
|
217
300
|
|
|
218
|
-
function
|
|
301
|
+
function waitForRestart({ title, body, errorEl, actions, cancel, confirm, t }) {
|
|
302
|
+
const startedAt = Date.now();
|
|
303
|
+
let sawUnavailable = false;
|
|
304
|
+
title.textContent = textOf(t, "restarting");
|
|
305
|
+
body.textContent = textOf(t, "restartWaiting");
|
|
306
|
+
actions.style.display = "none";
|
|
307
|
+
const check = async () => {
|
|
308
|
+
try {
|
|
309
|
+
const response = await fetch("/?dshRestartProbe=" + Date.now(), { cache: "no-store" });
|
|
310
|
+
if (response.ok && (sawUnavailable || Date.now() - startedAt >= 5_000)) {
|
|
311
|
+
globalThis.location.reload();
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
} catch {
|
|
315
|
+
sawUnavailable = true;
|
|
316
|
+
}
|
|
317
|
+
if (Date.now() - startedAt >= 60_000) {
|
|
318
|
+
restartPollTimer = null;
|
|
319
|
+
errorEl.textContent = textOf(t, "restartTimeout");
|
|
320
|
+
actions.style.removeProperty("display");
|
|
321
|
+
confirm.style.display = "none";
|
|
322
|
+
cancel.disabled = false;
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
restartPollTimer = setTimeout(check, 500);
|
|
326
|
+
};
|
|
327
|
+
restartPollTimer = setTimeout(check, 800);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function openModal(ctx, actionBtn, t, action) {
|
|
219
331
|
const endpoint = currentDshEndpoint();
|
|
332
|
+
const isRestart = action === "restart";
|
|
333
|
+
const keys = isRestart ? {
|
|
334
|
+
title: "restartDialogTitle",
|
|
335
|
+
body: "restartDialogBody",
|
|
336
|
+
confirm: "restartConfirm",
|
|
337
|
+
pending: "restarting",
|
|
338
|
+
button: "restartButton",
|
|
339
|
+
error: "restartErrorPrefix"
|
|
340
|
+
} : {
|
|
341
|
+
title: "dialogTitle",
|
|
342
|
+
body: "dialogBody",
|
|
343
|
+
confirm: "confirm",
|
|
344
|
+
pending: "exiting",
|
|
345
|
+
button: "exitButton",
|
|
346
|
+
error: "exitErrorPrefix"
|
|
347
|
+
};
|
|
220
348
|
const text = (key) => textOf(t, key, { endpoint });
|
|
221
349
|
closeModal();
|
|
222
350
|
overlayEl = document.createElement("div");
|
|
@@ -225,13 +353,13 @@ window.__ModuleLoader__.load({
|
|
|
225
353
|
panel.className = "dshExitModal";
|
|
226
354
|
panel.setAttribute("role", "dialog");
|
|
227
355
|
panel.setAttribute("aria-modal", "true");
|
|
228
|
-
panel.setAttribute("aria-label", text(
|
|
356
|
+
panel.setAttribute("aria-label", text(keys.title));
|
|
229
357
|
const title = document.createElement("h3");
|
|
230
358
|
title.className = "dshExitTitle";
|
|
231
|
-
title.textContent = text(
|
|
359
|
+
title.textContent = text(keys.title);
|
|
232
360
|
const body = document.createElement("p");
|
|
233
361
|
body.className = "dshExitBody";
|
|
234
|
-
body.textContent = text(
|
|
362
|
+
body.textContent = text(keys.body);
|
|
235
363
|
const errorEl = document.createElement("p");
|
|
236
364
|
errorEl.className = "dshExitError";
|
|
237
365
|
const actions = document.createElement("div");
|
|
@@ -242,48 +370,51 @@ window.__ModuleLoader__.load({
|
|
|
242
370
|
cancel.textContent = text("cancel");
|
|
243
371
|
const confirm = document.createElement("button");
|
|
244
372
|
confirm.type = "button";
|
|
245
|
-
confirm.className = "dshExitConfirm";
|
|
246
|
-
confirm.textContent = text(
|
|
373
|
+
confirm.className = "dshExitConfirm" + (isRestart ? " dshExitConfirm--restart" : "");
|
|
374
|
+
confirm.textContent = text(keys.confirm);
|
|
247
375
|
actions.append(cancel, confirm);
|
|
248
376
|
panel.append(title, body, errorEl, actions);
|
|
249
377
|
overlayEl.appendChild(panel);
|
|
250
378
|
|
|
251
379
|
escHandler = (event) => {
|
|
252
|
-
if (event.key === "Escape" && !
|
|
380
|
+
if (event.key === "Escape" && !cancel.disabled) closeModal();
|
|
253
381
|
};
|
|
254
382
|
document.addEventListener("keydown", escHandler);
|
|
255
383
|
overlayEl.addEventListener("click", (event) => {
|
|
256
|
-
if (event.target === overlayEl && !
|
|
384
|
+
if (event.target === overlayEl && !cancel.disabled) closeModal();
|
|
257
385
|
});
|
|
258
386
|
cancel.addEventListener("click", () => {
|
|
259
|
-
if (!
|
|
387
|
+
if (!cancel.disabled) closeModal();
|
|
260
388
|
});
|
|
261
389
|
confirm.addEventListener("click", () => {
|
|
262
|
-
exiting = true;
|
|
263
390
|
confirm.disabled = true;
|
|
264
391
|
cancel.disabled = true;
|
|
265
392
|
errorEl.textContent = "";
|
|
266
|
-
|
|
267
|
-
|
|
393
|
+
actionBtn.disabled = true;
|
|
394
|
+
actionBtn.setAttribute("aria-label", text(keys.pending));
|
|
268
395
|
Promise.resolve()
|
|
269
396
|
.then(() => {
|
|
270
397
|
const service = ctx.get("remote.dshExit");
|
|
271
398
|
if (service === undefined) throw new Error("dsh-exit 服务不可用(宿主未注册?)");
|
|
272
|
-
|
|
399
|
+
if (typeof service[action] !== "function") throw new Error("dsh-exit 不支持 " + action + " 操作");
|
|
400
|
+
return service[action]();
|
|
273
401
|
})
|
|
274
402
|
.then((result) => {
|
|
275
403
|
if (!result || result.ok !== true) {
|
|
276
|
-
throw new Error((result && result.error && result.error.message) || "
|
|
404
|
+
throw new Error((result && result.error && result.error.message) || action + " 调用失败");
|
|
405
|
+
}
|
|
406
|
+
if (isRestart) {
|
|
407
|
+
waitForRestart({ title, body, errorEl, actions, cancel, confirm, t });
|
|
408
|
+
} else {
|
|
409
|
+
closeModal();
|
|
277
410
|
}
|
|
278
|
-
closeModal();
|
|
279
411
|
})
|
|
280
412
|
.catch((reason) => {
|
|
281
|
-
exiting = false;
|
|
282
413
|
confirm.disabled = false;
|
|
283
414
|
cancel.disabled = false;
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
errorEl.textContent = text(
|
|
415
|
+
actionBtn.disabled = false;
|
|
416
|
+
actionBtn.setAttribute("aria-label", text(keys.button));
|
|
417
|
+
errorEl.textContent = text(keys.error) + (reason instanceof Error ? reason.message : String(reason));
|
|
287
418
|
});
|
|
288
419
|
});
|
|
289
420
|
document.body.appendChild(overlayEl);
|
|
@@ -316,7 +447,7 @@ window.__ModuleLoader__.load({
|
|
|
316
447
|
};
|
|
317
448
|
}
|
|
318
449
|
|
|
319
|
-
exports.inject = ["slots", "locale"];
|
|
450
|
+
exports.inject = ["slots", "locale", "remote"];
|
|
320
451
|
exports.apply = apply;
|
|
321
452
|
return module.exports;
|
|
322
453
|
}
|
package/lib/index.js
CHANGED
|
@@ -2,10 +2,15 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* dsh-exit:把「退出 dsh 宿主并释放端口」暴露给浏览器的一个宿主服务。
|
|
4
4
|
*
|
|
5
|
-
* 远程面:`remote.dshExit.exit()`(Typert Remote
|
|
6
|
-
* 调用后:先应答,再延迟 400ms
|
|
5
|
+
* 远程面:`remote.dshExit.exit()` / `remote.dshExit.restart()`(Typert Remote)。
|
|
6
|
+
* 调用后:先应答,再延迟 400ms 请求 dsh 完成有界清理并结束宿主进程;
|
|
7
|
+
* 重启时由独立辅助进程等待旧宿主退出,再在 Windows Terminal 新前台窗口中
|
|
8
|
+
* 用原启动参数拉起新宿主。
|
|
7
9
|
* 不操作宿主终端窗口或系统托盘。
|
|
8
10
|
*/
|
|
11
|
+
import { existsSync } from "node:fs";
|
|
12
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
13
|
+
import { join } from "node:path";
|
|
9
14
|
import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
|
|
10
15
|
|
|
11
16
|
const exitedSchema = {
|
|
@@ -30,6 +35,20 @@ const EXIT_INVOCATIONS = [
|
|
|
30
35
|
schema: exitedSchema
|
|
31
36
|
},
|
|
32
37
|
sourceLocation: { file: "@kesike/dsh-exit/lib/index.js", line: 1, column: 1 }
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: "@kesike/dsh-exit#dshExit/restart",
|
|
41
|
+
service: "dshExit",
|
|
42
|
+
namespace: "dshExit",
|
|
43
|
+
method: "restart",
|
|
44
|
+
invocation: { kind: "direct" },
|
|
45
|
+
parameters: [],
|
|
46
|
+
result: {
|
|
47
|
+
mode: "strict",
|
|
48
|
+
typeSymbol: "@kesike/dsh-exit/types#Restarted",
|
|
49
|
+
schema: exitedSchema
|
|
50
|
+
},
|
|
51
|
+
sourceLocation: { file: "@kesike/dsh-exit/lib/index.js", line: 1, column: 1 }
|
|
33
52
|
}
|
|
34
53
|
];
|
|
35
54
|
|
|
@@ -57,14 +76,192 @@ function markRemoteMethod(instance, method) {
|
|
|
57
76
|
Remote(method)(void 0, context);
|
|
58
77
|
}
|
|
59
78
|
|
|
79
|
+
/** 拒绝在 supervisor/Node IPC 管道下自脱离重启,避免新宿主失去外部生命周期管理。 */
|
|
80
|
+
function hasSupervisorChannel() {
|
|
81
|
+
return process.env.NODE_CHANNEL_FD !== undefined ||
|
|
82
|
+
process.env.NODE_UNIQUE_ID !== undefined ||
|
|
83
|
+
typeof process.send === "function";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 等待 detached 子进程确认已成功创建;异步 error 不能由 spawn() 的 try/catch 捕获。 */
|
|
87
|
+
function waitForSpawn(child, label) {
|
|
88
|
+
return new Promise((resolve, reject) => {
|
|
89
|
+
child.once("spawn", resolve);
|
|
90
|
+
child.once("error", (error) => {
|
|
91
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
92
|
+
reject(new Error(`${label} failed to start: ${detail}`, { cause: error }));
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 解析 PowerShell 7,避免新 Windows Terminal tab 落回 Windows PowerShell 5。 */
|
|
98
|
+
function resolvePowerShell7() {
|
|
99
|
+
const candidates = [
|
|
100
|
+
process.env.ProgramW6432 === undefined ? undefined : join(process.env.ProgramW6432, "PowerShell", "7", "pwsh.exe"),
|
|
101
|
+
process.env.ProgramFiles === undefined ? undefined : join(process.env.ProgramFiles, "PowerShell", "7", "pwsh.exe"),
|
|
102
|
+
process.env.LOCALAPPDATA === undefined ? undefined : join(process.env.LOCALAPPDATA, "Programs", "PowerShell", "7", "pwsh.exe")
|
|
103
|
+
].filter((candidate) => candidate !== undefined && existsSync(candidate));
|
|
104
|
+
if (candidates[0] !== undefined) return candidates[0];
|
|
105
|
+
const result = spawnSync("where.exe", ["pwsh.exe"], {
|
|
106
|
+
encoding: "utf8",
|
|
107
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
108
|
+
windowsHide: true
|
|
109
|
+
});
|
|
110
|
+
const executable = String(result.stdout || "")
|
|
111
|
+
.split(/\r?\n/)
|
|
112
|
+
.map((line) => line.trim())
|
|
113
|
+
.find(Boolean);
|
|
114
|
+
if (result.status === 0 && executable !== undefined) return executable;
|
|
115
|
+
throw new Error("dsh-exit: PowerShell 7 (pwsh.exe) is not available");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 通过 PowerShell 7 解析当前用户安装的 Windows Terminal 本体。 */
|
|
119
|
+
function resolveWindowsTerminal(shellPath) {
|
|
120
|
+
const command = [
|
|
121
|
+
"$package = Get-AppxPackage -Name 'Microsoft.WindowsTerminal' | Sort-Object Version -Descending | Select-Object -First 1",
|
|
122
|
+
"if ($null -eq $package) { exit 1 }",
|
|
123
|
+
"$terminal = Join-Path $package.InstallLocation 'WindowsTerminal.exe'",
|
|
124
|
+
"if (-not (Test-Path -LiteralPath $terminal)) { exit 1 }",
|
|
125
|
+
"Write-Output $terminal"
|
|
126
|
+
].join("; ");
|
|
127
|
+
const result = spawnSync(shellPath, [
|
|
128
|
+
"-NoLogo",
|
|
129
|
+
"-NoProfile",
|
|
130
|
+
"-NonInteractive",
|
|
131
|
+
"-ExecutionPolicy",
|
|
132
|
+
"Bypass",
|
|
133
|
+
"-Command",
|
|
134
|
+
command
|
|
135
|
+
], {
|
|
136
|
+
encoding: "utf8",
|
|
137
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
138
|
+
windowsHide: true
|
|
139
|
+
});
|
|
140
|
+
const terminal = String(result.stdout || "")
|
|
141
|
+
.split(/\r?\n/)
|
|
142
|
+
.map((line) => line.trim())
|
|
143
|
+
.find((line) => line.endsWith("WindowsTerminal.exe") && existsSync(line));
|
|
144
|
+
if (result.status === 0 && terminal !== undefined) return terminal;
|
|
145
|
+
const detail = String(result.stderr || "").trim();
|
|
146
|
+
throw new Error("dsh-exit: Windows Terminal (WindowsTerminal.exe) is not available" +
|
|
147
|
+
(detail === "" ? "" : ": " + detail));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** 将 Node 重启参数编码成 PowerShell 7 的 -EncodedCommand,避免 WT 二次解析。 */
|
|
151
|
+
function encodePowerShellLaunch(payload) {
|
|
152
|
+
const payloadBase64 = Buffer.from(JSON.stringify(payload), "utf8").toString("base64");
|
|
153
|
+
const nodeCommand = [
|
|
154
|
+
`$payloadJson = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${payloadBase64}'))`,
|
|
155
|
+
"$payload = $payloadJson | ConvertFrom-Json",
|
|
156
|
+
"Set-Location -LiteralPath $payload.cwd",
|
|
157
|
+
"$arguments = @($payload.args)",
|
|
158
|
+
"& $payload.execPath @arguments",
|
|
159
|
+
"exit $LASTEXITCODE"
|
|
160
|
+
].join("; ");
|
|
161
|
+
const nodeCommandBase64 = Buffer.from(nodeCommand, "utf16le").toString("base64");
|
|
162
|
+
return nodeCommandBase64;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const RESTART_HELPER = String.raw`
|
|
166
|
+
const { spawn } = require("node:child_process");
|
|
167
|
+
const payload = JSON.parse(process.argv[1]);
|
|
168
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
169
|
+
const isAlive = (pid) => {
|
|
170
|
+
try {
|
|
171
|
+
process.kill(pid, 0);
|
|
172
|
+
return true;
|
|
173
|
+
} catch (error) {
|
|
174
|
+
// ESRCH means the PID is gone. Treat every other failure as alive so
|
|
175
|
+
// permissions or transient Windows errors fail closed instead of
|
|
176
|
+
// starting a second host while the old one may still own the port.
|
|
177
|
+
return error?.code !== "ESRCH";
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
(async () => {
|
|
181
|
+
const deadline = Date.now() + payload.waitTimeoutMs;
|
|
182
|
+
while (isAlive(payload.parentPid) && Date.now() < deadline) await sleep(200);
|
|
183
|
+
if (isAlive(payload.parentPid)) process.exit(1);
|
|
184
|
+
await sleep(250);
|
|
185
|
+
const terminalLaunch = payload.launcher === "windows-terminal";
|
|
186
|
+
const launchArgs = terminalLaunch ? [
|
|
187
|
+
"-w",
|
|
188
|
+
"new",
|
|
189
|
+
"new-tab",
|
|
190
|
+
"--title",
|
|
191
|
+
payload.title,
|
|
192
|
+
"--startingDirectory",
|
|
193
|
+
payload.cwd,
|
|
194
|
+
"--inheritEnvironment",
|
|
195
|
+
payload.shellPath,
|
|
196
|
+
"-NoLogo",
|
|
197
|
+
"-NoProfile",
|
|
198
|
+
"-ExecutionPolicy",
|
|
199
|
+
"Bypass",
|
|
200
|
+
"-EncodedCommand",
|
|
201
|
+
payload.commandBase64
|
|
202
|
+
] : payload.args;
|
|
203
|
+
const child = spawn(terminalLaunch ? payload.terminalPath : payload.execPath, launchArgs, {
|
|
204
|
+
cwd: payload.cwd,
|
|
205
|
+
env: process.env,
|
|
206
|
+
detached: true,
|
|
207
|
+
stdio: "ignore",
|
|
208
|
+
windowsHide: terminalLaunch ? false : true
|
|
209
|
+
});
|
|
210
|
+
child.once("error", () => {
|
|
211
|
+
process.exitCode = 1;
|
|
212
|
+
});
|
|
213
|
+
child.once("spawn", () => child.unref());
|
|
214
|
+
})().catch(() => {
|
|
215
|
+
process.exitCode = 1;
|
|
216
|
+
});
|
|
217
|
+
`;
|
|
218
|
+
|
|
219
|
+
/** 启动一个与旧宿主生命周期解耦的 helper;Windows 下直接打开新的可见 Windows Terminal 窗口。 */
|
|
220
|
+
function scheduleRestart() {
|
|
221
|
+
if (hasSupervisorChannel()) {
|
|
222
|
+
throw new Error("dsh-exit: refusing self-restart under supervisor or Node IPC");
|
|
223
|
+
}
|
|
224
|
+
const launcher = process.platform === "win32" ? "windows-terminal" : "direct";
|
|
225
|
+
const env = { ...process.env };
|
|
226
|
+
delete env.NODE_CHANNEL_FD;
|
|
227
|
+
delete env.NODE_UNIQUE_ID;
|
|
228
|
+
const restartPayload = {
|
|
229
|
+
parentPid: process.pid,
|
|
230
|
+
execPath: process.execPath,
|
|
231
|
+
args: [...process.execArgv, ...process.argv.slice(1)],
|
|
232
|
+
cwd: process.cwd(),
|
|
233
|
+
};
|
|
234
|
+
const shellPath = launcher === "windows-terminal" ? resolvePowerShell7() : undefined;
|
|
235
|
+
const terminalPath = launcher === "windows-terminal" ? resolveWindowsTerminal(shellPath) : undefined;
|
|
236
|
+
const payload = JSON.stringify({
|
|
237
|
+
...restartPayload,
|
|
238
|
+
launcher,
|
|
239
|
+
shellPath,
|
|
240
|
+
terminalPath,
|
|
241
|
+
commandBase64: launcher === "windows-terminal" ? encodePowerShellLaunch(restartPayload) : undefined,
|
|
242
|
+
title: "dsh-web",
|
|
243
|
+
waitTimeoutMs: 15_000
|
|
244
|
+
});
|
|
245
|
+
const helper = spawn(process.execPath, ["-e", RESTART_HELPER, payload], {
|
|
246
|
+
env,
|
|
247
|
+
detached: true,
|
|
248
|
+
stdio: "ignore",
|
|
249
|
+
windowsHide: true
|
|
250
|
+
});
|
|
251
|
+
const started = waitForSpawn(helper, "restart helper");
|
|
252
|
+
helper.unref();
|
|
253
|
+
return started;
|
|
254
|
+
}
|
|
255
|
+
|
|
60
256
|
class DshExitService extends TypertRemoteService {
|
|
61
257
|
static name = "dshExit";
|
|
62
258
|
static inject = ["typert"];
|
|
63
|
-
|
|
259
|
+
closing = null;
|
|
64
260
|
|
|
65
261
|
constructor(ctx) {
|
|
66
262
|
super(ctx, "dshExit");
|
|
67
263
|
markRemoteMethod(this, "exit");
|
|
264
|
+
markRemoteMethod(this, "restart");
|
|
68
265
|
const existing = ctx.get("typert");
|
|
69
266
|
if (existing !== undefined) {
|
|
70
267
|
existing.register(EXIT_TYPERT);
|
|
@@ -75,12 +272,41 @@ class DshExitService extends TypertRemoteService {
|
|
|
75
272
|
}
|
|
76
273
|
}
|
|
77
274
|
|
|
275
|
+
scheduleRestart() {
|
|
276
|
+
return scheduleRestart();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** 让远程响应先写回浏览器,再通过 dsh 的 appExit 执行有界资源清理。 */
|
|
280
|
+
requestShutdown() {
|
|
281
|
+
const appExit = this.ctx.get("appExit");
|
|
282
|
+
setTimeout(() => {
|
|
283
|
+
if (typeof appExit === "function") appExit(0);
|
|
284
|
+
else process.exit(0);
|
|
285
|
+
}, 400);
|
|
286
|
+
}
|
|
287
|
+
|
|
78
288
|
/** 结束宿主并释放端口;不触碰宿主终端窗口。 */
|
|
79
289
|
async exit() {
|
|
80
|
-
if (this.
|
|
81
|
-
this.
|
|
290
|
+
if (this.closing !== null) return true;
|
|
291
|
+
this.closing = "exit";
|
|
82
292
|
this.ctx.logger?.info?.("dsh-exit: exit requested from the web UI");
|
|
83
|
-
|
|
293
|
+
this.requestShutdown();
|
|
294
|
+
return true;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** 清理并结束当前宿主,待旧进程退出后使用相同 Node 参数重新启动。 */
|
|
298
|
+
async restart() {
|
|
299
|
+
if (this.closing !== null) return true;
|
|
300
|
+
this.closing = "restart";
|
|
301
|
+
try {
|
|
302
|
+
await this.scheduleRestart();
|
|
303
|
+
} catch (error) {
|
|
304
|
+
this.closing = null;
|
|
305
|
+
this.ctx.logger?.error?.("dsh-exit: failed to schedule restart", error);
|
|
306
|
+
throw error;
|
|
307
|
+
}
|
|
308
|
+
this.ctx.logger?.info?.("dsh-exit: restart requested from the web UI");
|
|
309
|
+
this.requestShutdown();
|
|
84
310
|
return true;
|
|
85
311
|
}
|
|
86
312
|
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kesike/dsh-exit",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "在 dsh web 界面提供退出按钮:确认后结束宿主进程并释放端口",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "node --test"
|
|
8
|
+
},
|
|
6
9
|
"keywords": [
|
|
7
10
|
"dsh",
|
|
8
11
|
"deepseek-harness",
|
|
@@ -29,6 +32,13 @@
|
|
|
29
32
|
"engines": {
|
|
30
33
|
"node": ">=22.19.0 || >=24.0.0"
|
|
31
34
|
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"@deepseek-ai/dsh-api-remotes": "^0.1.1-rc.2",
|
|
37
|
+
"@deepseek-ai/dsh-client-locale": "^0.1.1-rc.2",
|
|
38
|
+
"@deepseek-ai/dsh-client-runtime": "^0.1.1-rc.2",
|
|
39
|
+
"@deepseek-ai/dsh-client-ui-settings": "^0.1.1-rc.2",
|
|
40
|
+
"@deepseek-ai/dsh-typert-protocol": "^0.1.1-rc.2"
|
|
41
|
+
},
|
|
32
42
|
"files": [
|
|
33
43
|
"lib",
|
|
34
44
|
"cordis.patch.yml",
|
|
@@ -44,8 +54,9 @@
|
|
|
44
54
|
},
|
|
45
55
|
"client": {
|
|
46
56
|
"inject": [
|
|
57
|
+
"@deepseek-ai/dsh-api-remotes",
|
|
47
58
|
"@deepseek-ai/dsh-client-runtime",
|
|
48
|
-
"@deepseek-ai/dsh-client-ui-
|
|
59
|
+
"@deepseek-ai/dsh-client-ui-settings",
|
|
49
60
|
"@deepseek-ai/dsh-client-locale"
|
|
50
61
|
],
|
|
51
62
|
"platform": "web"
|