dsh-single-terminal 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/README.md +144 -0
- package/README.zh.md +129 -0
- package/cordis.patch.yml +11 -0
- package/lib/client.js +11438 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +588 -0
- package/lib/types/client/controller.d.ts +86 -0
- package/lib/types/client/controller.d.ts.map +1 -0
- package/lib/types/client/controller.js +235 -0
- package/lib/types/client/drawer.d.ts +10 -0
- package/lib/types/client/drawer.d.ts.map +1 -0
- package/lib/types/client/drawer.js +84 -0
- package/lib/types/client/i18n.d.ts +8 -0
- package/lib/types/client/i18n.d.ts.map +1 -0
- package/lib/types/client/i18n.js +51 -0
- package/lib/types/client/index.d.ts +13 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/index.js +13 -0
- package/lib/types/client/plugin.d.ts +23 -0
- package/lib/types/client/plugin.d.ts.map +1 -0
- package/lib/types/client/plugin.js +60 -0
- package/lib/types/client/protocol.d.ts +81 -0
- package/lib/types/client/protocol.d.ts.map +1 -0
- package/lib/types/client/protocol.js +5 -0
- package/lib/types/client/storage.d.ts +6 -0
- package/lib/types/client/storage.d.ts.map +1 -0
- package/lib/types/client/storage.js +21 -0
- package/lib/types/client/styles.d.ts +5 -0
- package/lib/types/client/styles.d.ts.map +1 -0
- package/lib/types/client/styles.js +103 -0
- package/lib/types/client/term.d.ts +15 -0
- package/lib/types/client/term.d.ts.map +1 -0
- package/lib/types/client/term.js +86 -0
- package/lib/types/client/toggle.d.ts +9 -0
- package/lib/types/client/toggle.d.ts.map +1 -0
- package/lib/types/client/toggle.js +19 -0
- package/lib/types/client/ws.d.ts +25 -0
- package/lib/types/client/ws.d.ts.map +1 -0
- package/lib/types/client/ws.js +79 -0
- package/lib/types/client/xterm-css.d.ts +7 -0
- package/lib/types/client/xterm-css.d.ts.map +1 -0
- package/lib/types/client/xterm-css.js +224 -0
- package/lib/types/host/hub.d.ts +39 -0
- package/lib/types/host/hub.d.ts.map +1 -0
- package/lib/types/host/hub.js +291 -0
- package/lib/types/host/index.d.ts +17 -0
- package/lib/types/host/index.d.ts.map +1 -0
- package/lib/types/host/index.js +66 -0
- package/lib/types/host/shells.d.ts +30 -0
- package/lib/types/host/shells.d.ts.map +1 -0
- package/lib/types/host/shells.js +202 -0
- package/lib/types/host/types.d.ts +109 -0
- package/lib/types/host/types.d.ts.map +1 -0
- package/lib/types/host/types.js +4 -0
- package/package.json +102 -0
- package/src/client/controller.ts +293 -0
- package/src/client/drawer.tsx +182 -0
- package/src/client/i18n.ts +58 -0
- package/src/client/index.ts +17 -0
- package/src/client/plugin.tsx +92 -0
- package/src/client/protocol.ts +40 -0
- package/src/client/storage.ts +21 -0
- package/src/client/styles.ts +106 -0
- package/src/client/term.tsx +95 -0
- package/src/client/toggle.tsx +43 -0
- package/src/client/ws.ts +81 -0
- package/src/client/xterm-css.ts +224 -0
- package/src/host/cordis-augment.d.ts +22 -0
- package/src/host/hub.ts +302 -0
- package/src/host/index.ts +78 -0
- package/src/host/shells.ts +216 -0
- package/src/host/types.ts +79 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import Schema from "@deepseek-ai/schemastery";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { lstatSync, statSync } from "node:fs";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { delimiter, isAbsolute, join } from "node:path";
|
|
8
|
+
import { WebSocket, WebSocketServer } from "ws";
|
|
9
|
+
//#region src/host/hub.ts
|
|
10
|
+
/**
|
|
11
|
+
* dsh-single-terminal —— 终端 Hub:PTY 会话管理 + WebSocket 帧协议。
|
|
12
|
+
*
|
|
13
|
+
* - PTY 经插件自身依赖 node-pty(原生模块,external)直连 ConPTY/POSIX pty,
|
|
14
|
+
* 获得完整 resize/kill/数据流控制(宿主 ctx.subprocess 的 TerminalHandle
|
|
15
|
+
* 接口不暴露 resize,无法满足抽屉尺寸跟随);
|
|
16
|
+
* - 会话与 WebSocket 连接解耦:页面刷新/抽屉关闭后会话保活,重连后 attach
|
|
17
|
+
* 回放环形缓冲(容量 scrollbackLimit);
|
|
18
|
+
* - 多浏览器标签页可同时 attach 同一会话:输出广播、输入合并。
|
|
19
|
+
*/
|
|
20
|
+
const nodePty = createRequire(import.meta.url)("node-pty");
|
|
21
|
+
const MAX_SESSIONS = 20;
|
|
22
|
+
const INPUT_LIMIT = 65536;
|
|
23
|
+
const COLS_MIN = 2;
|
|
24
|
+
const COLS_MAX = 500;
|
|
25
|
+
const ROWS_MIN = 2;
|
|
26
|
+
const ROWS_MAX = 300;
|
|
27
|
+
const clamp = (value, min, max) => Number.isFinite(value) ? Math.min(max, Math.max(min, Math.round(value))) : min;
|
|
28
|
+
var TerminalHub = class {
|
|
29
|
+
config;
|
|
30
|
+
registry;
|
|
31
|
+
wss = new WebSocketServer({ noServer: true });
|
|
32
|
+
sockets = /* @__PURE__ */ new Set();
|
|
33
|
+
sessions = /* @__PURE__ */ new Map();
|
|
34
|
+
nextSocketId = 0;
|
|
35
|
+
constructor(config, registry) {
|
|
36
|
+
this.config = config;
|
|
37
|
+
this.registry = registry;
|
|
38
|
+
this.wss.on("connection", (socket) => {
|
|
39
|
+
this.handleConnection(socket);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
/** webServer.registerUpgrade 的 handler 入口(鉴权由插件入口完成后调用)。 */
|
|
43
|
+
handleUpgrade(req, socket, head) {
|
|
44
|
+
this.wss.handleUpgrade(req, socket, head, (ws) => {
|
|
45
|
+
this.wss.emit("connection", ws, req);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
/** 插件卸载:终止全部会话并断开所有连接。 */
|
|
49
|
+
dispose() {
|
|
50
|
+
console.log(`[dsh-single-terminal] hub dispose (${this.sessions.size} sessions, ${this.sockets.size} sockets)`);
|
|
51
|
+
for (const session of this.sessions.values()) this.killSession(session);
|
|
52
|
+
this.sessions.clear();
|
|
53
|
+
for (const socket of this.sockets) try {
|
|
54
|
+
socket.terminate();
|
|
55
|
+
} catch {}
|
|
56
|
+
this.sockets.clear();
|
|
57
|
+
}
|
|
58
|
+
handleConnection(socket) {
|
|
59
|
+
const sid = ++this.nextSocketId;
|
|
60
|
+
this.sockets.add(socket);
|
|
61
|
+
console.log(`[dsh-single-terminal] ws#${sid} connected (total ${this.sockets.size})`);
|
|
62
|
+
socket.on("message", (raw) => {
|
|
63
|
+
this.dispatch(socket, raw, sid);
|
|
64
|
+
});
|
|
65
|
+
socket.on("close", () => {
|
|
66
|
+
this.sockets.delete(socket);
|
|
67
|
+
console.log(`[dsh-single-terminal] ws#${sid} closed (total ${this.sockets.size})`);
|
|
68
|
+
});
|
|
69
|
+
socket.on("error", () => {
|
|
70
|
+
this.sockets.delete(socket);
|
|
71
|
+
});
|
|
72
|
+
this.send(socket, {
|
|
73
|
+
type: "hello",
|
|
74
|
+
platform: process.platform,
|
|
75
|
+
defaultShell: this.registry.defaultShellId(this.config.defaultShell),
|
|
76
|
+
fontSize: this.config.fontSize,
|
|
77
|
+
fontFamily: this.config.fontFamily
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
dispatch(socket, raw, sid) {
|
|
81
|
+
let frame;
|
|
82
|
+
try {
|
|
83
|
+
frame = JSON.parse(String(raw));
|
|
84
|
+
} catch {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (frame.type !== "input" && frame.type !== "resize") {
|
|
88
|
+
const detail = "id" in frame ? ` id=${String(frame.id).slice(0, 8)}` : "";
|
|
89
|
+
console.log(`[dsh-single-terminal] ws#${sid} <- ${frame.type}${detail}`);
|
|
90
|
+
}
|
|
91
|
+
switch (frame.type) {
|
|
92
|
+
case "ping":
|
|
93
|
+
this.send(socket, { type: "pong" });
|
|
94
|
+
break;
|
|
95
|
+
case "list":
|
|
96
|
+
this.send(socket, this.snapshotFrame());
|
|
97
|
+
break;
|
|
98
|
+
case "open":
|
|
99
|
+
this.open(frame.shellId, frame.cols, frame.rows, frame.cwd);
|
|
100
|
+
break;
|
|
101
|
+
case "input": {
|
|
102
|
+
const session = this.sessions.get(frame.id);
|
|
103
|
+
if (session === void 0 || !session.alive) break;
|
|
104
|
+
try {
|
|
105
|
+
session.pty.write(frame.data.length > INPUT_LIMIT ? frame.data.slice(0, INPUT_LIMIT) : frame.data);
|
|
106
|
+
} catch {}
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
case "resize": {
|
|
110
|
+
const session = this.sessions.get(frame.id);
|
|
111
|
+
if (session === void 0 || !session.alive) break;
|
|
112
|
+
try {
|
|
113
|
+
session.pty.resize(clamp(frame.cols, COLS_MIN, COLS_MAX), clamp(frame.rows, ROWS_MIN, ROWS_MAX));
|
|
114
|
+
} catch {}
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
case "attach": {
|
|
118
|
+
const session = this.sessions.get(frame.id);
|
|
119
|
+
if (session === void 0) break;
|
|
120
|
+
this.send(socket, {
|
|
121
|
+
type: "replay",
|
|
122
|
+
id: session.id,
|
|
123
|
+
data: session.buffer
|
|
124
|
+
});
|
|
125
|
+
if (!session.alive) this.send(socket, {
|
|
126
|
+
type: "exit",
|
|
127
|
+
id: session.id,
|
|
128
|
+
exitCode: session.exitCode,
|
|
129
|
+
signal: session.signal
|
|
130
|
+
});
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
case "close": this.close(frame.id);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
open(shellId, cols, rows, cwdOverride) {
|
|
137
|
+
if (this.sessions.size >= MAX_SESSIONS) {
|
|
138
|
+
this.broadcast({
|
|
139
|
+
type: "error",
|
|
140
|
+
message: "终端数量已达上限 / terminal session limit reached"
|
|
141
|
+
});
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const shell = this.registry.resolve(shellId);
|
|
145
|
+
if (shell === null) {
|
|
146
|
+
this.broadcast({
|
|
147
|
+
type: "error",
|
|
148
|
+
message: `未知或不可用的 shell:「${shellId}」/ unknown or unavailable shell`
|
|
149
|
+
});
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const cwd = this.resolveCwd(cwdOverride);
|
|
153
|
+
const env = { ...process.env };
|
|
154
|
+
if (process.platform !== "win32") env.TERM = "xterm-256color";
|
|
155
|
+
let pty;
|
|
156
|
+
try {
|
|
157
|
+
pty = nodePty.spawn(shell.file, shell.args, {
|
|
158
|
+
name: "xterm-256color",
|
|
159
|
+
cols: clamp(cols, COLS_MIN, COLS_MAX),
|
|
160
|
+
rows: clamp(rows, ROWS_MIN, ROWS_MAX),
|
|
161
|
+
cwd,
|
|
162
|
+
env
|
|
163
|
+
});
|
|
164
|
+
} catch (e) {
|
|
165
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
166
|
+
this.broadcast({
|
|
167
|
+
type: "error",
|
|
168
|
+
message: `无法启动 ${shell.name} / failed to spawn ${shell.name}: ${message}`
|
|
169
|
+
});
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const session = {
|
|
173
|
+
id: randomUUID(),
|
|
174
|
+
shell,
|
|
175
|
+
cwd,
|
|
176
|
+
pty,
|
|
177
|
+
buffer: "",
|
|
178
|
+
alive: true,
|
|
179
|
+
closing: false,
|
|
180
|
+
exitCode: null,
|
|
181
|
+
signal: null
|
|
182
|
+
};
|
|
183
|
+
this.sessions.set(session.id, session);
|
|
184
|
+
console.log(`[dsh-single-terminal] session ${session.id.slice(0, 8)} opened shell=${shell.id} pid=${pty.pid} cwd=${cwd} (${this.sessions.size} total)`);
|
|
185
|
+
pty.onData((data) => {
|
|
186
|
+
this.appendBuffer(session, data);
|
|
187
|
+
this.broadcast({
|
|
188
|
+
type: "data",
|
|
189
|
+
id: session.id,
|
|
190
|
+
data
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
pty.onExit(({ exitCode, signal }) => {
|
|
194
|
+
session.alive = false;
|
|
195
|
+
session.exitCode = exitCode;
|
|
196
|
+
session.signal = typeof signal === "number" ? signal : null;
|
|
197
|
+
if (this.sessions.has(session.id)) {
|
|
198
|
+
this.broadcast({
|
|
199
|
+
type: "exit",
|
|
200
|
+
id: session.id,
|
|
201
|
+
exitCode: session.exitCode,
|
|
202
|
+
signal: session.signal
|
|
203
|
+
});
|
|
204
|
+
this.sessions.delete(session.id);
|
|
205
|
+
console.log(`[dsh-single-terminal] session ${session.id.slice(0, 8)} (pid ${session.pty.pid}) exited code=${exitCode}`);
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
this.broadcast({
|
|
209
|
+
type: "opened",
|
|
210
|
+
session: this.sessionInfo(session)
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
close(id) {
|
|
214
|
+
const session = this.sessions.get(id);
|
|
215
|
+
if (session === void 0) return;
|
|
216
|
+
console.log(`[dsh-single-terminal] closing session ${id.slice(0, 8)} (pid ${session.pty.pid})`);
|
|
217
|
+
this.sessions.delete(id);
|
|
218
|
+
if (!session.alive) {
|
|
219
|
+
this.broadcast({
|
|
220
|
+
type: "exit",
|
|
221
|
+
id,
|
|
222
|
+
exitCode: session.exitCode,
|
|
223
|
+
signal: session.signal,
|
|
224
|
+
closed: true
|
|
225
|
+
});
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
session.closing = true;
|
|
229
|
+
this.killSession(session);
|
|
230
|
+
this.broadcast({
|
|
231
|
+
type: "exit",
|
|
232
|
+
id,
|
|
233
|
+
exitCode: null,
|
|
234
|
+
signal: null,
|
|
235
|
+
closed: true
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
killSession(session) {
|
|
239
|
+
try {
|
|
240
|
+
session.pty.kill();
|
|
241
|
+
} catch {}
|
|
242
|
+
if (process.platform === "win32") try {
|
|
243
|
+
spawn("taskkill", [
|
|
244
|
+
"/T",
|
|
245
|
+
"/F",
|
|
246
|
+
"/PID",
|
|
247
|
+
String(session.pty.pid)
|
|
248
|
+
], { stdio: "ignore" });
|
|
249
|
+
} catch {}
|
|
250
|
+
else try {
|
|
251
|
+
process.kill(-session.pty.pid, "SIGKILL");
|
|
252
|
+
} catch {}
|
|
253
|
+
}
|
|
254
|
+
resolveCwd(override) {
|
|
255
|
+
const validDirectory = (path) => {
|
|
256
|
+
try {
|
|
257
|
+
return statSync(path).isDirectory();
|
|
258
|
+
} catch {
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
if (override !== void 0) {
|
|
263
|
+
const trimmed = override.trim();
|
|
264
|
+
if (trimmed.length > 0 && isAbsolute(trimmed) && validDirectory(trimmed)) return trimmed;
|
|
265
|
+
}
|
|
266
|
+
const configured = (this.config.defaultCwd ?? "workspace").trim();
|
|
267
|
+
if (configured.length > 0 && configured !== "workspace" && configured !== "home" && isAbsolute(configured) && validDirectory(configured)) return configured;
|
|
268
|
+
return homedir();
|
|
269
|
+
}
|
|
270
|
+
appendBuffer(session, data) {
|
|
271
|
+
session.buffer += data;
|
|
272
|
+
const limit = this.config.scrollbackLimit;
|
|
273
|
+
if (session.buffer.length > limit) session.buffer = session.buffer.slice(session.buffer.length - limit);
|
|
274
|
+
}
|
|
275
|
+
snapshotFrame() {
|
|
276
|
+
return {
|
|
277
|
+
type: "shells",
|
|
278
|
+
defaultShell: this.registry.defaultShellId(this.config.defaultShell),
|
|
279
|
+
shells: this.registry.list(),
|
|
280
|
+
sessions: [...this.sessions.values()].map((session) => this.sessionInfo(session))
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
sessionInfo(session) {
|
|
284
|
+
return {
|
|
285
|
+
id: session.id,
|
|
286
|
+
shellId: session.shell.id,
|
|
287
|
+
label: session.shell.name,
|
|
288
|
+
cwd: session.cwd,
|
|
289
|
+
alive: session.alive,
|
|
290
|
+
pid: session.pty.pid,
|
|
291
|
+
exitCode: session.exitCode,
|
|
292
|
+
signal: session.signal
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
send(socket, frame) {
|
|
296
|
+
if (socket.readyState !== WebSocket.OPEN) return;
|
|
297
|
+
try {
|
|
298
|
+
socket.send(JSON.stringify(frame));
|
|
299
|
+
} catch {}
|
|
300
|
+
}
|
|
301
|
+
broadcast(frame) {
|
|
302
|
+
for (const socket of this.sockets) this.send(socket, frame);
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
//#endregion
|
|
306
|
+
//#region src/host/shells.ts
|
|
307
|
+
/**
|
|
308
|
+
* dsh-single-terminal —— shell 注册表与探测。
|
|
309
|
+
*
|
|
310
|
+
* 探测范式对齐宿主 packages/shell/pwsh-local/src/resolve.ts:
|
|
311
|
+
* 已知路径 + PATH 逐项探测 + lstatSync(isFile||isSymbolicLink),
|
|
312
|
+
* 不用注册表 / where.exe。检测不到的 shell available=false(客户端隐藏)。
|
|
313
|
+
*/
|
|
314
|
+
function isFileLike(path) {
|
|
315
|
+
try {
|
|
316
|
+
const stat = lstatSync(path);
|
|
317
|
+
return stat.isFile() || stat.isSymbolicLink();
|
|
318
|
+
} catch {
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
function firstExisting(paths) {
|
|
323
|
+
for (const path of paths) if (path.length > 0 && isFileLike(path)) return path;
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
function pathEntries(env) {
|
|
327
|
+
return (env.PATH ?? "").split(delimiter).map((entry) => entry.trim().replace(/^"|"$/g, "")).filter((entry) => entry.length > 0);
|
|
328
|
+
}
|
|
329
|
+
/** PATH 逐项探测一个裸可执行名(Windows 附加 PATHEXT 扩展名)。 */
|
|
330
|
+
function probeOnPath(name, env, platform) {
|
|
331
|
+
const extensions = platform === "win32" ? (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter((ext) => ext.length > 0) : [""];
|
|
332
|
+
const candidates = [];
|
|
333
|
+
for (const entry of pathEntries(env)) for (const ext of extensions) candidates.push(join(entry, name + ext));
|
|
334
|
+
return firstExisting(candidates);
|
|
335
|
+
}
|
|
336
|
+
const WINDOWS_BUILTINS = [
|
|
337
|
+
{
|
|
338
|
+
id: "powershell",
|
|
339
|
+
name: "PowerShell",
|
|
340
|
+
platforms: ["win32"],
|
|
341
|
+
resolve: (env) => {
|
|
342
|
+
const systemRoot = env.SystemRoot ?? "C:\\Windows";
|
|
343
|
+
const file = firstExisting([join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe")]);
|
|
344
|
+
return file === null ? null : {
|
|
345
|
+
file,
|
|
346
|
+
args: []
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
},
|
|
350
|
+
{
|
|
351
|
+
id: "pwsh",
|
|
352
|
+
name: "PowerShell 7",
|
|
353
|
+
platforms: [
|
|
354
|
+
"win32",
|
|
355
|
+
"darwin",
|
|
356
|
+
"linux"
|
|
357
|
+
],
|
|
358
|
+
resolve: (env, platform) => {
|
|
359
|
+
const candidates = [];
|
|
360
|
+
if (platform === "win32") {
|
|
361
|
+
const programFiles = env.ProgramFiles ?? "C:\\Program Files";
|
|
362
|
+
candidates.push(join(programFiles, "PowerShell", "7", "pwsh.exe"));
|
|
363
|
+
for (const entry of pathEntries(env)) candidates.push(join(entry, "pwsh.exe"));
|
|
364
|
+
} else candidates.push(...pathEntries(env).map((entry) => join(entry, "pwsh")));
|
|
365
|
+
const file = firstExisting(candidates);
|
|
366
|
+
return file === null ? null : {
|
|
367
|
+
file,
|
|
368
|
+
args: []
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
},
|
|
372
|
+
{
|
|
373
|
+
id: "cmd",
|
|
374
|
+
name: "CMD",
|
|
375
|
+
platforms: ["win32"],
|
|
376
|
+
resolve: (env) => {
|
|
377
|
+
const systemRoot = env.SystemRoot ?? "C:\\Windows";
|
|
378
|
+
const file = firstExisting([join(systemRoot, "System32", "cmd.exe")]);
|
|
379
|
+
return file === null ? null : {
|
|
380
|
+
file,
|
|
381
|
+
args: []
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
},
|
|
385
|
+
{
|
|
386
|
+
id: "gitbash",
|
|
387
|
+
name: "Git Bash",
|
|
388
|
+
platforms: ["win32"],
|
|
389
|
+
resolve: (env) => {
|
|
390
|
+
const programFiles = env.ProgramFiles ?? "C:\\Program Files";
|
|
391
|
+
const programFilesX86 = env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)";
|
|
392
|
+
const localAppData = env.LocalAppData ?? "";
|
|
393
|
+
const file = firstExisting([
|
|
394
|
+
join(programFiles, "Git", "bin", "bash.exe"),
|
|
395
|
+
join(programFiles, "Git", "usr", "bin", "bash.exe"),
|
|
396
|
+
join(programFilesX86, "Git", "bin", "bash.exe"),
|
|
397
|
+
localAppData.length > 0 ? join(localAppData, "Programs", "Git", "bin", "bash.exe") : ""
|
|
398
|
+
]);
|
|
399
|
+
return file === null ? null : {
|
|
400
|
+
file,
|
|
401
|
+
args: ["-i"]
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
},
|
|
405
|
+
{
|
|
406
|
+
id: "wsl",
|
|
407
|
+
name: "WSL",
|
|
408
|
+
platforms: ["win32"],
|
|
409
|
+
resolve: (env) => {
|
|
410
|
+
const systemRoot = env.SystemRoot ?? "C:\\Windows";
|
|
411
|
+
const file = firstExisting([join(systemRoot, "System32", "wsl.exe")]);
|
|
412
|
+
return file === null ? null : {
|
|
413
|
+
file,
|
|
414
|
+
args: []
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
];
|
|
419
|
+
const POSIX_BUILTINS = [
|
|
420
|
+
{
|
|
421
|
+
id: "bash",
|
|
422
|
+
name: "Bash",
|
|
423
|
+
platforms: ["darwin", "linux"],
|
|
424
|
+
resolve: (env) => {
|
|
425
|
+
const file = firstExisting([
|
|
426
|
+
...pathEntries(env).map((entry) => join(entry, "bash")),
|
|
427
|
+
"/bin/bash",
|
|
428
|
+
"/usr/bin/bash"
|
|
429
|
+
]);
|
|
430
|
+
return file === null ? null : {
|
|
431
|
+
file,
|
|
432
|
+
args: []
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
},
|
|
436
|
+
{
|
|
437
|
+
id: "zsh",
|
|
438
|
+
name: "Zsh",
|
|
439
|
+
platforms: ["darwin", "linux"],
|
|
440
|
+
resolve: (env) => {
|
|
441
|
+
const file = firstExisting([
|
|
442
|
+
"/bin/zsh",
|
|
443
|
+
"/usr/bin/zsh",
|
|
444
|
+
...pathEntries(env).map((entry) => join(entry, "zsh"))
|
|
445
|
+
]);
|
|
446
|
+
return file === null ? null : {
|
|
447
|
+
file,
|
|
448
|
+
args: []
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
},
|
|
452
|
+
{
|
|
453
|
+
id: "fish",
|
|
454
|
+
name: "Fish",
|
|
455
|
+
platforms: ["darwin", "linux"],
|
|
456
|
+
resolve: (env) => {
|
|
457
|
+
const file = firstExisting([
|
|
458
|
+
"/usr/local/bin/fish",
|
|
459
|
+
"/opt/homebrew/bin/fish",
|
|
460
|
+
...pathEntries(env).map((entry) => join(entry, "fish"))
|
|
461
|
+
]);
|
|
462
|
+
return file === null ? null : {
|
|
463
|
+
file,
|
|
464
|
+
args: []
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
];
|
|
469
|
+
const BUILTINS = [...WINDOWS_BUILTINS, ...POSIX_BUILTINS];
|
|
470
|
+
var ShellRegistry = class {
|
|
471
|
+
customShells;
|
|
472
|
+
constructor(customShells) {
|
|
473
|
+
this.customShells = customShells;
|
|
474
|
+
}
|
|
475
|
+
/** 枚举当前平台可配置的 shell(检测不到的 available=false,由客户端隐藏)。 */
|
|
476
|
+
list(env = process.env, platform = process.platform) {
|
|
477
|
+
const result = [];
|
|
478
|
+
for (const def of BUILTINS) {
|
|
479
|
+
if (!def.platforms.includes(platform)) continue;
|
|
480
|
+
result.push({
|
|
481
|
+
id: def.id,
|
|
482
|
+
name: def.name,
|
|
483
|
+
available: def.resolve(env, platform) !== null
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
for (const custom of this.customShells) {
|
|
487
|
+
if (platform !== "win32" && !/[\\/]/.test(custom.command) && custom.command.toLowerCase().endsWith(".exe")) continue;
|
|
488
|
+
result.push({
|
|
489
|
+
id: custom.id,
|
|
490
|
+
name: custom.name,
|
|
491
|
+
available: this.resolveCustom(custom, env, platform) !== null
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
return result;
|
|
495
|
+
}
|
|
496
|
+
/** 解析为可执行规格;内置 id 优先,其次自定义 shell。 */
|
|
497
|
+
resolve(id, env = process.env, platform = process.platform) {
|
|
498
|
+
const builtin = BUILTINS.find((def) => def.id === id && def.platforms.includes(platform));
|
|
499
|
+
if (builtin !== void 0) {
|
|
500
|
+
const resolved = builtin.resolve(env, platform);
|
|
501
|
+
return resolved === null ? null : {
|
|
502
|
+
id: builtin.id,
|
|
503
|
+
name: builtin.name,
|
|
504
|
+
...resolved
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
const custom = this.customShells.find((entry) => entry.id === id);
|
|
508
|
+
if (custom !== void 0) {
|
|
509
|
+
const resolved = this.resolveCustom(custom, env, platform);
|
|
510
|
+
return resolved === null ? null : {
|
|
511
|
+
id: custom.id,
|
|
512
|
+
name: custom.name,
|
|
513
|
+
...resolved
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
return null;
|
|
517
|
+
}
|
|
518
|
+
/** 默认 shell id:配置值可用则用之;否则 win32 用 powershell,POSIX 用 $SHELL 名或 bash。 */
|
|
519
|
+
defaultShellId(preferred, env = process.env, platform = process.platform) {
|
|
520
|
+
if (preferred !== void 0 && preferred.length > 0 && this.resolve(preferred, env, platform) !== null) return preferred;
|
|
521
|
+
if (platform === "win32") return "powershell";
|
|
522
|
+
const shellName = (env.SHELL ?? "").split("/").pop() ?? "";
|
|
523
|
+
if (shellName.length > 0 && this.resolve(shellName, env, platform) !== null) return shellName;
|
|
524
|
+
return "bash";
|
|
525
|
+
}
|
|
526
|
+
resolveCustom(custom, env, platform) {
|
|
527
|
+
const raw = custom.command.trim();
|
|
528
|
+
if (raw.length === 0) return null;
|
|
529
|
+
const args = Array.isArray(custom.args) ? [...custom.args] : [];
|
|
530
|
+
if (/[\\/]/.test(raw) || isFileLike(raw)) return isFileLike(raw) ? {
|
|
531
|
+
file: raw,
|
|
532
|
+
args
|
|
533
|
+
} : null;
|
|
534
|
+
const file = probeOnPath(raw, env, platform);
|
|
535
|
+
return file === null ? null : {
|
|
536
|
+
file,
|
|
537
|
+
args
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
};
|
|
541
|
+
//#endregion
|
|
542
|
+
//#region src/host/index.ts
|
|
543
|
+
const name = "dsh-single-terminal";
|
|
544
|
+
const inject = ["webServer", "connection"];
|
|
545
|
+
const CustomShellSchema = Schema.object({
|
|
546
|
+
id: Schema.string().required().description("唯一 id,如 my-shell"),
|
|
547
|
+
name: Schema.string().required().description("菜单显示名,如 My Shell"),
|
|
548
|
+
command: Schema.string().required().description("可执行文件路径或 PATH 上的命令名,如 nu"),
|
|
549
|
+
args: Schema.array(Schema.string()).default([]).description("启动参数")
|
|
550
|
+
});
|
|
551
|
+
const Config = Schema.object({
|
|
552
|
+
defaultShell: Schema.string().default("powershell").description("默认 shell id:powershell | pwsh | cmd | gitbash | wsl(或自定义 shell 的 id)。非 Windows 下自动回退到 $SHELL/bash"),
|
|
553
|
+
defaultCwd: Schema.string().default("workspace").description("终端初始目录:workspace(当前工作区根,未知时回退用户主目录)| home | 绝对路径"),
|
|
554
|
+
scrollbackLimit: Schema.number().default(2e5).description("重连回放缓冲的字符上限(每会话)"),
|
|
555
|
+
fontSize: Schema.number().default(13).description("终端字号"),
|
|
556
|
+
fontFamily: Schema.string().default("Consolas, \"Cascadia Mono\", \"Courier New\", monospace").description("终端字体"),
|
|
557
|
+
customShells: Schema.array(CustomShellSchema).default([]).description("自定义 shell 列表")
|
|
558
|
+
});
|
|
559
|
+
const WS_PATH = "/api/dsh-single-terminal.ws";
|
|
560
|
+
function apply(ctx, config) {
|
|
561
|
+
const webServer = ctx.get("webServer");
|
|
562
|
+
const connection = ctx.get("connection");
|
|
563
|
+
if (webServer === void 0 || connection === void 0) return;
|
|
564
|
+
const hub = new TerminalHub(config, new ShellRegistry(config.customShells ?? []));
|
|
565
|
+
let unregister;
|
|
566
|
+
try {
|
|
567
|
+
unregister = webServer.registerUpgrade({
|
|
568
|
+
path: WS_PATH,
|
|
569
|
+
handler: (req, socket, head) => {
|
|
570
|
+
if (connection.requestRejection(req) !== void 0) {
|
|
571
|
+
socket.destroy();
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
hub.handleUpgrade(req, socket, head);
|
|
575
|
+
}
|
|
576
|
+
});
|
|
577
|
+
} catch (e) {
|
|
578
|
+
console.warn("[dsh-single-terminal] register upgrade route failed:", e instanceof Error ? e.message : String(e));
|
|
579
|
+
}
|
|
580
|
+
ctx.effect(() => () => {
|
|
581
|
+
try {
|
|
582
|
+
unregister?.();
|
|
583
|
+
} catch {}
|
|
584
|
+
hub.dispose();
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
//#endregion
|
|
588
|
+
export { Config, apply, inject, name };
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-single-terminal —— 客户端控制器(模块级单例):
|
|
3
|
+
* WebSocket 生命周期、帧路由、sink 注册(xterm 实例)、UI 状态 store。
|
|
4
|
+
*
|
|
5
|
+
* 会话输出在 sink 注册前先积压到 pending(React 挂载/重连时序下不丢字节);
|
|
6
|
+
* 抽屉关闭不卸载组件(display:none),xterm 缓冲与会话保持。
|
|
7
|
+
*/
|
|
8
|
+
import type { ShellInfo, SessionInfo } from './protocol.ts';
|
|
9
|
+
import { type ConnectionState } from './ws.ts';
|
|
10
|
+
export interface TabState {
|
|
11
|
+
id: string;
|
|
12
|
+
shellId: string;
|
|
13
|
+
label: string;
|
|
14
|
+
cwd: string;
|
|
15
|
+
alive: boolean;
|
|
16
|
+
exitCode: number | null;
|
|
17
|
+
}
|
|
18
|
+
export type DrawerMode = 'docked' | 'overlay';
|
|
19
|
+
export interface TerminalSink {
|
|
20
|
+
write(data: string): void;
|
|
21
|
+
}
|
|
22
|
+
interface DrawerState {
|
|
23
|
+
open: boolean;
|
|
24
|
+
mode: DrawerMode;
|
|
25
|
+
height: number;
|
|
26
|
+
conn: ConnectionState;
|
|
27
|
+
tabs: TabState[];
|
|
28
|
+
activeId: string | null;
|
|
29
|
+
shells: ShellInfo[];
|
|
30
|
+
defaultShell: string;
|
|
31
|
+
fontSize: number;
|
|
32
|
+
fontFamily: string;
|
|
33
|
+
notice: string | null;
|
|
34
|
+
}
|
|
35
|
+
declare class DrawerStore {
|
|
36
|
+
private state;
|
|
37
|
+
private readonly listeners;
|
|
38
|
+
subscribe: (listener: () => void) => (() => void);
|
|
39
|
+
getSnapshot: () => DrawerState;
|
|
40
|
+
private set;
|
|
41
|
+
setOpen(open: boolean): void;
|
|
42
|
+
setMode(mode: DrawerMode): void;
|
|
43
|
+
setHeight(height: number): void;
|
|
44
|
+
setConn(conn: ConnectionState): void;
|
|
45
|
+
setNotice(notice: string | null): void;
|
|
46
|
+
applyHello(frame: {
|
|
47
|
+
defaultShell: string;
|
|
48
|
+
fontSize: number;
|
|
49
|
+
fontFamily: string;
|
|
50
|
+
}): void;
|
|
51
|
+
applyInventory(shells: ShellInfo[], sessions: SessionInfo[]): {
|
|
52
|
+
adopted: SessionInfo[];
|
|
53
|
+
};
|
|
54
|
+
addSession(session: SessionInfo): void;
|
|
55
|
+
setActive(id: string): void;
|
|
56
|
+
markDead(id: string, exitCode: number | null): void;
|
|
57
|
+
removeTab(id: string): void;
|
|
58
|
+
}
|
|
59
|
+
declare class TerminalController {
|
|
60
|
+
readonly store: DrawerStore;
|
|
61
|
+
private socket;
|
|
62
|
+
private readonly sinks;
|
|
63
|
+
private readonly pending;
|
|
64
|
+
private noticeTimer;
|
|
65
|
+
toggle(): void;
|
|
66
|
+
setOpen(open: boolean): void;
|
|
67
|
+
ensureStarted(): void;
|
|
68
|
+
private autoOpening;
|
|
69
|
+
private maybeAutoOpen;
|
|
70
|
+
newTerminal(shellId: string): void;
|
|
71
|
+
closeTab(id: string): void;
|
|
72
|
+
setActive(id: string): void;
|
|
73
|
+
setMode(mode: DrawerMode): void;
|
|
74
|
+
setHeight(height: number): void;
|
|
75
|
+
showNotice(message: string): void;
|
|
76
|
+
sendInput(id: string, data: string): void;
|
|
77
|
+
sendResize(id: string, cols: number, rows: number): void;
|
|
78
|
+
registerSink(id: string, sink: TerminalSink): () => void;
|
|
79
|
+
dispose(): void;
|
|
80
|
+
private writeData;
|
|
81
|
+
private onFrame;
|
|
82
|
+
}
|
|
83
|
+
export declare const terminal: TerminalController;
|
|
84
|
+
export declare function useDrawerState(): DrawerState;
|
|
85
|
+
export {};
|
|
86
|
+
//# sourceMappingURL=controller.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"controller.d.ts","sourceRoot":"","sources":["../../../src/client/controller.ts"],"names":[],"mappings":"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"}
|