ephem-cli 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.
@@ -0,0 +1,226 @@
1
+ // 集成测试:建房 → 双端 WS 连接 → 加密收发 → peer 事件 → 错误房间码。
2
+ // 运行:NODE_PATH 指向 cli 的 node_modules(需要 ws)
3
+
4
+ import { WebSocket } from "ws";
5
+ import {
6
+ createCipheriv,
7
+ createDecipheriv,
8
+ randomBytes,
9
+ hkdfSync,
10
+ } from "node:crypto";
11
+
12
+ const HTTP = "http://127.0.0.1:8787";
13
+ const WS_BASE = "ws://127.0.0.1:8787";
14
+ const ADMIN_KEY = "change-me-in-production";
15
+
16
+ const SALT = "ephem-v1-room-salt";
17
+ const INFO = "ephem-room-encryption-key";
18
+ const TAG_LEN = 16;
19
+
20
+ let pass = 0;
21
+ let fail = 0;
22
+ function assert(cond, msg) {
23
+ if (cond) {
24
+ pass++;
25
+ console.log(` ✓ ${msg}`);
26
+ } else {
27
+ fail++;
28
+ console.log(` ✗ ${msg}`);
29
+ }
30
+ }
31
+
32
+ function deriveKey(code) {
33
+ return Buffer.from(
34
+ hkdfSync("sha256", Buffer.from(code), Buffer.from(SALT), Buffer.from(INFO), 32),
35
+ );
36
+ }
37
+ function encrypt(key, pt) {
38
+ const nonce = randomBytes(12);
39
+ const c = createCipheriv("aes-256-gcm", key, nonce);
40
+ const enc = Buffer.concat([c.update(pt, "utf8"), c.final()]);
41
+ return {
42
+ ciphertext: Buffer.concat([enc, c.getAuthTag()]).toString("base64"),
43
+ nonce: nonce.toString("base64"),
44
+ };
45
+ }
46
+ function decrypt(key, p) {
47
+ const combined = Buffer.from(p.ciphertext, "base64");
48
+ const nonce = Buffer.from(p.nonce, "base64");
49
+ const tag = combined.subarray(combined.length - TAG_LEN);
50
+ const enc = combined.subarray(0, combined.length - TAG_LEN);
51
+ const d = createDecipheriv("aes-256-gcm", key, nonce);
52
+ d.setAuthTag(tag);
53
+ return Buffer.concat([d.update(enc), d.final()]).toString("utf8");
54
+ }
55
+
56
+ function once(ws, type) {
57
+ return new Promise((resolve) => {
58
+ const handler = (raw) => {
59
+ const msg = JSON.parse(raw.toString());
60
+ if (msg.type === type) {
61
+ ws.off("message", handler);
62
+ resolve(msg);
63
+ }
64
+ };
65
+ ws.on("message", handler);
66
+ });
67
+ }
68
+
69
+ function connect(username, code) {
70
+ const ws = new WebSocket(
71
+ `${WS_BASE}/room/${code}?username=${encodeURIComponent(username)}`,
72
+ );
73
+ ws.on("open", () => console.log(` [${username} open]`));
74
+ ws.on("message", (raw) =>
75
+ console.log(` [${username} msg]`, raw.toString().slice(0, 60)),
76
+ );
77
+ ws.on("error", (e) => console.log(` [${username} error]`, e.message));
78
+ ws.on("close", (code, reason) =>
79
+ console.log(` [${username} close]`, code, reason.toString()),
80
+ );
81
+ ws.on("unexpected-response", (_req, res) =>
82
+ console.log(` [${username} unexpected-response]`, res.statusCode),
83
+ );
84
+ return ws;
85
+ }
86
+
87
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
88
+
89
+ // 全局兜底:防止未捕获的 rejection 让进程悄悄退出
90
+ process.on("unhandledRejection", (e) => {
91
+ console.log(" [unhandledRejection]", e?.message ?? e);
92
+ });
93
+ process.on("uncaughtException", (e) => {
94
+ console.log(" [uncaughtException]", e?.message ?? e);
95
+ });
96
+
97
+ async function main() {
98
+ // ── 建房 ──────────────────────────────
99
+ console.log("\n[1] 创建房间");
100
+ const r = await fetch(`${HTTP}/api/rooms`, {
101
+ method: "POST",
102
+ headers: { "Content-Type": "application/json", "X-Admin-Key": ADMIN_KEY },
103
+ body: JSON.stringify({ maxMembers: 3, ttlSeconds: 600 }),
104
+ });
105
+ const room = await r.json();
106
+ assert(r.status === 200 && !!room.roomCode, `房间已创建:${room.roomCode}`);
107
+ const key = deriveKey(room.roomCode);
108
+
109
+ // ── 状态查询(需鉴权)────────────────
110
+ console.log("\n[2] 状态查询");
111
+ const s1 = await fetch(
112
+ `${HTTP}/api/rooms/${encodeURIComponent(room.roomCode)}/status`,
113
+ { headers: { "X-Admin-Key": ADMIN_KEY } },
114
+ );
115
+ const sj = await s1.json();
116
+ assert(s1.status === 200 && sj.alive === true, `状态正常:${sj.currentMembers}/${sj.maxMembers}`);
117
+
118
+ const sNoAuth = await fetch(
119
+ `${HTTP}/api/rooms/${encodeURIComponent(room.roomCode)}/status`,
120
+ );
121
+ assert(sNoAuth.status === 401, "无鉴权查询状态 → 401");
122
+
123
+ // ── 双端连接 ──────────────────────────
124
+ console.log("\n[3] 双端连接");
125
+ const a = connect("alice", room.roomCode);
126
+ const aJoined = await once(a, "joined");
127
+ assert(aJoined.payload.username === "alice", "alice 收到 joined");
128
+ assert(aJoined.payload.maxMembers === 3, "joined 携带 maxMembers=3");
129
+
130
+ const b = connect("bob", room.roomCode);
131
+ const bJoined = await once(b, "joined");
132
+ const aPeer = await once(a, "peer_joined");
133
+ assert(bJoined.payload.username === "bob", "bob 收到 joined");
134
+ assert(aPeer.payload.username === "bob", "alice 收到 bob 的 peer_joined");
135
+
136
+ // ── 加密收发 ──────────────────────────
137
+ console.log("\n[4] 端到端加密收发");
138
+ const msgA = "你好 bob,我是 alice 🐰";
139
+ a.send(JSON.stringify({ type: "message", payload: encrypt(key, msgA) }));
140
+ const bRecv = await once(b, "message");
141
+ assert(bRecv.payload.from === "alice", "bob 收到 alice 的消息");
142
+ assert(decrypt(key, bRecv.payload) === msgA, `bob 解密成功:${decrypt(key, bRecv.payload)}`);
143
+
144
+ const msgB = "收到!加密真好用 🔐";
145
+ b.send(JSON.stringify({ type: "message", payload: encrypt(key, msgB) }));
146
+ const aRecv = await once(a, "message");
147
+ assert(aRecv.payload.from === "bob", "alice 收到 bob 的消息");
148
+ assert(decrypt(key, aRecv.payload) === msgB, `alice 解密成功:${decrypt(key, aRecv.payload)}`);
149
+
150
+ // ── 后端只看到密文(密文 base64 解码后不含明文)──
151
+ console.log("\n[5] 后端零知识验证");
152
+ const cipherText = bRecv.payload.ciphertext;
153
+ assert(!cipherText.includes("你好"), "密文中不含明文(base64 也搜不到)");
154
+
155
+ // ── peer_left ─────────────────────────
156
+ console.log("\n[6] 成员离开");
157
+ const aPeerLeft = once(a, "peer_left");
158
+ b.close();
159
+ const left = await aPeerLeft;
160
+ assert(left.payload.username === "bob", "alice 收到 bob 的 peer_left");
161
+
162
+ // ── 错误房间码 ────────────────────────
163
+ console.log("\n[7] 错误房间码");
164
+ const bad = connect("eve", "wrong-word-here");
165
+ const badResult = await new Promise((resolve) => {
166
+ bad.on("unexpected-response", (_req, res) => {
167
+ let body = "";
168
+ res.on("data", (c) => (body += c));
169
+ res.on("end", () => resolve({ status: res.statusCode, body }));
170
+ });
171
+ bad.on("error", (e) => resolve({ error: e.message }));
172
+ });
173
+ assert(badResult.status === 404, `错误房间码 → 404(${badResult.body})`);
174
+
175
+ // ── 人数上限 ──────────────────────────
176
+ console.log("\n[8] 人数上限");
177
+ const r2 = await fetch(`${HTTP}/api/rooms`, {
178
+ method: "POST",
179
+ headers: { "Content-Type": "application/json", "X-Admin-Key": ADMIN_KEY },
180
+ body: JSON.stringify({ maxMembers: 2, ttlSeconds: 600 }),
181
+ });
182
+ const room2 = await r2.json();
183
+ const c1 = connect("u1", room2.roomCode);
184
+ await once(c1, "joined");
185
+ const c2 = connect("u2", room2.roomCode);
186
+ await once(c2, "joined");
187
+ const c3 = connect("u3", room2.roomCode);
188
+ const fullResult = await new Promise((resolve) => {
189
+ c3.on("unexpected-response", (_req, res) => {
190
+ let body = "";
191
+ res.on("data", (ck) => (body += ck));
192
+ res.on("end", () => resolve({ status: res.statusCode, body }));
193
+ });
194
+ });
195
+ assert(fullResult.status === 403, `第三个人被拒 → 403(${fullResult.body})`);
196
+ c1.close();
197
+ c2.close();
198
+ c3.close();
199
+
200
+ // ── 手动销毁 ──────────────────────────
201
+ console.log("\n[9] 手动销毁房间");
202
+ const d = await fetch(`${HTTP}/api/rooms/${encodeURIComponent(room2.roomCode)}`, {
203
+ method: "DELETE",
204
+ headers: { "X-Admin-Key": ADMIN_KEY },
205
+ });
206
+ const dj = await d.json();
207
+ assert(d.status === 200 && dj.success === true, "销毁成功");
208
+ const afterDel = connect("late", room2.roomCode);
209
+ const afterResult = await new Promise((resolve) => {
210
+ afterDel.on("unexpected-response", (_req, res) => {
211
+ let body = "";
212
+ res.on("data", (ck) => (body += ck));
213
+ res.on("end", () => resolve({ status: res.statusCode, body }));
214
+ });
215
+ });
216
+ assert(afterResult.status === 404, `销毁后连接 → 404`);
217
+
218
+ a.close();
219
+ console.log(`\n──────────────\n结果:${pass} 通过 / ${fail} 失败`);
220
+ process.exit(fail > 0 ? 1 : 0);
221
+ }
222
+
223
+ main().catch((e) => {
224
+ console.error("测试异常:", e);
225
+ process.exit(1);
226
+ });
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "ephem-cli",
3
+ "version": "0.1.0",
4
+ "description": "临时、端到端加密的命令行聊天室 CLI",
5
+ "type": "module",
6
+ "bin": {
7
+ "ephem": "./dist/index.js"
8
+ },
9
+ "scripts": {
10
+ "build": "tsup",
11
+ "dev": "tsup --watch",
12
+ "typecheck": "tsc --noEmit",
13
+ "start": "node dist/index.js"
14
+ },
15
+ "dependencies": {
16
+ "commander": "^12.0.0",
17
+ "ink": "^5.0.0",
18
+ "ink-text-input": "^6.0.0",
19
+ "react": "^18.0.0",
20
+ "ws": "^8.0.0"
21
+ },
22
+ "devDependencies": {
23
+ "@types/node": "^20.0.0",
24
+ "@types/react": "^18.0.0",
25
+ "@types/ws": "^8.0.0",
26
+ "tsup": "^8.0.0",
27
+ "typescript": "^5.0.0"
28
+ },
29
+ "engines": {
30
+ "node": ">=18"
31
+ }
32
+ }
@@ -0,0 +1,43 @@
1
+ // AES-256-GCM 加解密封装。
2
+ // 约定:每条消息用独立随机 12 字节 nonce;认证标签 (authTag, 16 字节) 拼在密文末尾。
3
+ // 密文与 nonce 都用 base64 编码传输(JSON 友好)。
4
+ // 后端只原样转发 { ciphertext, nonce },不解密、不校验。
5
+
6
+ import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
7
+
8
+ const ALGO = "aes-256-gcm";
9
+ const NONCE_LEN = 12;
10
+ const TAG_LEN = 16;
11
+
12
+ export interface EncryptedPayload {
13
+ ciphertext: string; // base64(密文 + authTag)
14
+ nonce: string; // base64(12 字节 nonce)
15
+ }
16
+
17
+ /** 加密一条文本消息。 */
18
+ export function encrypt(key: Buffer, plaintext: string): EncryptedPayload {
19
+ const nonce = randomBytes(NONCE_LEN);
20
+ const cipher = createCipheriv(ALGO, key, nonce);
21
+ const enc = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
22
+ const tag = cipher.getAuthTag();
23
+ const combined = Buffer.concat([enc, tag]); // authTag 拼在末尾
24
+ return {
25
+ ciphertext: combined.toString("base64"),
26
+ nonce: nonce.toString("base64"),
27
+ };
28
+ }
29
+
30
+ /** 解密一条消息。认证失败会抛错(说明密钥不对或密文被篡改)。 */
31
+ export function decrypt(key: Buffer, payload: EncryptedPayload): string {
32
+ const combined = Buffer.from(payload.ciphertext, "base64");
33
+ const nonce = Buffer.from(payload.nonce, "base64");
34
+ if (combined.length < TAG_LEN + 1) {
35
+ throw new Error("密文长度异常");
36
+ }
37
+ const tag = combined.subarray(combined.length - TAG_LEN);
38
+ const enc = combined.subarray(0, combined.length - TAG_LEN);
39
+ const decipher = createDecipheriv(ALGO, key, nonce);
40
+ decipher.setAuthTag(tag);
41
+ const dec = Buffer.concat([decipher.update(enc), decipher.final()]);
42
+ return dec.toString("utf8");
43
+ }
@@ -0,0 +1,21 @@
1
+ // 从房间码派生对称密钥:HKDF(SHA-256) → 32 字节 AES-256-GCM 密钥。
2
+ // 全程在客户端本地完成,房间码本身不因此通过网络发给后端。
3
+ //
4
+ // 设计说明:这是"共享密码派生密钥"模式(PAKE 的简化版)。房间码同时承担
5
+ // 路由标识和密钥种子双重职责。攻击者要验证猜测必须先连上对应房间码的 WS
6
+ // 端点,而后端对单房间码连接尝试做了限流。
7
+
8
+ import { hkdfSync } from "node:crypto";
9
+
10
+ const SALT = "ephem-v1-room-salt";
11
+ const INFO = "ephem-room-encryption-key";
12
+ const KEY_LEN = 32; // AES-256
13
+
14
+ /** 从房间码派生房间加密密钥(32 字节)。 */
15
+ export function deriveRoomKey(roomCode: string): Buffer {
16
+ const ikm = Buffer.from(roomCode, "utf8");
17
+ const salt = Buffer.from(SALT, "utf8");
18
+ const info = Buffer.from(INFO, "utf8");
19
+ // hkdfSync 在 Node 18+ 返回 Buffer
20
+ return Buffer.from(hkdfSync("sha256", ikm, salt, info, KEY_LEN));
21
+ }
package/src/index.ts ADDED
@@ -0,0 +1,37 @@
1
+ // ephem-cli 入口:解析命令行参数,未提供的走交互式问答。
2
+
3
+ import React from "react";
4
+ import { render } from "ink";
5
+ import { Command } from "commander";
6
+ import { App } from "./ui/App.js";
7
+
8
+ const program = new Command();
9
+
10
+ program
11
+ .name("ephem")
12
+ .description("临时、端到端加密的命令行聊天室")
13
+ .option("-s, --server <url>", "后端地址(也可用 EPHEM_SERVER 环境变量)")
14
+ .option("-r, --room <code>", "房间码,例如 correct-horse-battery")
15
+ .option("-u, --username <name>", "用户名")
16
+ .helpOption("-h, --help", "查看帮助")
17
+ .action((opts) => {
18
+ const defaults = {
19
+ server: opts.server ?? process.env.EPHEM_SERVER,
20
+ room: opts.room,
21
+ username: opts.username,
22
+ };
23
+
24
+ // 安全提醒:命令行参数传房间码会被记录到 shell history,优先用交互式输入。
25
+ if (opts.room) {
26
+ process.stderr.write(
27
+ "⚠ 提示:通过 --room 传入的房间码可能被记录到 shell 历史,建议优先交互式输入。\n",
28
+ );
29
+ }
30
+
31
+ const instance = render(React.createElement(App, { defaults }));
32
+ instance.waitUntilExit()
33
+ .then(() => process.exit(0))
34
+ .catch(() => process.exit(1));
35
+ });
36
+
37
+ program.parse(process.argv);
package/src/ui/App.tsx ADDED
@@ -0,0 +1,129 @@
1
+ import React, { useCallback, useEffect, useRef, useState } from "react";
2
+ import { Box, Text, useApp, useInput } from "ink";
3
+ import { SetupWizard, type ConnectConfig } from "./SetupWizard.js";
4
+ import { ChatRoom } from "./ChatRoom.js";
5
+ import { RoomClient, type JoinedInfo } from "../ws/client.js";
6
+
7
+ interface Props {
8
+ defaults: { server?: string; room?: string; username?: string };
9
+ }
10
+
11
+ type Phase = "setup" | "connecting" | "chat" | "error";
12
+
13
+ export function App({ defaults }: Props) {
14
+ const { exit } = useApp();
15
+ const skipSetup = Boolean(defaults.server && defaults.room && defaults.username);
16
+ const [phase, setPhase] = useState<Phase>(skipSetup ? "connecting" : "setup");
17
+ const [client, setClient] = useState<RoomClient | null>(null);
18
+ const [joined, setJoined] = useState<JoinedInfo | null>(null);
19
+ const [error, setError] = useState<{ code: string; message: string } | null>(null);
20
+ const cfgRef = useRef<ConnectConfig | null>(
21
+ skipSetup
22
+ ? { server: defaults.server!, room: defaults.room!, username: defaults.username! }
23
+ : null,
24
+ );
25
+
26
+ const connect = useCallback((config: ConnectConfig) => {
27
+ cfgRef.current = config;
28
+ const c = new RoomClient(config.server, config.room, config.username);
29
+ setClient(c);
30
+ setPhase("connecting");
31
+ setError(null);
32
+ setJoined(null);
33
+
34
+ c.on("joined", (info: JoinedInfo) => {
35
+ setJoined(info);
36
+ setPhase("chat");
37
+ });
38
+ c.on("server_error", (info: { code: string; message: string }) => {
39
+ setError(info);
40
+ setPhase("error");
41
+ });
42
+ // 连接彻底关闭时,若仍处于 connecting 则视为失败
43
+ c.on("closed", () => {
44
+ setPhase((p) => (p === "connecting" ? "error" : p));
45
+ });
46
+ c.connect();
47
+ }, []);
48
+
49
+ // 命令行参数齐全时直接连接
50
+ useEffect(() => {
51
+ if (skipSetup && cfgRef.current) connect(cfgRef.current);
52
+ // eslint-disable-next-line react-hooks/exhaustive-deps
53
+ }, []);
54
+
55
+ // 退出清理
56
+ useEffect(() => () => client?.close(), [client]);
57
+
58
+ const handleRetry = useCallback(() => {
59
+ client?.close();
60
+ setClient(null);
61
+ setJoined(null);
62
+ setError(null);
63
+ setPhase("setup");
64
+ }, [client]);
65
+
66
+ if (phase === "setup") {
67
+ return <SetupWizard defaults={defaults} onComplete={connect} />;
68
+ }
69
+
70
+ if (phase === "connecting") {
71
+ return (
72
+ <Box flexDirection="column" gap={1}>
73
+ <Text color="cyan">正在连接…</Text>
74
+ <Text color="gray">服务器:{cfgRef.current?.server}</Text>
75
+ <Text color="gray">房间:{cfgRef.current?.room}</Text>
76
+ </Box>
77
+ );
78
+ }
79
+
80
+ if (phase === "error") {
81
+ return (
82
+ <ErrorScreen
83
+ message={error?.message ?? "未知错误"}
84
+ code={error?.code ?? "unknown"}
85
+ onRetry={handleRetry}
86
+ onExit={() => exit()}
87
+ />
88
+ );
89
+ }
90
+
91
+ // chat
92
+ if (!client || !joined || !cfgRef.current) return null;
93
+ return (
94
+ <ChatRoom
95
+ client={client}
96
+ roomCode={cfgRef.current.room}
97
+ username={cfgRef.current.username}
98
+ joined={joined}
99
+ onExit={() => exit()}
100
+ />
101
+ );
102
+ }
103
+
104
+ function ErrorScreen({
105
+ message,
106
+ code,
107
+ onRetry,
108
+ onExit,
109
+ }: {
110
+ message: string;
111
+ code: string;
112
+ onRetry: () => void;
113
+ onExit: () => void;
114
+ }) {
115
+ useInput((input, key) => {
116
+ if (key.return) onRetry();
117
+ });
118
+ return (
119
+ <Box flexDirection="column" gap={1}>
120
+ <Text color="red" bold>
121
+ 连接失败
122
+ </Text>
123
+ <Text color="gray">
124
+ {message}({code})
125
+ </Text>
126
+ <Text color="gray">按回车返回设置重试,Ctrl+C 退出</Text>
127
+ </Box>
128
+ );
129
+ }
@@ -0,0 +1,194 @@
1
+ import React, { useEffect, useReducer, useRef, useState } from "react";
2
+ import { Box, Text, useApp, useStdout } from "ink";
3
+ import TextInput from "ink-text-input";
4
+ import type { RoomClient, JoinedInfo, ChatMessage } from "../ws/client.js";
5
+ import { deriveRoomKey } from "../crypto/deriveKey.js";
6
+ import { encrypt, decrypt } from "../crypto/cipher.js";
7
+
8
+ interface Props {
9
+ client: RoomClient;
10
+ roomCode: string;
11
+ username: string;
12
+ joined: JoinedInfo;
13
+ onExit: () => void;
14
+ }
15
+
16
+ type Line =
17
+ | { id: number; kind: "system"; text: string }
18
+ | { id: number; kind: "msg"; from: string; text: string; self: boolean; time: string };
19
+
20
+ let lineId = 0;
21
+
22
+ export function ChatRoom({ client, roomCode, username, joined, onExit }: Props) {
23
+ const { exit } = useApp();
24
+ const { stdout } = useStdout();
25
+ const roomKey = useRef(deriveRoomKey(roomCode));
26
+
27
+ const [lines, dispatch] = useReducer(
28
+ (state: Line[], action: { type: "add"; line: Line } | { type: "clear" }) => {
29
+ if (action.type === "clear") return [];
30
+ return [...state, action.line].slice(-500);
31
+ },
32
+ [],
33
+ );
34
+ const [input, setInput] = useState("");
35
+ const [members, setMembers] = useState(joined.currentMembers);
36
+ const [maxMembers] = useState(joined.maxMembers);
37
+ const [expiresAt] = useState(joined.expiresAt);
38
+ const [remaining, setRemaining] = useState(() => Math.max(0, Math.floor((joined.expiresAt - Date.now()) / 1000)));
39
+ const [closing, setClosing] = useState<string | null>(null);
40
+
41
+ const addSystem = (text: string) =>
42
+ dispatch({ type: "add", line: { id: ++lineId, kind: "system", text } });
43
+ const addMsg = (from: string, text: string, self: boolean) =>
44
+ dispatch({
45
+ type: "add",
46
+ line: { id: ++lineId, kind: "msg", from, text, self, time: nowStr() },
47
+ });
48
+
49
+ // 订阅客户端事件
50
+ useEffect(() => {
51
+ addSystem(`已加入房间 ${roomCode}(${joined.currentMembers}/${joined.maxMembers} 人)`);
52
+
53
+ const onPeerJoined = ({ username: u }: { username: string }) => {
54
+ setMembers((m) => m + 1);
55
+ addSystem(`${u} 加入了房间`);
56
+ };
57
+ const onPeerLeft = ({ username: u }: { username: string }) => {
58
+ setMembers((m) => Math.max(0, m - 1));
59
+ addSystem(`${u} 离开了房间`);
60
+ };
61
+ const onMessage = (msg: ChatMessage) => {
62
+ try {
63
+ const text = decrypt(roomKey.current, { ciphertext: msg.ciphertext, nonce: msg.nonce });
64
+ addMsg(msg.from, text, false);
65
+ } catch {
66
+ addSystem(`收到来自 ${msg.from} 的无法解密的消息`);
67
+ }
68
+ };
69
+ const onRoomClosing = ({ reason }: { reason: string }) => {
70
+ const reasonText =
71
+ reason === "ttl_expired" ? "房间已到期" : reason === "empty" ? "房间已空" : "房间被手动销毁";
72
+ setClosing(reasonText);
73
+ addSystem(`房间即将关闭:${reasonText}`);
74
+ setTimeout(() => {
75
+ client.close();
76
+ onExit();
77
+ exit();
78
+ }, 1500);
79
+ };
80
+ const onServerError = (info: { code: string; message: string }) => {
81
+ addSystem(`错误:${info.message} (${info.code})`);
82
+ };
83
+
84
+ client.on("peer_joined", onPeerJoined);
85
+ client.on("peer_left", onPeerLeft);
86
+ client.on("message", onMessage);
87
+ client.on("room_closing", onRoomClosing);
88
+ client.on("server_error", onServerError);
89
+
90
+ return () => {
91
+ client.off("peer_joined", onPeerJoined);
92
+ client.off("peer_left", onPeerLeft);
93
+ client.off("message", onMessage);
94
+ client.off("room_closing", onRoomClosing);
95
+ client.off("server_error", onServerError);
96
+ };
97
+ // eslint-disable-next-line react-hooks/exhaustive-deps
98
+ }, [client]);
99
+
100
+ // 倒计时
101
+ useEffect(() => {
102
+ const t = setInterval(() => {
103
+ const r = Math.max(0, Math.floor((expiresAt - Date.now()) / 1000));
104
+ setRemaining(r);
105
+ }, 1000);
106
+ return () => clearInterval(t);
107
+ }, [expiresAt]);
108
+
109
+ // 退出时关闭连接
110
+ useEffect(() => () => client.close(), [client]);
111
+
112
+ function handleSend(text: string) {
113
+ const t = text.trim();
114
+ if (!t) return;
115
+ try {
116
+ const { ciphertext, nonce } = encrypt(roomKey.current, t);
117
+ client.send(ciphertext, nonce);
118
+ addMsg(username, t, true);
119
+ } catch {
120
+ addSystem("发送失败:加密出错");
121
+ }
122
+ setInput("");
123
+ }
124
+
125
+ // 可视区域:留出 header(2) + 输入区(3) 的空间
126
+ const rows = stdout?.rows ?? 24;
127
+ const visible = Math.max(4, rows - 6);
128
+
129
+ const cdColor = remaining < 60 ? "red" : remaining < 300 ? "yellow" : "gray";
130
+
131
+ return (
132
+ <Box flexDirection="column" height={rows}>
133
+ {/* Header */}
134
+ <Box flexDirection="column">
135
+ <Box>
136
+ <Text color="cyan" bold>
137
+ ephem
138
+ </Text>
139
+ <Text color="gray"> · </Text>
140
+ <Text bold>{roomCode}</Text>
141
+ <Text color="gray">
142
+ {" "}
143
+ {members}/{maxMembers} 人
144
+ </Text>
145
+ <Box flexGrow={1} />
146
+ <Text color={cdColor}>⏳ {fmtCd(remaining)}</Text>
147
+ </Box>
148
+ <Text color="gray">输入消息回车发送 · Ctrl+C 退出{closing ? ` · ${closing}` : ""}</Text>
149
+ </Box>
150
+
151
+ {/* 消息列表 */}
152
+ <Box flexDirection="column" flexGrow={1} marginTop={1}>
153
+ {lines.slice(-visible).map((l) =>
154
+ l.kind === "system" ? (
155
+ <Text key={l.id} color="yellow">
156
+ {" "}
157
+ {l.text}
158
+ </Text>
159
+ ) : (
160
+ <Box key={l.id}>
161
+ <Text color="gray">{l.time} </Text>
162
+ <Text color={l.self ? "cyan" : "white"} bold>
163
+ {l.from}
164
+ </Text>
165
+ <Text color={l.self ? "cyan" : "white"}>: {l.text}</Text>
166
+ </Box>
167
+ ),
168
+ )}
169
+ </Box>
170
+
171
+ {/* 输入栏 */}
172
+ <Box marginTop={1}>
173
+ <Text color="cyan">{"> "}</Text>
174
+ <TextInput
175
+ value={input}
176
+ onChange={setInput}
177
+ onSubmit={handleSend}
178
+ placeholder={closing ? "房间即将关闭…" : "输入消息…"}
179
+ />
180
+ </Box>
181
+ </Box>
182
+ );
183
+ }
184
+
185
+ function nowStr(): string {
186
+ return new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
187
+ }
188
+
189
+ function fmtCd(sec: number): string {
190
+ const h = Math.floor(sec / 3600);
191
+ const m = Math.floor((sec % 3600) / 60);
192
+ const s = sec % 60;
193
+ return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
194
+ }