@co0ontty/wand 1.22.0 → 1.24.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/dist/cli.js +111 -2
- package/dist/pidfile.d.ts +38 -0
- package/dist/pidfile.js +117 -0
- package/dist/tui/attach.d.ts +18 -0
- package/dist/tui/attach.js +306 -0
- package/dist/tui/commands.d.ts +60 -0
- package/dist/tui/commands.js +505 -0
- package/dist/tui/index.js +171 -3
- package/dist/tui/ipc-client.d.ts +27 -0
- package/dist/tui/ipc-client.js +153 -0
- package/dist/tui/ipc-protocol.d.ts +50 -0
- package/dist/tui/ipc-protocol.js +7 -0
- package/dist/tui/ipc-server.d.ts +17 -0
- package/dist/tui/ipc-server.js +100 -0
- package/dist/tui/layout.d.ts +44 -0
- package/dist/tui/layout.js +365 -11
- package/dist/tui/service-panel.d.ts +19 -0
- package/dist/tui/service-panel.js +108 -0
- package/dist/tui/snapshot.d.ts +26 -0
- package/dist/tui/snapshot.js +58 -0
- package/dist/web-ui/content/scripts.js +253 -125
- package/dist/web-ui/content/styles.css +253 -141
- package/package.json +1 -1
package/dist/tui/index.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { checkUpdate, copyToClipboard, installService, installUpdate, isServiceInstalled, openInBrowser, restartSelf, uninstallService, } from "./commands.js";
|
|
1
2
|
import { buildLayout } from "./layout.js";
|
|
2
3
|
import { installLogBus, restoreLogBus } from "./log-bus.js";
|
|
3
4
|
import { formatSession, sortRows } from "./session-formatter.js";
|
|
5
|
+
import { openServicePanel } from "./service-panel.js";
|
|
4
6
|
/** 触发 sessions 列表重渲的事件类型。output 太频繁,不订阅。 */
|
|
5
7
|
const SESSIONS_REFRESH_EVENTS = new Set(["status", "started", "ended", "task"]);
|
|
6
8
|
const RELATIVE_TIME_TICK_MS = 5_000;
|
|
@@ -8,6 +10,7 @@ export function startTui(deps) {
|
|
|
8
10
|
const layout = buildLayout();
|
|
9
11
|
let active = true;
|
|
10
12
|
let stopping = false;
|
|
13
|
+
const startedAtMs = Date.now();
|
|
11
14
|
const handle = {
|
|
12
15
|
get isActive() { return active; },
|
|
13
16
|
stop,
|
|
@@ -32,6 +35,9 @@ export function startTui(deps) {
|
|
|
32
35
|
dbPath: deps.dbPath,
|
|
33
36
|
orphanRecoveredCount: deps.orphanRecoveredCount,
|
|
34
37
|
sessionCounts: counts,
|
|
38
|
+
startedAtMs,
|
|
39
|
+
rssBytes: safeRss(),
|
|
40
|
+
serviceInstalled: safeServiceInstalled(),
|
|
35
41
|
};
|
|
36
42
|
};
|
|
37
43
|
function collectSessions() {
|
|
@@ -94,12 +100,145 @@ export function startTui(deps) {
|
|
|
94
100
|
refreshAll();
|
|
95
101
|
}, RELATIVE_TIME_TICK_MS);
|
|
96
102
|
tickTimer.unref?.();
|
|
97
|
-
//
|
|
98
|
-
|
|
103
|
+
// 服务面板打开时,屏幕级快捷键全部让位给面板自身按键。
|
|
104
|
+
const idle = () => !layout.isServicePanelOpen();
|
|
105
|
+
// —— 基本键位 ——
|
|
106
|
+
layout.screen.key(["q", "Q"], () => { if (idle())
|
|
107
|
+
void stop("user"); });
|
|
99
108
|
layout.screen.key(["C-c"], () => { void stop("user"); });
|
|
100
|
-
layout.screen.key(["r"
|
|
109
|
+
layout.screen.key(["r"], () => {
|
|
110
|
+
if (!idle())
|
|
111
|
+
return;
|
|
112
|
+
refreshAll();
|
|
113
|
+
layout.showToast("已刷新", "info", 1500);
|
|
114
|
+
});
|
|
115
|
+
layout.screen.key(["l", "L"], () => {
|
|
116
|
+
if (!idle())
|
|
117
|
+
return;
|
|
118
|
+
layout.clearLogs();
|
|
119
|
+
layout.showToast("日志已清空", "info", 1500);
|
|
120
|
+
});
|
|
121
|
+
layout.screen.key(["?", "h", "H"], () => {
|
|
122
|
+
if (!idle())
|
|
123
|
+
return;
|
|
124
|
+
const visible = layout.toggleHelp();
|
|
125
|
+
if (!visible)
|
|
126
|
+
refreshAll();
|
|
127
|
+
});
|
|
128
|
+
// —— 运维快捷键 ——
|
|
129
|
+
layout.screen.key(["g", "G"], () => { if (idle())
|
|
130
|
+
openServicePanel({ layout, configPath: deps.configPath }); });
|
|
131
|
+
layout.screen.key(["S-r"], () => { if (idle())
|
|
132
|
+
void handleRestart(); });
|
|
133
|
+
layout.screen.key(["u", "U"], () => { if (idle())
|
|
134
|
+
void handleUpdate(); });
|
|
135
|
+
layout.screen.key(["o", "O"], () => { if (idle())
|
|
136
|
+
handleOpenBrowser(); });
|
|
137
|
+
layout.screen.key(["c", "C"], () => { if (idle())
|
|
138
|
+
handleCopyUrl(); });
|
|
139
|
+
layout.screen.key(["s"], () => { if (idle())
|
|
140
|
+
void handleInstallService(); });
|
|
141
|
+
layout.screen.key(["S-s"], () => { if (idle())
|
|
142
|
+
void handleUninstallService(); });
|
|
101
143
|
// 首次渲染
|
|
102
144
|
refreshAll();
|
|
145
|
+
// —— 操作处理函数 ——
|
|
146
|
+
async function handleRestart() {
|
|
147
|
+
const ok = await layout.confirm({
|
|
148
|
+
title: "重启 wand",
|
|
149
|
+
body: "将派生新进程并退出当前进程,活跃会话会因 PTY 中断而中止,是否继续?",
|
|
150
|
+
});
|
|
151
|
+
if (!ok)
|
|
152
|
+
return;
|
|
153
|
+
layout.showToast("正在重启…", "info", 5000);
|
|
154
|
+
// 让 toast 有时间渲染,再触发 restart
|
|
155
|
+
setTimeout(() => {
|
|
156
|
+
const r = restartSelf();
|
|
157
|
+
if (!r.ok)
|
|
158
|
+
layout.showToast(r.message, "error", 4000);
|
|
159
|
+
}, 200);
|
|
160
|
+
}
|
|
161
|
+
async function handleUpdate() {
|
|
162
|
+
layout.showToast("正在检查更新…", "info", 2000);
|
|
163
|
+
const info = await runOffMicrotask(() => checkUpdate(deps.version));
|
|
164
|
+
if (!info.latest) {
|
|
165
|
+
layout.showToast("无法连接到 npm registry", "error", 3500);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (!info.hasUpdate) {
|
|
169
|
+
layout.showToast(`已是最新版本 (v${info.current})`, "success", 3000);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const go = await layout.confirm({
|
|
173
|
+
title: "发现新版本",
|
|
174
|
+
body: `当前 v${info.current} → 最新 v${info.latest},是否立即升级?`,
|
|
175
|
+
yes: "回车 / y 安装",
|
|
176
|
+
no: "Esc / n 取消",
|
|
177
|
+
});
|
|
178
|
+
if (!go)
|
|
179
|
+
return;
|
|
180
|
+
layout.showToast("正在执行 npm install -g …", "info", 5000);
|
|
181
|
+
const r = await runOffMicrotask(() => installUpdate());
|
|
182
|
+
layout.showToast(r.message, r.ok ? "success" : "error", 5000);
|
|
183
|
+
if (r.detail)
|
|
184
|
+
layout.showDetail(r.ok ? "更新输出" : "更新失败", r.detail);
|
|
185
|
+
}
|
|
186
|
+
function handleOpenBrowser() {
|
|
187
|
+
const url = deps.urls[0]?.url;
|
|
188
|
+
if (!url) {
|
|
189
|
+
layout.showToast("没有可用 URL", "warn", 2000);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const r = openInBrowser(url);
|
|
193
|
+
layout.showToast(r.message, r.ok ? "success" : "error", 2500);
|
|
194
|
+
}
|
|
195
|
+
function handleCopyUrl() {
|
|
196
|
+
const url = deps.urls[0]?.url;
|
|
197
|
+
if (!url) {
|
|
198
|
+
layout.showToast("没有可用 URL", "warn", 2000);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const r = copyToClipboard(url);
|
|
202
|
+
layout.showToast(r.message, r.ok ? "success" : "error", 2500);
|
|
203
|
+
}
|
|
204
|
+
async function handleInstallService() {
|
|
205
|
+
if (isServiceInstalled()) {
|
|
206
|
+
layout.showToast("服务已安装,按 Shift+S 卸载", "warn", 2500);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const ok = await layout.confirm({
|
|
210
|
+
title: "注册为系统服务",
|
|
211
|
+
body: process.platform === "linux"
|
|
212
|
+
? "将写入 ~/.config/systemd/user/wand.service 并 systemctl --user enable --now。"
|
|
213
|
+
: process.platform === "darwin"
|
|
214
|
+
? "将写入 ~/Library/LaunchAgents/com.wand.web.plist 并 launchctl load。"
|
|
215
|
+
: "当前平台暂不支持。",
|
|
216
|
+
});
|
|
217
|
+
if (!ok)
|
|
218
|
+
return;
|
|
219
|
+
const r = await runOffMicrotask(() => installService({ configPath: deps.configPath }));
|
|
220
|
+
layout.showToast(r.message, r.ok ? "success" : "error", 5000);
|
|
221
|
+
if (r.detail)
|
|
222
|
+
layout.showDetail(r.ok ? "服务安装详情" : "服务安装失败", r.detail);
|
|
223
|
+
refreshAll();
|
|
224
|
+
}
|
|
225
|
+
async function handleUninstallService() {
|
|
226
|
+
if (!isServiceInstalled()) {
|
|
227
|
+
layout.showToast("当前未安装系统服务", "warn", 2500);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const ok = await layout.confirm({
|
|
231
|
+
title: "卸载系统服务",
|
|
232
|
+
body: "将禁用并删除 wand 的 systemd / launchd 配置,确认继续?",
|
|
233
|
+
});
|
|
234
|
+
if (!ok)
|
|
235
|
+
return;
|
|
236
|
+
const r = await runOffMicrotask(() => uninstallService());
|
|
237
|
+
layout.showToast(r.message, r.ok ? "success" : "error", 4000);
|
|
238
|
+
if (r.detail)
|
|
239
|
+
layout.showDetail(r.ok ? "服务卸载详情" : "服务卸载失败", r.detail);
|
|
240
|
+
refreshAll();
|
|
241
|
+
}
|
|
103
242
|
async function stop(reason) {
|
|
104
243
|
if (stopping || !active)
|
|
105
244
|
return;
|
|
@@ -136,3 +275,32 @@ function safeStructuredList(mgr) {
|
|
|
136
275
|
return [];
|
|
137
276
|
}
|
|
138
277
|
}
|
|
278
|
+
function safeRss() {
|
|
279
|
+
try {
|
|
280
|
+
return process.memoryUsage().rss;
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
return 0;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
function safeServiceInstalled() {
|
|
287
|
+
try {
|
|
288
|
+
return isServiceInstalled();
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
return false;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
/** 把同步阻塞操作放到下一 microtask,给 TUI 留出一帧把 toast 画出来。 */
|
|
295
|
+
function runOffMicrotask(fn) {
|
|
296
|
+
return new Promise((resolve, reject) => {
|
|
297
|
+
setImmediate(() => {
|
|
298
|
+
try {
|
|
299
|
+
resolve(fn());
|
|
300
|
+
}
|
|
301
|
+
catch (err) {
|
|
302
|
+
reject(err);
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IPC 客户端:连接主进程的 wand.sock,发送 snapshot/ping,等待行内 JSON 应答。
|
|
3
|
+
*
|
|
4
|
+
* 极简实现:单连接、并发请求按 id 路由、断线自动重连(指数退避)。
|
|
5
|
+
*/
|
|
6
|
+
import { EventEmitter } from "node:events";
|
|
7
|
+
import { IpcSnapshotData } from "./ipc-protocol.js";
|
|
8
|
+
export declare class IpcClient extends EventEmitter {
|
|
9
|
+
private readonly socketPath;
|
|
10
|
+
private socket;
|
|
11
|
+
private buf;
|
|
12
|
+
private pending;
|
|
13
|
+
private nextId;
|
|
14
|
+
private closed;
|
|
15
|
+
private reconnectMs;
|
|
16
|
+
private connected;
|
|
17
|
+
constructor(socketPath: string);
|
|
18
|
+
start(): void;
|
|
19
|
+
isConnected(): boolean;
|
|
20
|
+
close(): void;
|
|
21
|
+
snapshot(): Promise<IpcSnapshotData>;
|
|
22
|
+
ping(): Promise<boolean>;
|
|
23
|
+
shutdownDaemon(): Promise<boolean>;
|
|
24
|
+
private connect;
|
|
25
|
+
private handleLine;
|
|
26
|
+
private request;
|
|
27
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IPC 客户端:连接主进程的 wand.sock,发送 snapshot/ping,等待行内 JSON 应答。
|
|
3
|
+
*
|
|
4
|
+
* 极简实现:单连接、并发请求按 id 路由、断线自动重连(指数退避)。
|
|
5
|
+
*/
|
|
6
|
+
import net from "node:net";
|
|
7
|
+
import { EventEmitter } from "node:events";
|
|
8
|
+
const REQ_TIMEOUT_MS = 5_000;
|
|
9
|
+
const RECONNECT_INITIAL_MS = 250;
|
|
10
|
+
const RECONNECT_MAX_MS = 5_000;
|
|
11
|
+
export class IpcClient extends EventEmitter {
|
|
12
|
+
socketPath;
|
|
13
|
+
socket = null;
|
|
14
|
+
buf = "";
|
|
15
|
+
pending = new Map();
|
|
16
|
+
nextId = 1;
|
|
17
|
+
closed = false;
|
|
18
|
+
reconnectMs = RECONNECT_INITIAL_MS;
|
|
19
|
+
connected = false;
|
|
20
|
+
constructor(socketPath) {
|
|
21
|
+
super();
|
|
22
|
+
this.socketPath = socketPath;
|
|
23
|
+
}
|
|
24
|
+
start() {
|
|
25
|
+
this.connect();
|
|
26
|
+
}
|
|
27
|
+
isConnected() {
|
|
28
|
+
return this.connected;
|
|
29
|
+
}
|
|
30
|
+
close() {
|
|
31
|
+
this.closed = true;
|
|
32
|
+
for (const [, p] of this.pending) {
|
|
33
|
+
clearTimeout(p.timer);
|
|
34
|
+
p.reject(new Error("client closed"));
|
|
35
|
+
}
|
|
36
|
+
this.pending.clear();
|
|
37
|
+
try {
|
|
38
|
+
this.socket?.destroy();
|
|
39
|
+
}
|
|
40
|
+
catch { /* noop */ }
|
|
41
|
+
this.socket = null;
|
|
42
|
+
}
|
|
43
|
+
async snapshot() {
|
|
44
|
+
const resp = await this.request({ cmd: "snapshot" });
|
|
45
|
+
if (!resp.ok)
|
|
46
|
+
throw new Error(resp.error);
|
|
47
|
+
return resp.data;
|
|
48
|
+
}
|
|
49
|
+
async ping() {
|
|
50
|
+
try {
|
|
51
|
+
const resp = await this.request({ cmd: "ping" });
|
|
52
|
+
if (!resp.ok)
|
|
53
|
+
return false;
|
|
54
|
+
return resp.data.pong === true;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
async shutdownDaemon() {
|
|
61
|
+
try {
|
|
62
|
+
const resp = await this.request({ cmd: "shutdown" });
|
|
63
|
+
return resp.ok === true;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// ─── 内部 ────────────────────────────────────────────────────────────
|
|
70
|
+
connect() {
|
|
71
|
+
if (this.closed)
|
|
72
|
+
return;
|
|
73
|
+
const sock = net.createConnection({ path: this.socketPath });
|
|
74
|
+
this.socket = sock;
|
|
75
|
+
sock.setEncoding("utf8");
|
|
76
|
+
sock.on("connect", () => {
|
|
77
|
+
this.connected = true;
|
|
78
|
+
this.reconnectMs = RECONNECT_INITIAL_MS;
|
|
79
|
+
this.emit("connect");
|
|
80
|
+
});
|
|
81
|
+
sock.on("data", (chunk) => {
|
|
82
|
+
this.buf += chunk;
|
|
83
|
+
let idx;
|
|
84
|
+
while ((idx = this.buf.indexOf("\n")) >= 0) {
|
|
85
|
+
const line = this.buf.slice(0, idx).trim();
|
|
86
|
+
this.buf = this.buf.slice(idx + 1);
|
|
87
|
+
if (line)
|
|
88
|
+
this.handleLine(line);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
const onClose = () => {
|
|
92
|
+
this.connected = false;
|
|
93
|
+
this.emit("disconnect");
|
|
94
|
+
// 把还没完成的请求拒掉,避免永远卡住
|
|
95
|
+
for (const [, p] of this.pending) {
|
|
96
|
+
clearTimeout(p.timer);
|
|
97
|
+
p.reject(new Error("ipc disconnected"));
|
|
98
|
+
}
|
|
99
|
+
this.pending.clear();
|
|
100
|
+
if (this.closed)
|
|
101
|
+
return;
|
|
102
|
+
const delay = this.reconnectMs;
|
|
103
|
+
this.reconnectMs = Math.min(this.reconnectMs * 2, RECONNECT_MAX_MS);
|
|
104
|
+
const timer = setTimeout(() => this.connect(), delay);
|
|
105
|
+
timer.unref?.();
|
|
106
|
+
};
|
|
107
|
+
sock.on("close", onClose);
|
|
108
|
+
sock.on("error", (err) => {
|
|
109
|
+
this.emit("error", err);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
handleLine(line) {
|
|
113
|
+
let msg = null;
|
|
114
|
+
try {
|
|
115
|
+
msg = JSON.parse(line);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (!msg || typeof msg.id !== "string")
|
|
121
|
+
return;
|
|
122
|
+
const p = this.pending.get(msg.id);
|
|
123
|
+
if (!p)
|
|
124
|
+
return;
|
|
125
|
+
this.pending.delete(msg.id);
|
|
126
|
+
clearTimeout(p.timer);
|
|
127
|
+
p.resolve(msg);
|
|
128
|
+
}
|
|
129
|
+
request(payload) {
|
|
130
|
+
return new Promise((resolve, reject) => {
|
|
131
|
+
if (!this.socket || !this.connected) {
|
|
132
|
+
reject(new Error("ipc not connected"));
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const id = String(this.nextId++);
|
|
136
|
+
const timer = setTimeout(() => {
|
|
137
|
+
this.pending.delete(id);
|
|
138
|
+
reject(new Error("ipc request timeout"));
|
|
139
|
+
}, REQ_TIMEOUT_MS);
|
|
140
|
+
timer.unref?.();
|
|
141
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
142
|
+
const req = { id, cmd: payload.cmd };
|
|
143
|
+
try {
|
|
144
|
+
this.socket.write(JSON.stringify(req) + "\n");
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
this.pending.delete(id);
|
|
148
|
+
clearTimeout(timer);
|
|
149
|
+
reject(err);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 主进程 ↔ attach 客户端的控制平面 IPC 协议。
|
|
3
|
+
*
|
|
4
|
+
* 帧格式:单行 JSON,以 `\n` 分割。客户端按行读,服务端按行解析。
|
|
5
|
+
* 不做长度前缀,因为单条 message 受 snapshot 大小限制,远低于 socket 缓冲区。
|
|
6
|
+
*/
|
|
7
|
+
import { SessionRow } from "./session-formatter.js";
|
|
8
|
+
export interface IpcSnapshotHeader {
|
|
9
|
+
version: string;
|
|
10
|
+
url: string;
|
|
11
|
+
scheme: "HTTP" | "HTTPS";
|
|
12
|
+
bindAddr: string;
|
|
13
|
+
configPath: string;
|
|
14
|
+
dbPath: string;
|
|
15
|
+
orphanRecoveredCount: number;
|
|
16
|
+
sessionCounts: {
|
|
17
|
+
active: number;
|
|
18
|
+
archived: number;
|
|
19
|
+
total: number;
|
|
20
|
+
};
|
|
21
|
+
/** 主进程启动时间 (ms epoch)。 */
|
|
22
|
+
startedAtMs: number;
|
|
23
|
+
/** 主进程 RSS。 */
|
|
24
|
+
rssBytes: number;
|
|
25
|
+
/** 主进程 PID。 */
|
|
26
|
+
pid: number;
|
|
27
|
+
}
|
|
28
|
+
export interface IpcSnapshotData {
|
|
29
|
+
header: IpcSnapshotHeader;
|
|
30
|
+
sessions: SessionRow[];
|
|
31
|
+
}
|
|
32
|
+
export interface IpcRequest {
|
|
33
|
+
id: string;
|
|
34
|
+
cmd: "snapshot" | "ping" | "shutdown";
|
|
35
|
+
}
|
|
36
|
+
export interface IpcResponseOk<T = unknown> {
|
|
37
|
+
id: string;
|
|
38
|
+
ok: true;
|
|
39
|
+
data: T;
|
|
40
|
+
}
|
|
41
|
+
export interface IpcResponseErr {
|
|
42
|
+
id: string;
|
|
43
|
+
ok: false;
|
|
44
|
+
error: string;
|
|
45
|
+
}
|
|
46
|
+
export type IpcResponse<T = unknown> = IpcResponseOk<T> | IpcResponseErr;
|
|
47
|
+
export type SnapshotResponse = IpcResponseOk<IpcSnapshotData>;
|
|
48
|
+
export type PingResponse = IpcResponseOk<{
|
|
49
|
+
pong: true;
|
|
50
|
+
}>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IPC 服务端:监听 Unix socket,处理 attach 客户端发来的 snapshot/ping 请求。
|
|
3
|
+
*
|
|
4
|
+
* Windows 不支持(socketPath 返回空字符串),调用方应跳过 startIpcServer。
|
|
5
|
+
*/
|
|
6
|
+
import { IpcSnapshotData } from "./ipc-protocol.js";
|
|
7
|
+
export interface IpcServerDeps {
|
|
8
|
+
socketPath: string;
|
|
9
|
+
/** 由 TUI 上层组装。同步函数,避免在 socket callback 里做异步阻塞。 */
|
|
10
|
+
snapshotProvider: () => IpcSnapshotData;
|
|
11
|
+
/** attach 客户端发起 shutdown 时调用;返回 promise,resolve 后服务端退出。 */
|
|
12
|
+
onShutdown?: () => void | Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
export interface IpcServerHandle {
|
|
15
|
+
close(): Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
export declare function startIpcServer(deps: IpcServerDeps): IpcServerHandle | null;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IPC 服务端:监听 Unix socket,处理 attach 客户端发来的 snapshot/ping 请求。
|
|
3
|
+
*
|
|
4
|
+
* Windows 不支持(socketPath 返回空字符串),调用方应跳过 startIpcServer。
|
|
5
|
+
*/
|
|
6
|
+
import net from "node:net";
|
|
7
|
+
import { existsSync, unlinkSync } from "node:fs";
|
|
8
|
+
export function startIpcServer(deps) {
|
|
9
|
+
if (!deps.socketPath)
|
|
10
|
+
return null;
|
|
11
|
+
// 残留 socket 文件可能让 net.listen 直接报 EADDRINUSE。先尝试清理。
|
|
12
|
+
if (existsSync(deps.socketPath)) {
|
|
13
|
+
try {
|
|
14
|
+
unlinkSync(deps.socketPath);
|
|
15
|
+
}
|
|
16
|
+
catch { /* noop */ }
|
|
17
|
+
}
|
|
18
|
+
const server = net.createServer((conn) => {
|
|
19
|
+
let buf = "";
|
|
20
|
+
conn.on("data", (chunk) => {
|
|
21
|
+
buf += chunk.toString("utf8");
|
|
22
|
+
let idx;
|
|
23
|
+
while ((idx = buf.indexOf("\n")) >= 0) {
|
|
24
|
+
const line = buf.slice(0, idx).trim();
|
|
25
|
+
buf = buf.slice(idx + 1);
|
|
26
|
+
if (!line)
|
|
27
|
+
continue;
|
|
28
|
+
handleLine(line, conn);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
conn.on("error", () => { });
|
|
32
|
+
});
|
|
33
|
+
function handleLine(line, conn) {
|
|
34
|
+
let req = null;
|
|
35
|
+
try {
|
|
36
|
+
req = JSON.parse(line);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return; // 直接丢弃损坏帧
|
|
40
|
+
}
|
|
41
|
+
if (!req || typeof req.id !== "string" || typeof req.cmd !== "string")
|
|
42
|
+
return;
|
|
43
|
+
try {
|
|
44
|
+
switch (req.cmd) {
|
|
45
|
+
case "ping": {
|
|
46
|
+
const resp = { id: req.id, ok: true, data: { pong: true } };
|
|
47
|
+
conn.write(JSON.stringify(resp) + "\n");
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
case "snapshot": {
|
|
51
|
+
const data = deps.snapshotProvider();
|
|
52
|
+
const resp = { id: req.id, ok: true, data };
|
|
53
|
+
conn.write(JSON.stringify(resp) + "\n");
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
case "shutdown": {
|
|
57
|
+
const ack = { id: req.id, ok: true, data: { accepted: true } };
|
|
58
|
+
conn.write(JSON.stringify(ack) + "\n");
|
|
59
|
+
// 给客户端一个 tick 把 ack 拿到再触发 shutdown
|
|
60
|
+
setImmediate(() => { void deps.onShutdown?.(); });
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
default: {
|
|
64
|
+
const err = { id: req.id, ok: false, error: `unknown cmd: ${req.cmd}` };
|
|
65
|
+
conn.write(JSON.stringify(err) + "\n");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
catch (e) {
|
|
70
|
+
const err = {
|
|
71
|
+
id: req.id,
|
|
72
|
+
ok: false,
|
|
73
|
+
error: e instanceof Error ? e.message : String(e),
|
|
74
|
+
};
|
|
75
|
+
try {
|
|
76
|
+
conn.write(JSON.stringify(err) + "\n");
|
|
77
|
+
}
|
|
78
|
+
catch { /* peer 走了 */ }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
server.on("error", (err) => {
|
|
82
|
+
// 不要崩进程;attach 模式不可用也不影响主功能。
|
|
83
|
+
process.stderr.write(`[wand] IPC server error: ${err.message}\n`);
|
|
84
|
+
});
|
|
85
|
+
server.listen(deps.socketPath);
|
|
86
|
+
return {
|
|
87
|
+
close: async () => {
|
|
88
|
+
await new Promise((resolve) => {
|
|
89
|
+
server.close(() => resolve());
|
|
90
|
+
// 兜底:1s 内强制 resolve
|
|
91
|
+
setTimeout(() => resolve(), 1000).unref?.();
|
|
92
|
+
});
|
|
93
|
+
try {
|
|
94
|
+
if (existsSync(deps.socketPath))
|
|
95
|
+
unlinkSync(deps.socketPath);
|
|
96
|
+
}
|
|
97
|
+
catch { /* noop */ }
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
package/dist/tui/layout.d.ts
CHANGED
|
@@ -13,6 +13,39 @@ export interface HeaderInfo {
|
|
|
13
13
|
archived: number;
|
|
14
14
|
total: number;
|
|
15
15
|
};
|
|
16
|
+
/** 服务启动时间(ms epoch),用于计算 uptime。 */
|
|
17
|
+
startedAtMs: number;
|
|
18
|
+
/** 当前进程 RSS 内存(字节),由调用方传入。 */
|
|
19
|
+
rssBytes: number;
|
|
20
|
+
/** 是否已注册系统服务(systemd / launchd),用于在 header 显示标签。 */
|
|
21
|
+
serviceInstalled: boolean;
|
|
22
|
+
}
|
|
23
|
+
export type ToastLevel = "info" | "warn" | "error" | "success";
|
|
24
|
+
export interface ConfirmOptions {
|
|
25
|
+
title: string;
|
|
26
|
+
body: string;
|
|
27
|
+
yes?: string;
|
|
28
|
+
no?: string;
|
|
29
|
+
}
|
|
30
|
+
export interface ServicePanelView {
|
|
31
|
+
/** 第一行状态描述,例如 "active (running) since ..." */
|
|
32
|
+
statusLine: string;
|
|
33
|
+
/** active / inactive / failed / unsupported / unknown / loaded。决定状态行颜色。 */
|
|
34
|
+
state: "active" | "inactive" | "failed" | "loaded" | "unknown" | "unsupported";
|
|
35
|
+
installed: boolean;
|
|
36
|
+
platform: NodeJS.Platform;
|
|
37
|
+
/** 用于"上次操作"提示行,可为空。 */
|
|
38
|
+
lastAction?: string;
|
|
39
|
+
}
|
|
40
|
+
export interface ServicePanelHandlers {
|
|
41
|
+
onStart: () => void | Promise<void>;
|
|
42
|
+
onStop: () => void | Promise<void>;
|
|
43
|
+
onRestart: () => void | Promise<void>;
|
|
44
|
+
onInstall: () => void | Promise<void>;
|
|
45
|
+
onUninstall: () => void | Promise<void>;
|
|
46
|
+
onLogs: () => void | Promise<void>;
|
|
47
|
+
onRefresh: () => void | Promise<void>;
|
|
48
|
+
onClose: () => void;
|
|
16
49
|
}
|
|
17
50
|
export interface LayoutHandle {
|
|
18
51
|
screen: any;
|
|
@@ -20,6 +53,17 @@ export interface LayoutHandle {
|
|
|
20
53
|
refreshSessions(rows: SessionRow[]): void;
|
|
21
54
|
appendLog(record: LogRecord): void;
|
|
22
55
|
setSelectionListener(listener: (index: number) => void): void;
|
|
56
|
+
showHelp(visible: boolean): void;
|
|
57
|
+
toggleHelp(): boolean;
|
|
58
|
+
showToast(message: string, level?: ToastLevel, ttlMs?: number): void;
|
|
59
|
+
confirm(opts: ConfirmOptions): Promise<boolean>;
|
|
60
|
+
showDetail(title: string, body: string): void;
|
|
61
|
+
hideDetail(): void;
|
|
62
|
+
clearLogs(): void;
|
|
63
|
+
openServicePanel(handlers: ServicePanelHandlers, initial: ServicePanelView): void;
|
|
64
|
+
updateServicePanel(view: ServicePanelView): void;
|
|
65
|
+
closeServicePanel(): void;
|
|
66
|
+
isServicePanelOpen(): boolean;
|
|
23
67
|
destroy(): void;
|
|
24
68
|
}
|
|
25
69
|
export declare function buildLayout(): LayoutHandle;
|