ephem-cli 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +255 -63
- package/dist/index.js.map +1 -1
- package/integration-test.mjs +27 -1
- package/package.json +2 -2
- package/src/protocol/message.ts +127 -0
- package/src/ui/ChatRoom.tsx +165 -38
- package/src/ui/SetupWizard.tsx +28 -9
package/dist/index.js
CHANGED
|
@@ -7,7 +7,7 @@ import { Command } from "commander";
|
|
|
7
7
|
|
|
8
8
|
// src/ui/App.tsx
|
|
9
9
|
import { useCallback, useEffect as useEffect2, useRef as useRef2, useState as useState3 } from "react";
|
|
10
|
-
import { Box as Box3, Text as Text3, useApp as useApp2, useInput } from "ink";
|
|
10
|
+
import { Box as Box3, Text as Text3, useApp as useApp2, useInput as useInput2 } from "ink";
|
|
11
11
|
|
|
12
12
|
// src/ui/SetupWizard.tsx
|
|
13
13
|
import { useState } from "react";
|
|
@@ -20,12 +20,19 @@ function SetupWizard({ defaults, onComplete }) {
|
|
|
20
20
|
const [server, setServer] = useState(defaults.server ?? "");
|
|
21
21
|
const [room, setRoom] = useState((defaults.room ?? "").toLowerCase());
|
|
22
22
|
const [username, setUsername] = useState(defaults.username ?? "");
|
|
23
|
+
const [error, setError] = useState(null);
|
|
23
24
|
const values = [server, room, username];
|
|
24
25
|
const setters = [setServer, setRoom, setUsername];
|
|
25
26
|
function submit(value) {
|
|
26
27
|
const v = value.trim();
|
|
27
28
|
setters[step](v);
|
|
29
|
+
setError(null);
|
|
28
30
|
if (step === 0 && !v) {
|
|
31
|
+
setError("\u8BF7\u8F93\u5165\u540E\u7AEF\u5730\u5740\uFF0C\u4F8B\u5982 wss://your-worker.workers.dev");
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (step === 1 && !/^[a-z]+-[a-z]+-[a-z]+$/.test(v.toLowerCase())) {
|
|
35
|
+
setError("\u623F\u95F4\u7801\u683C\u5F0F\u5E94\u4E3A\u4E09\u6BB5\u82F1\u6587\u5355\u8BCD\uFF0C\u4F8B\u5982 correct-horse-battery");
|
|
29
36
|
return;
|
|
30
37
|
}
|
|
31
38
|
if (step < 2) {
|
|
@@ -39,17 +46,20 @@ function SetupWizard({ defaults, onComplete }) {
|
|
|
39
46
|
}
|
|
40
47
|
}
|
|
41
48
|
;
|
|
42
|
-
return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", gap: 1, children: [
|
|
49
|
+
return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", gap: 1, borderStyle: "round", borderColor: "cyan", paddingX: 1, paddingY: 1, children: [
|
|
43
50
|
/* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
|
|
44
|
-
/* @__PURE__ */
|
|
45
|
-
|
|
51
|
+
/* @__PURE__ */ jsxs(Text, { children: [
|
|
52
|
+
/* @__PURE__ */ jsx(Text, { color: "cyan", bold: true, children: "ephem" }),
|
|
53
|
+
/* @__PURE__ */ jsx(Text, { color: "gray", children: " \xB7 \u4E34\u65F6\u52A0\u5BC6\u804A\u5929\u5BA4" })
|
|
54
|
+
] }),
|
|
55
|
+
/* @__PURE__ */ jsx(Text, { color: "gray", children: "\u6309\u56DE\u8F66\u8FDB\u5165\u4E0B\u4E00\u6B65\uFF0CCtrl+C \u9000\u51FA\u3002\u623F\u95F4\u7801\u548C\u5BC6\u94A5\u4E0D\u4F1A\u843D\u76D8\u3002" })
|
|
46
56
|
] }),
|
|
47
57
|
STEPS.map((label, i) => {
|
|
48
58
|
const done = i < step;
|
|
49
59
|
const active = i === step;
|
|
50
|
-
return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
|
|
51
|
-
/* @__PURE__ */ jsxs(Text, { color: active ? "cyan" : "gray", children: [
|
|
52
|
-
done ? "\u2713" : active ? "
|
|
60
|
+
return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", marginTop: i === 0 ? 1 : 0, children: [
|
|
61
|
+
/* @__PURE__ */ jsxs(Text, { color: active ? "cyan" : done ? "green" : "gray", children: [
|
|
62
|
+
done ? "\u2713" : active ? "\u203A" : "\xB7",
|
|
53
63
|
" ",
|
|
54
64
|
label,
|
|
55
65
|
i === 0 && defaults.server ? "\uFF08\u56DE\u8F66\u4F7F\u7528\u9ED8\u8BA4\u503C\uFF09" : ""
|
|
@@ -67,16 +77,21 @@ function SetupWizard({ defaults, onComplete }) {
|
|
|
67
77
|
)
|
|
68
78
|
] }) : done ? /* @__PURE__ */ jsxs(Text, { color: "gray", children: [
|
|
69
79
|
" ",
|
|
70
|
-
values[i] || "(\u7A7A)"
|
|
80
|
+
i === 2 && !values[i] ? "\u533F\u540D" : values[i] || "(\u7A7A)"
|
|
71
81
|
] }) : null
|
|
72
82
|
] }, label);
|
|
73
|
-
})
|
|
83
|
+
}),
|
|
84
|
+
error ? /* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsxs(Text, { color: "red", children: [
|
|
85
|
+
"\u9519\u8BEF\uFF1A",
|
|
86
|
+
error
|
|
87
|
+
] }) }) : /* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsx(Text, { color: "gray", children: "\u63D0\u793A\uFF1A\u8FDB\u5165\u804A\u5929\u540E\u53EF\u7528 /image <\u8DEF\u5F84> \u53D1\u9001\u5C0F\u4E8E 1 MiB \u7684\u56FE\u7247\u3002" }) })
|
|
74
88
|
] });
|
|
75
89
|
}
|
|
76
90
|
|
|
77
91
|
// src/ui/ChatRoom.tsx
|
|
78
92
|
import { useEffect, useReducer, useRef, useState as useState2 } from "react";
|
|
79
|
-
import {
|
|
93
|
+
import { readFile, stat } from "fs/promises";
|
|
94
|
+
import { Box as Box2, Text as Text2, useApp, useInput, useStdout } from "ink";
|
|
80
95
|
import TextInput2 from "ink-text-input";
|
|
81
96
|
|
|
82
97
|
// src/crypto/deriveKey.ts
|
|
@@ -121,6 +136,83 @@ function decrypt(key, payload) {
|
|
|
121
136
|
return dec.toString("utf8");
|
|
122
137
|
}
|
|
123
138
|
|
|
139
|
+
// src/protocol/message.ts
|
|
140
|
+
import { basename } from "path";
|
|
141
|
+
var IMAGE_MAX_BYTES = 1024 * 1024;
|
|
142
|
+
function encodeTextMessage(text) {
|
|
143
|
+
return JSON.stringify({ v: 1, kind: "text", text });
|
|
144
|
+
}
|
|
145
|
+
function encodeImageMessage(input) {
|
|
146
|
+
return JSON.stringify({
|
|
147
|
+
v: 1,
|
|
148
|
+
kind: "image",
|
|
149
|
+
mime: input.mime,
|
|
150
|
+
name: input.name,
|
|
151
|
+
size: input.size,
|
|
152
|
+
data: input.data
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
function parsePlaintextMessage(plaintext) {
|
|
156
|
+
try {
|
|
157
|
+
const msg = JSON.parse(plaintext);
|
|
158
|
+
if (msg && msg.v === 1 && msg.kind === "text" && typeof msg.text === "string") {
|
|
159
|
+
return { kind: "text", text: msg.text, structured: true };
|
|
160
|
+
}
|
|
161
|
+
if (msg && msg.v === 1 && msg.kind === "image" && typeof msg.mime === "string" && typeof msg.size === "number" && typeof msg.data === "string") {
|
|
162
|
+
return {
|
|
163
|
+
kind: "image",
|
|
164
|
+
mime: msg.mime,
|
|
165
|
+
name: typeof msg.name === "string" ? msg.name : void 0,
|
|
166
|
+
size: msg.size,
|
|
167
|
+
width: typeof msg.width === "number" ? msg.width : void 0,
|
|
168
|
+
height: typeof msg.height === "number" ? msg.height : void 0
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
} catch {
|
|
172
|
+
}
|
|
173
|
+
return { kind: "text", text: plaintext, structured: false };
|
|
174
|
+
}
|
|
175
|
+
function imageSummary(image) {
|
|
176
|
+
const name = image.name ? `${image.name} \xB7 ` : "";
|
|
177
|
+
const dim = image.width && image.height ? ` \xB7 ${image.width}x${image.height}` : "";
|
|
178
|
+
return `[\u56FE\u7247 ${name}${formatBytes(image.size)} \xB7 ${image.mime}${dim}]`;
|
|
179
|
+
}
|
|
180
|
+
function formatBytes(bytes) {
|
|
181
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
182
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} KB`;
|
|
183
|
+
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
|
184
|
+
}
|
|
185
|
+
function parseImageCommand(input) {
|
|
186
|
+
const trimmed = input.trim();
|
|
187
|
+
if (!trimmed.toLowerCase().startsWith("/image")) return null;
|
|
188
|
+
const rest = trimmed.slice("/image".length).trim();
|
|
189
|
+
if (!rest) return "";
|
|
190
|
+
if (rest.startsWith('"') && rest.endsWith('"') || rest.startsWith("'") && rest.endsWith("'")) {
|
|
191
|
+
return rest.slice(1, -1);
|
|
192
|
+
}
|
|
193
|
+
return rest;
|
|
194
|
+
}
|
|
195
|
+
function displayFileName(filePath) {
|
|
196
|
+
return basename(filePath) || "image";
|
|
197
|
+
}
|
|
198
|
+
function detectImageMime(bytes, fileName) {
|
|
199
|
+
if (bytes.length >= 3 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) return "image/jpeg";
|
|
200
|
+
if (bytes.length >= 8 && bytes[0] === 137 && bytes[1] === 80 && bytes[2] === 78 && bytes[3] === 71 && bytes[4] === 13 && bytes[5] === 10 && bytes[6] === 26 && bytes[7] === 10) {
|
|
201
|
+
return "image/png";
|
|
202
|
+
}
|
|
203
|
+
if (bytes.length >= 12 && ascii(bytes, 0, 4) === "RIFF" && ascii(bytes, 8, 12) === "WEBP") return "image/webp";
|
|
204
|
+
if (bytes.length >= 6 && (ascii(bytes, 0, 6) === "GIF87a" || ascii(bytes, 0, 6) === "GIF89a")) return "image/gif";
|
|
205
|
+
const lower = fileName.toLowerCase();
|
|
206
|
+
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
|
|
207
|
+
if (lower.endsWith(".png")) return "image/png";
|
|
208
|
+
if (lower.endsWith(".webp")) return "image/webp";
|
|
209
|
+
if (lower.endsWith(".gif")) return "image/gif";
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
function ascii(bytes, start, end) {
|
|
213
|
+
return String.fromCharCode(...bytes.subarray(start, end));
|
|
214
|
+
}
|
|
215
|
+
|
|
124
216
|
// src/ui/ChatRoom.tsx
|
|
125
217
|
import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
126
218
|
var lineId = 0;
|
|
@@ -141,10 +233,16 @@ function ChatRoom({ client, roomCode, username, joined, onExit }) {
|
|
|
141
233
|
const [expiresAt] = useState2(joined.expiresAt);
|
|
142
234
|
const [remaining, setRemaining] = useState2(() => Math.max(0, Math.floor((joined.expiresAt - Date.now()) / 1e3)));
|
|
143
235
|
const [closing, setClosing] = useState2(null);
|
|
144
|
-
const
|
|
145
|
-
const
|
|
236
|
+
const [status, setStatus] = useState2("online");
|
|
237
|
+
const [statusText, setStatusText] = useState2("\u5DF2\u8FDE\u63A5");
|
|
238
|
+
const addSystem = (text, level = "info") => dispatch({ type: "add", line: { id: ++lineId, kind: "system", text, level } });
|
|
239
|
+
const addText = (from, text, self) => dispatch({
|
|
146
240
|
type: "add",
|
|
147
|
-
line: { id: ++lineId, kind: "
|
|
241
|
+
line: { id: ++lineId, kind: "text", from, text, self, time: nowStr() }
|
|
242
|
+
});
|
|
243
|
+
const addImage = (from, summary, self) => dispatch({
|
|
244
|
+
type: "add",
|
|
245
|
+
line: { id: ++lineId, kind: "image", from, summary, self, time: nowStr() }
|
|
148
246
|
});
|
|
149
247
|
useEffect(() => {
|
|
150
248
|
addSystem(`\u5DF2\u52A0\u5165\u623F\u95F4 ${roomCode}\uFF08${joined.currentMembers}/${joined.maxMembers} \u4EBA\uFF09`);
|
|
@@ -158,16 +256,20 @@ function ChatRoom({ client, roomCode, username, joined, onExit }) {
|
|
|
158
256
|
};
|
|
159
257
|
const onMessage = (msg) => {
|
|
160
258
|
try {
|
|
161
|
-
const
|
|
162
|
-
|
|
259
|
+
const plaintext = decrypt(roomKey.current, { ciphertext: msg.ciphertext, nonce: msg.nonce });
|
|
260
|
+
const parsed = parsePlaintextMessage(plaintext);
|
|
261
|
+
if (parsed.kind === "image") addImage(msg.from, imageSummary(parsed), false);
|
|
262
|
+
else addText(msg.from, parsed.text, false);
|
|
163
263
|
} catch {
|
|
164
|
-
addSystem(`\u6536\u5230\u6765\u81EA ${msg.from} \u7684\u65E0\u6CD5\u89E3\u5BC6\u7684\u6D88\u606F
|
|
264
|
+
addSystem(`\u6536\u5230\u6765\u81EA ${msg.from} \u7684\u65E0\u6CD5\u89E3\u5BC6\u7684\u6D88\u606F`, "warn");
|
|
165
265
|
}
|
|
166
266
|
};
|
|
167
267
|
const onRoomClosing = ({ reason }) => {
|
|
168
268
|
const reasonText = reason === "ttl_expired" ? "\u623F\u95F4\u5DF2\u5230\u671F" : reason === "empty" ? "\u623F\u95F4\u5DF2\u7A7A" : "\u623F\u95F4\u88AB\u624B\u52A8\u9500\u6BC1";
|
|
269
|
+
setStatus("closing");
|
|
270
|
+
setStatusText(reasonText);
|
|
169
271
|
setClosing(reasonText);
|
|
170
|
-
addSystem(`\u623F\u95F4\u5373\u5C06\u5173\u95ED\uFF1A${reasonText}
|
|
272
|
+
addSystem(`\u623F\u95F4\u5373\u5C06\u5173\u95ED\uFF1A${reasonText}`, "warn");
|
|
171
273
|
setTimeout(() => {
|
|
172
274
|
client.close();
|
|
173
275
|
onExit();
|
|
@@ -175,19 +277,32 @@ function ChatRoom({ client, roomCode, username, joined, onExit }) {
|
|
|
175
277
|
}, 1500);
|
|
176
278
|
};
|
|
177
279
|
const onServerError = (info) => {
|
|
178
|
-
addSystem(`\u9519\u8BEF\uFF1A${info.message} (${info.code})
|
|
280
|
+
addSystem(`\u9519\u8BEF\uFF1A${info.message} (${info.code})`, "error");
|
|
281
|
+
};
|
|
282
|
+
const onReconnecting = ({ attempt, delayMs }) => {
|
|
283
|
+
setStatus("reconnecting");
|
|
284
|
+
setStatusText(`\u91CD\u8FDE #${attempt}\uFF0C${Math.ceil(delayMs / 1e3)}s \u540E`);
|
|
285
|
+
addSystem(`\u8FDE\u63A5\u65AD\u5F00\uFF0C\u51C6\u5907\u7B2C ${attempt} \u6B21\u91CD\u8FDE`, "warn");
|
|
286
|
+
};
|
|
287
|
+
const onJoined = () => {
|
|
288
|
+
setStatus("online");
|
|
289
|
+
setStatusText("\u5DF2\u8FDE\u63A5");
|
|
179
290
|
};
|
|
291
|
+
client.on("joined", onJoined);
|
|
180
292
|
client.on("peer_joined", onPeerJoined);
|
|
181
293
|
client.on("peer_left", onPeerLeft);
|
|
182
294
|
client.on("message", onMessage);
|
|
183
295
|
client.on("room_closing", onRoomClosing);
|
|
184
296
|
client.on("server_error", onServerError);
|
|
297
|
+
client.on("reconnecting", onReconnecting);
|
|
185
298
|
return () => {
|
|
299
|
+
client.off("joined", onJoined);
|
|
186
300
|
client.off("peer_joined", onPeerJoined);
|
|
187
301
|
client.off("peer_left", onPeerLeft);
|
|
188
302
|
client.off("message", onMessage);
|
|
189
303
|
client.off("room_closing", onRoomClosing);
|
|
190
304
|
client.off("server_error", onServerError);
|
|
305
|
+
client.off("reconnecting", onReconnecting);
|
|
191
306
|
};
|
|
192
307
|
}, [client]);
|
|
193
308
|
useEffect(() => {
|
|
@@ -198,75 +313,152 @@ function ChatRoom({ client, roomCode, username, joined, onExit }) {
|
|
|
198
313
|
return () => clearInterval(t);
|
|
199
314
|
}, [expiresAt]);
|
|
200
315
|
useEffect(() => () => client.close(), [client]);
|
|
201
|
-
|
|
316
|
+
useInput((input2, key) => {
|
|
317
|
+
if (key.ctrl && input2.toLowerCase() === "l") {
|
|
318
|
+
dispatch({ type: "clear" });
|
|
319
|
+
addSystem("\u5DF2\u6E05\u5C4F");
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
async function handleSend(text) {
|
|
202
323
|
const t = text.trim();
|
|
203
324
|
if (!t) return;
|
|
325
|
+
const imagePath = parseImageCommand(t);
|
|
326
|
+
if (imagePath !== null) {
|
|
327
|
+
await handleImageSend(imagePath);
|
|
328
|
+
setInput("");
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
204
331
|
try {
|
|
205
|
-
const { ciphertext, nonce } = encrypt(roomKey.current, t);
|
|
332
|
+
const { ciphertext, nonce } = encrypt(roomKey.current, encodeTextMessage(t));
|
|
206
333
|
client.send(ciphertext, nonce);
|
|
207
|
-
|
|
334
|
+
addText(username, t, true);
|
|
208
335
|
} catch {
|
|
209
|
-
addSystem("\u53D1\u9001\u5931\u8D25\uFF1A\u52A0\u5BC6\u51FA\u9519");
|
|
336
|
+
addSystem("\u53D1\u9001\u5931\u8D25\uFF1A\u52A0\u5BC6\u51FA\u9519", "error");
|
|
210
337
|
}
|
|
211
338
|
setInput("");
|
|
212
339
|
}
|
|
340
|
+
async function handleImageSend(filePath) {
|
|
341
|
+
if (!filePath) {
|
|
342
|
+
addSystem("\u7528\u6CD5\uFF1A/image <\u56FE\u7247\u8DEF\u5F84>", "warn");
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
try {
|
|
346
|
+
const info = await stat(filePath);
|
|
347
|
+
if (!info.isFile()) {
|
|
348
|
+
addSystem("\u53D1\u9001\u5931\u8D25\uFF1A\u8DEF\u5F84\u4E0D\u662F\u6587\u4EF6", "error");
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
if (info.size > IMAGE_MAX_BYTES) {
|
|
352
|
+
addSystem(`\u53D1\u9001\u5931\u8D25\uFF1A\u56FE\u7247\u4E0D\u80FD\u8D85\u8FC7 ${formatBytes(IMAGE_MAX_BYTES)}\uFF0C\u5F53\u524D ${formatBytes(info.size)}`, "error");
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
const bytes = await readFile(filePath);
|
|
356
|
+
const name = displayFileName(filePath);
|
|
357
|
+
const mime = detectImageMime(bytes, name);
|
|
358
|
+
if (!mime) {
|
|
359
|
+
addSystem("\u53D1\u9001\u5931\u8D25\uFF1A\u4EC5\u652F\u6301 jpg/png/webp/gif \u56FE\u7247", "error");
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
const plaintext = encodeImageMessage({
|
|
363
|
+
name,
|
|
364
|
+
mime,
|
|
365
|
+
size: bytes.length,
|
|
366
|
+
data: bytes.toString("base64")
|
|
367
|
+
});
|
|
368
|
+
const { ciphertext, nonce } = encrypt(roomKey.current, plaintext);
|
|
369
|
+
client.send(ciphertext, nonce);
|
|
370
|
+
addImage(username, imageSummary({ name, mime, size: bytes.length }), true);
|
|
371
|
+
} catch (err) {
|
|
372
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
373
|
+
addSystem(`\u53D1\u9001\u56FE\u7247\u5931\u8D25\uFF1A${message}`, "error");
|
|
374
|
+
}
|
|
375
|
+
}
|
|
213
376
|
const rows = stdout?.rows ?? 24;
|
|
214
|
-
const
|
|
377
|
+
const columns = stdout?.columns ?? 80;
|
|
378
|
+
const compact = rows < 18 || columns < 72;
|
|
379
|
+
const visible = Math.max(4, rows - (compact ? 7 : 9));
|
|
215
380
|
const cdColor = remaining < 60 ? "red" : remaining < 300 ? "yellow" : "gray";
|
|
381
|
+
const statusColor = status === "online" ? "green" : status === "closing" ? "yellow" : "cyan";
|
|
216
382
|
return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", height: rows, children: [
|
|
217
|
-
/* @__PURE__ */
|
|
218
|
-
/* @__PURE__ */
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
/* @__PURE__ */ jsx2(Text2, { bold: true, children: roomCode }),
|
|
222
|
-
/* @__PURE__ */ jsxs2(Text2, { color: "gray", children: [
|
|
223
|
-
" ",
|
|
224
|
-
members,
|
|
225
|
-
"/",
|
|
226
|
-
maxMembers,
|
|
227
|
-
" \u4EBA"
|
|
228
|
-
] }),
|
|
229
|
-
/* @__PURE__ */ jsx2(Box2, { flexGrow: 1 }),
|
|
230
|
-
/* @__PURE__ */ jsxs2(Text2, { color: cdColor, children: [
|
|
231
|
-
"\u23F3 ",
|
|
232
|
-
fmtCd(remaining)
|
|
233
|
-
] })
|
|
234
|
-
] }),
|
|
383
|
+
/* @__PURE__ */ jsx2(Box2, { borderStyle: compact ? void 0 : "round", borderColor: "cyan", paddingX: compact ? 0 : 1, children: /* @__PURE__ */ jsxs2(Box2, { flexGrow: 1, children: [
|
|
384
|
+
/* @__PURE__ */ jsx2(Text2, { color: "cyan", bold: true, children: "ephem" }),
|
|
385
|
+
/* @__PURE__ */ jsx2(Text2, { color: "gray", children: " \xB7 " }),
|
|
386
|
+
/* @__PURE__ */ jsx2(Text2, { bold: true, children: roomCode }),
|
|
235
387
|
/* @__PURE__ */ jsxs2(Text2, { color: "gray", children: [
|
|
236
|
-
"
|
|
237
|
-
|
|
388
|
+
" ",
|
|
389
|
+
members,
|
|
390
|
+
"/",
|
|
391
|
+
maxMembers,
|
|
392
|
+
" \u4EBA"
|
|
393
|
+
] }),
|
|
394
|
+
/* @__PURE__ */ jsx2(Box2, { flexGrow: 1 }),
|
|
395
|
+
/* @__PURE__ */ jsx2(Text2, { color: statusColor, children: statusText }),
|
|
396
|
+
/* @__PURE__ */ jsx2(Text2, { color: "gray", children: " " }),
|
|
397
|
+
/* @__PURE__ */ jsxs2(Text2, { color: cdColor, children: [
|
|
398
|
+
"\u23F3 ",
|
|
399
|
+
fmtCd(remaining)
|
|
238
400
|
] })
|
|
401
|
+
] }) }),
|
|
402
|
+
/* @__PURE__ */ jsxs2(Text2, { color: "gray", children: [
|
|
403
|
+
"Enter \u53D1\u9001 \xB7 /image <\u8DEF\u5F84> \u53D1\u56FE \xB7 Ctrl+L \u6E05\u5C4F \xB7 Ctrl+C \u9000\u51FA",
|
|
404
|
+
closing ? ` \xB7 ${closing}` : ""
|
|
239
405
|
] }),
|
|
240
|
-
/* @__PURE__ */ jsx2(
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
406
|
+
/* @__PURE__ */ jsx2(
|
|
407
|
+
Box2,
|
|
408
|
+
{
|
|
409
|
+
flexDirection: "column",
|
|
410
|
+
flexGrow: 1,
|
|
411
|
+
marginTop: 1,
|
|
412
|
+
borderStyle: compact ? void 0 : "single",
|
|
413
|
+
borderColor: "gray",
|
|
414
|
+
paddingX: compact ? 0 : 1,
|
|
415
|
+
children: lines.slice(-visible).map(
|
|
416
|
+
(l) => l.kind === "system" ? /* @__PURE__ */ jsxs2(Text2, { color: systemColor(l.level), children: [
|
|
417
|
+
"\xB7 ",
|
|
418
|
+
l.text
|
|
419
|
+
] }, l.id) : l.kind === "image" ? /* @__PURE__ */ jsx2(MessageRow, { time: l.time, from: l.from, self: l.self, text: l.summary, image: true }, l.id) : /* @__PURE__ */ jsx2(MessageRow, { time: l.time, from: l.from, self: l.self, text: l.text }, l.id)
|
|
420
|
+
)
|
|
421
|
+
}
|
|
422
|
+
),
|
|
423
|
+
/* @__PURE__ */ jsxs2(Box2, { marginTop: 1, borderStyle: compact ? void 0 : "single", borderColor: "cyan", paddingX: compact ? 0 : 1, children: [
|
|
424
|
+
/* @__PURE__ */ jsx2(Text2, { color: "cyan", children: "\u203A " }),
|
|
258
425
|
/* @__PURE__ */ jsx2(
|
|
259
426
|
TextInput2,
|
|
260
427
|
{
|
|
261
428
|
value: input,
|
|
262
429
|
onChange: setInput,
|
|
263
|
-
onSubmit:
|
|
430
|
+
onSubmit: (value) => {
|
|
431
|
+
void handleSend(value);
|
|
432
|
+
},
|
|
264
433
|
placeholder: closing ? "\u623F\u95F4\u5373\u5C06\u5173\u95ED\u2026" : "\u8F93\u5165\u6D88\u606F\u2026"
|
|
265
434
|
}
|
|
266
435
|
)
|
|
267
436
|
] })
|
|
268
437
|
] });
|
|
269
438
|
}
|
|
439
|
+
function MessageRow({
|
|
440
|
+
time,
|
|
441
|
+
from,
|
|
442
|
+
text,
|
|
443
|
+
self,
|
|
444
|
+
image = false
|
|
445
|
+
}) {
|
|
446
|
+
const nameColor = self ? "cyan" : "white";
|
|
447
|
+
return /* @__PURE__ */ jsxs2(Box2, { children: [
|
|
448
|
+
/* @__PURE__ */ jsxs2(Text2, { color: "gray", children: [
|
|
449
|
+
time,
|
|
450
|
+
" "
|
|
451
|
+
] }),
|
|
452
|
+
/* @__PURE__ */ jsx2(Text2, { color: nameColor, bold: true, children: from }),
|
|
453
|
+
/* @__PURE__ */ jsx2(Text2, { color: "gray", children: " \u2502 " }),
|
|
454
|
+
/* @__PURE__ */ jsx2(Text2, { color: image ? "magenta" : nameColor, children: text })
|
|
455
|
+
] });
|
|
456
|
+
}
|
|
457
|
+
function systemColor(level) {
|
|
458
|
+
if (level === "error") return "red";
|
|
459
|
+
if (level === "warn") return "yellow";
|
|
460
|
+
return "gray";
|
|
461
|
+
}
|
|
270
462
|
function nowStr() {
|
|
271
463
|
return (/* @__PURE__ */ new Date()).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
|
|
272
464
|
}
|
|
@@ -495,7 +687,7 @@ function ErrorScreen({
|
|
|
495
687
|
onRetry,
|
|
496
688
|
onExit
|
|
497
689
|
}) {
|
|
498
|
-
|
|
690
|
+
useInput2((input, key) => {
|
|
499
691
|
if (key.return) onRetry();
|
|
500
692
|
});
|
|
501
693
|
return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", gap: 1, children: [
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts","../src/ui/App.tsx","../src/ui/SetupWizard.tsx","../src/ui/ChatRoom.tsx","../src/crypto/deriveKey.ts","../src/crypto/cipher.ts","../src/ws/client.ts"],"sourcesContent":["// ephem-cli 入口:解析命令行参数,未提供的走交互式问答。\n\nimport React from \"react\";\nimport { render } from \"ink\";\nimport { Command } from \"commander\";\nimport { App } from \"./ui/App.js\";\n\nconst program = new Command();\n\nprogram\n .name(\"ephem\")\n .description(\"临时、端到端加密的命令行聊天室\")\n .option(\"-s, --server <url>\", \"后端地址(也可用 EPHEM_SERVER 环境变量)\")\n .option(\"-r, --room <code>\", \"房间码,例如 correct-horse-battery\")\n .option(\"-u, --username <name>\", \"用户名\")\n .helpOption(\"-h, --help\", \"查看帮助\")\n .action((opts) => {\n const defaults = {\n server: opts.server ?? process.env.EPHEM_SERVER,\n room: opts.room,\n username: opts.username,\n };\n\n // 安全提醒:命令行参数传房间码会被记录到 shell history,优先用交互式输入。\n if (opts.room) {\n process.stderr.write(\n \"⚠ 提示:通过 --room 传入的房间码可能被记录到 shell 历史,建议优先交互式输入。\\n\",\n );\n }\n\n const instance = render(React.createElement(App, { defaults }));\n instance.waitUntilExit()\n .then(() => process.exit(0))\n .catch(() => process.exit(1));\n });\n\nprogram.parse(process.argv);\n","import React, { useCallback, useEffect, useRef, useState } from \"react\";\nimport { Box, Text, useApp, useInput } from \"ink\";\nimport { SetupWizard, type ConnectConfig } from \"./SetupWizard.js\";\nimport { ChatRoom } from \"./ChatRoom.js\";\nimport { RoomClient, type JoinedInfo } from \"../ws/client.js\";\n\ninterface Props {\n defaults: { server?: string; room?: string; username?: string };\n}\n\ntype Phase = \"setup\" | \"connecting\" | \"chat\" | \"error\";\n\nexport function App({ defaults }: Props) {\n const { exit } = useApp();\n const skipSetup = Boolean(defaults.server && defaults.room && defaults.username);\n const [phase, setPhase] = useState<Phase>(skipSetup ? \"connecting\" : \"setup\");\n const [client, setClient] = useState<RoomClient | null>(null);\n const [joined, setJoined] = useState<JoinedInfo | null>(null);\n const [error, setError] = useState<{ code: string; message: string } | null>(null);\n const cfgRef = useRef<ConnectConfig | null>(\n skipSetup\n ? { server: defaults.server!, room: defaults.room!, username: defaults.username! }\n : null,\n );\n\n const connect = useCallback((config: ConnectConfig) => {\n cfgRef.current = config;\n const c = new RoomClient(config.server, config.room, config.username);\n setClient(c);\n setPhase(\"connecting\");\n setError(null);\n setJoined(null);\n\n c.on(\"joined\", (info: JoinedInfo) => {\n setJoined(info);\n setPhase(\"chat\");\n });\n c.on(\"server_error\", (info: { code: string; message: string }) => {\n setError(info);\n setPhase(\"error\");\n });\n // 连接彻底关闭时,若仍处于 connecting 则视为失败\n c.on(\"closed\", () => {\n setPhase((p) => (p === \"connecting\" ? \"error\" : p));\n });\n c.connect();\n }, []);\n\n // 命令行参数齐全时直接连接\n useEffect(() => {\n if (skipSetup && cfgRef.current) connect(cfgRef.current);\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n // 退出清理\n useEffect(() => () => client?.close(), [client]);\n\n const handleRetry = useCallback(() => {\n client?.close();\n setClient(null);\n setJoined(null);\n setError(null);\n setPhase(\"setup\");\n }, [client]);\n\n if (phase === \"setup\") {\n return <SetupWizard defaults={defaults} onComplete={connect} />;\n }\n\n if (phase === \"connecting\") {\n return (\n <Box flexDirection=\"column\" gap={1}>\n <Text color=\"cyan\">正在连接…</Text>\n <Text color=\"gray\">服务器:{cfgRef.current?.server}</Text>\n <Text color=\"gray\">房间:{cfgRef.current?.room}</Text>\n </Box>\n );\n }\n\n if (phase === \"error\") {\n return (\n <ErrorScreen\n message={error?.message ?? \"未知错误\"}\n code={error?.code ?? \"unknown\"}\n onRetry={handleRetry}\n onExit={() => exit()}\n />\n );\n }\n\n // chat\n if (!client || !joined || !cfgRef.current) return null;\n return (\n <ChatRoom\n client={client}\n roomCode={cfgRef.current.room}\n username={cfgRef.current.username}\n joined={joined}\n onExit={() => exit()}\n />\n );\n}\n\nfunction ErrorScreen({\n message,\n code,\n onRetry,\n onExit,\n}: {\n message: string;\n code: string;\n onRetry: () => void;\n onExit: () => void;\n}) {\n useInput((input, key) => {\n if (key.return) onRetry();\n });\n return (\n <Box flexDirection=\"column\" gap={1}>\n <Text color=\"red\" bold>\n 连接失败\n </Text>\n <Text color=\"gray\">\n {message}({code})\n </Text>\n <Text color=\"gray\">按回车返回设置重试,Ctrl+C 退出</Text>\n </Box>\n );\n}\n","import React, { useState } from \"react\";\nimport { Box, Text } from \"ink\";\nimport TextInput from \"ink-text-input\";\n\nexport interface ConnectConfig {\n server: string;\n room: string;\n username: string;\n}\n\ninterface Props {\n defaults: { server?: string; room?: string; username?: string };\n onComplete: (cfg: ConnectConfig) => void;\n}\n\nconst STEPS = [\"后端地址\", \"房间码\", \"用户名\"] as const;\n\n/** 三步问答:后端地址 → 房间码 → 用户名。完成后回调 onComplete。 */\nexport function SetupWizard({ defaults, onComplete }: Props) {\n const [step, setStep] = useState(0);\n const [server, setServer] = useState(defaults.server ?? \"\");\n const [room, setRoom] = useState((defaults.room ?? \"\").toLowerCase());\n const [username, setUsername] = useState(defaults.username ?? \"\");\n\n const values = [server, room, username];\n const setters = [setServer, setRoom, setUsername];\n\n function submit(value: string) {\n const v = value.trim();\n setters[step](v);\n if (step === 0 && !v) {\n // 后端地址为空时拒绝(除非有默认值)\n return;\n }\n if (step < 2) {\n setStep(step + 1);\n } else {\n onComplete({\n server: (server || \"\").trim(),\n room: (room || \"\").trim().toLowerCase(),\n username: (v || \"匿名\").slice(0, 32),\n });\n }\n };\n\n return (\n <Box flexDirection=\"column\" gap={1}>\n <Box flexDirection=\"column\">\n <Text color=\"cyan\" bold>\n ephem · 临时加密聊天室\n </Text>\n <Text color=\"gray\">按回车进入下一步,Ctrl+C 退出</Text>\n </Box>\n\n {STEPS.map((label, i) => {\n const done = i < step;\n const active = i === step;\n return (\n <Box key={label} flexDirection=\"column\">\n <Text color={active ? \"cyan\" : \"gray\"}>\n {done ? \"✓\" : active ? \"?\" : \"·\"} {label}\n {i === 0 && defaults.server ? \"(回车使用默认值)\" : \"\"}\n </Text>\n {active ? (\n <Box>\n <Text color=\"gray\"> › </Text>\n <TextInput\n value={values[i]}\n onChange={(v) => setters[i](v)}\n onSubmit={submit}\n placeholder={i === 0 ? \"wss://your-worker.workers.dev\" : i === 1 ? \"correct-horse-battery\" : \"你的名字\"}\n />\n </Box>\n ) : done ? (\n <Text color=\"gray\"> {values[i] || \"(空)\"}</Text>\n ) : null}\n </Box>\n );\n })}\n </Box>\n );\n}\n","import React, { useEffect, useReducer, useRef, useState } from \"react\";\nimport { Box, Text, useApp, useStdout } from \"ink\";\nimport TextInput from \"ink-text-input\";\nimport type { RoomClient, JoinedInfo, ChatMessage } from \"../ws/client.js\";\nimport { deriveRoomKey } from \"../crypto/deriveKey.js\";\nimport { encrypt, decrypt } from \"../crypto/cipher.js\";\n\ninterface Props {\n client: RoomClient;\n roomCode: string;\n username: string;\n joined: JoinedInfo;\n onExit: () => void;\n}\n\ntype Line =\n | { id: number; kind: \"system\"; text: string }\n | { id: number; kind: \"msg\"; from: string; text: string; self: boolean; time: string };\n\nlet lineId = 0;\n\nexport function ChatRoom({ client, roomCode, username, joined, onExit }: Props) {\n const { exit } = useApp();\n const { stdout } = useStdout();\n const roomKey = useRef(deriveRoomKey(roomCode));\n\n const [lines, dispatch] = useReducer(\n (state: Line[], action: { type: \"add\"; line: Line } | { type: \"clear\" }) => {\n if (action.type === \"clear\") return [];\n return [...state, action.line].slice(-500);\n },\n [],\n );\n const [input, setInput] = useState(\"\");\n const [members, setMembers] = useState(joined.currentMembers);\n const [maxMembers] = useState(joined.maxMembers);\n const [expiresAt] = useState(joined.expiresAt);\n const [remaining, setRemaining] = useState(() => Math.max(0, Math.floor((joined.expiresAt - Date.now()) / 1000)));\n const [closing, setClosing] = useState<string | null>(null);\n\n const addSystem = (text: string) =>\n dispatch({ type: \"add\", line: { id: ++lineId, kind: \"system\", text } });\n const addMsg = (from: string, text: string, self: boolean) =>\n dispatch({\n type: \"add\",\n line: { id: ++lineId, kind: \"msg\", from, text, self, time: nowStr() },\n });\n\n // 订阅客户端事件\n useEffect(() => {\n addSystem(`已加入房间 ${roomCode}(${joined.currentMembers}/${joined.maxMembers} 人)`);\n\n const onPeerJoined = ({ username: u }: { username: string }) => {\n setMembers((m) => m + 1);\n addSystem(`${u} 加入了房间`);\n };\n const onPeerLeft = ({ username: u }: { username: string }) => {\n setMembers((m) => Math.max(0, m - 1));\n addSystem(`${u} 离开了房间`);\n };\n const onMessage = (msg: ChatMessage) => {\n try {\n const text = decrypt(roomKey.current, { ciphertext: msg.ciphertext, nonce: msg.nonce });\n addMsg(msg.from, text, false);\n } catch {\n addSystem(`收到来自 ${msg.from} 的无法解密的消息`);\n }\n };\n const onRoomClosing = ({ reason }: { reason: string }) => {\n const reasonText =\n reason === \"ttl_expired\" ? \"房间已到期\" : reason === \"empty\" ? \"房间已空\" : \"房间被手动销毁\";\n setClosing(reasonText);\n addSystem(`房间即将关闭:${reasonText}`);\n setTimeout(() => {\n client.close();\n onExit();\n exit();\n }, 1500);\n };\n const onServerError = (info: { code: string; message: string }) => {\n addSystem(`错误:${info.message} (${info.code})`);\n };\n\n client.on(\"peer_joined\", onPeerJoined);\n client.on(\"peer_left\", onPeerLeft);\n client.on(\"message\", onMessage);\n client.on(\"room_closing\", onRoomClosing);\n client.on(\"server_error\", onServerError);\n\n return () => {\n client.off(\"peer_joined\", onPeerJoined);\n client.off(\"peer_left\", onPeerLeft);\n client.off(\"message\", onMessage);\n client.off(\"room_closing\", onRoomClosing);\n client.off(\"server_error\", onServerError);\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [client]);\n\n // 倒计时\n useEffect(() => {\n const t = setInterval(() => {\n const r = Math.max(0, Math.floor((expiresAt - Date.now()) / 1000));\n setRemaining(r);\n }, 1000);\n return () => clearInterval(t);\n }, [expiresAt]);\n\n // 退出时关闭连接\n useEffect(() => () => client.close(), [client]);\n\n function handleSend(text: string) {\n const t = text.trim();\n if (!t) return;\n try {\n const { ciphertext, nonce } = encrypt(roomKey.current, t);\n client.send(ciphertext, nonce);\n addMsg(username, t, true);\n } catch {\n addSystem(\"发送失败:加密出错\");\n }\n setInput(\"\");\n }\n\n // 可视区域:留出 header(2) + 输入区(3) 的空间\n const rows = stdout?.rows ?? 24;\n const visible = Math.max(4, rows - 6);\n\n const cdColor = remaining < 60 ? \"red\" : remaining < 300 ? \"yellow\" : \"gray\";\n\n return (\n <Box flexDirection=\"column\" height={rows}>\n {/* Header */}\n <Box flexDirection=\"column\">\n <Box>\n <Text color=\"cyan\" bold>\n ephem\n </Text>\n <Text color=\"gray\"> · </Text>\n <Text bold>{roomCode}</Text>\n <Text color=\"gray\">\n {\" \"}\n {members}/{maxMembers} 人\n </Text>\n <Box flexGrow={1} />\n <Text color={cdColor}>⏳ {fmtCd(remaining)}</Text>\n </Box>\n <Text color=\"gray\">输入消息回车发送 · Ctrl+C 退出{closing ? ` · ${closing}` : \"\"}</Text>\n </Box>\n\n {/* 消息列表 */}\n <Box flexDirection=\"column\" flexGrow={1} marginTop={1}>\n {lines.slice(-visible).map((l) =>\n l.kind === \"system\" ? (\n <Text key={l.id} color=\"yellow\">\n {\" \"}\n {l.text}\n </Text>\n ) : (\n <Box key={l.id}>\n <Text color=\"gray\">{l.time} </Text>\n <Text color={l.self ? \"cyan\" : \"white\"} bold>\n {l.from}\n </Text>\n <Text color={l.self ? \"cyan\" : \"white\"}>: {l.text}</Text>\n </Box>\n ),\n )}\n </Box>\n\n {/* 输入栏 */}\n <Box marginTop={1}>\n <Text color=\"cyan\">{\"> \"}</Text>\n <TextInput\n value={input}\n onChange={setInput}\n onSubmit={handleSend}\n placeholder={closing ? \"房间即将关闭…\" : \"输入消息…\"}\n />\n </Box>\n </Box>\n );\n}\n\nfunction nowStr(): string {\n return new Date().toLocaleTimeString(\"zh-CN\", { hour: \"2-digit\", minute: \"2-digit\" });\n}\n\nfunction fmtCd(sec: number): string {\n const h = Math.floor(sec / 3600);\n const m = Math.floor((sec % 3600) / 60);\n const s = sec % 60;\n return `${String(h).padStart(2, \"0\")}:${String(m).padStart(2, \"0\")}:${String(s).padStart(2, \"0\")}`;\n}\n","// 从房间码派生对称密钥:HKDF(SHA-256) → 32 字节 AES-256-GCM 密钥。\n// 全程在客户端本地完成,房间码本身不因此通过网络发给后端。\n//\n// 设计说明:这是\"共享密码派生密钥\"模式(PAKE 的简化版)。房间码同时承担\n// 路由标识和密钥种子双重职责。攻击者要验证猜测必须先连上对应房间码的 WS\n// 端点,而后端对单房间码连接尝试做了限流。\n\nimport { hkdfSync } from \"node:crypto\";\n\nconst SALT = \"ephem-v1-room-salt\";\nconst INFO = \"ephem-room-encryption-key\";\nconst KEY_LEN = 32; // AES-256\n\n/** 从房间码派生房间加密密钥(32 字节)。 */\nexport function deriveRoomKey(roomCode: string): Buffer {\n const ikm = Buffer.from(roomCode, \"utf8\");\n const salt = Buffer.from(SALT, \"utf8\");\n const info = Buffer.from(INFO, \"utf8\");\n // hkdfSync 在 Node 18+ 返回 Buffer\n return Buffer.from(hkdfSync(\"sha256\", ikm, salt, info, KEY_LEN));\n}\n","// AES-256-GCM 加解密封装。\n// 约定:每条消息用独立随机 12 字节 nonce;认证标签 (authTag, 16 字节) 拼在密文末尾。\n// 密文与 nonce 都用 base64 编码传输(JSON 友好)。\n// 后端只原样转发 { ciphertext, nonce },不解密、不校验。\n\nimport { createCipheriv, createDecipheriv, randomBytes } from \"node:crypto\";\n\nconst ALGO = \"aes-256-gcm\";\nconst NONCE_LEN = 12;\nconst TAG_LEN = 16;\n\nexport interface EncryptedPayload {\n ciphertext: string; // base64(密文 + authTag)\n nonce: string; // base64(12 字节 nonce)\n}\n\n/** 加密一条文本消息。 */\nexport function encrypt(key: Buffer, plaintext: string): EncryptedPayload {\n const nonce = randomBytes(NONCE_LEN);\n const cipher = createCipheriv(ALGO, key, nonce);\n const enc = Buffer.concat([cipher.update(plaintext, \"utf8\"), cipher.final()]);\n const tag = cipher.getAuthTag();\n const combined = Buffer.concat([enc, tag]); // authTag 拼在末尾\n return {\n ciphertext: combined.toString(\"base64\"),\n nonce: nonce.toString(\"base64\"),\n };\n}\n\n/** 解密一条消息。认证失败会抛错(说明密钥不对或密文被篡改)。 */\nexport function decrypt(key: Buffer, payload: EncryptedPayload): string {\n const combined = Buffer.from(payload.ciphertext, \"base64\");\n const nonce = Buffer.from(payload.nonce, \"base64\");\n if (combined.length < TAG_LEN + 1) {\n throw new Error(\"密文长度异常\");\n }\n const tag = combined.subarray(combined.length - TAG_LEN);\n const enc = combined.subarray(0, combined.length - TAG_LEN);\n const decipher = createDecipheriv(ALGO, key, nonce);\n decipher.setAuthTag(tag);\n const dec = Buffer.concat([decipher.update(enc), decipher.final()]);\n return dec.toString(\"utf8\");\n}\n","// 房间 WebSocket 客户端:连接、收发密文、心跳保活、断线指数退避重连。\n// 服务端主动拒绝(房间不存在/已满/过期)时不重连,交由 UI 处理。\n\nimport { EventEmitter } from \"node:events\";\nimport WebSocket from \"ws\";\n\nexport interface JoinedInfo {\n username: string;\n currentMembers: number;\n maxMembers: number;\n expiresAt: number;\n}\n\nexport interface ChatMessage {\n from: string;\n ciphertext: string;\n nonce: string;\n timestamp: number;\n}\n\nexport type CloseReason = \"ttl_expired\" | \"empty\" | \"manual\";\n\ninterface ReconnectingInfo {\n attempt: number;\n delayMs: number;\n}\n\n/**\n * 事件(全部通过 on 订阅):\n * joined(info) 加入成功\n * peer_joined({username})\n * peer_left({username})\n * message(msg) 收到一条密文消息\n * room_closing({reason})房间即将销毁\n * server_error({code,message}) 服务端拒绝/出错(不可恢复)\n * reconnecting(info) 断线后准备第 N 次重连\n * closed() 连接彻底关闭\n */\nexport class RoomClient extends EventEmitter {\n private ws: WebSocket | null = null;\n private reconnectAttempt = 0;\n private manuallyClosed = false;\n private rejectedByServer = false;\n private pingTimer: NodeJS.Timeout | null = null;\n private reconnectTimer: NodeJS.Timeout | null = null;\n\n constructor(\n private readonly server: string,\n private readonly roomCode: string,\n private readonly username: string,\n ) {\n super();\n }\n\n connect(): void {\n this.manuallyClosed = false;\n this.rejectedByServer = false;\n this.openSocket();\n }\n\n private openSocket(): void {\n const url = `${normalizeWs(this.server)}/room/${encodeURIComponent(this.roomCode)}?username=${encodeURIComponent(this.username)}`;\n const ws = new WebSocket(url);\n this.ws = ws;\n\n ws.on(\"open\", () => {\n this.reconnectAttempt = 0;\n this.startPing();\n // 真正\"加入成功\"由服务端 joined 消息确认;这里只表示链路通了\n });\n\n ws.on(\"message\", (raw: Buffer | string) => {\n let msg: { type?: string; payload?: unknown };\n try {\n msg = JSON.parse(raw.toString());\n } catch {\n return;\n }\n this.dispatch(msg);\n });\n\n // 服务端返回非 101 响应(房间不存在/已满/过期/限流)\n ws.on(\"unexpected-response\", (_req, res) => {\n let body = \"\";\n res.on(\"data\", (c: Buffer) => (body += c.toString()));\n res.on(\"end\", () => {\n let info = { code: `http_${res.statusCode}`, message: \"连接被服务端拒绝\" };\n try {\n const j = JSON.parse(body);\n if (j.error) info = { code: String(j.error), message: String(j.message ?? j.error) };\n } catch {\n /* keep default */\n }\n this.rejectedByServer = true;\n this.emit(\"server_error\", info);\n });\n });\n\n ws.on(\"close\", () => {\n this.stopPing();\n if (this.manuallyClosed || this.rejectedByServer) {\n this.emit(\"closed\");\n return;\n }\n this.scheduleReconnect();\n });\n\n ws.on(\"error\", () => {\n // 网络层错误;后续 close 会触发重连流程,这里不单独抛出\n });\n }\n\n private dispatch(msg: { type?: string; payload?: any }) {\n switch (msg.type) {\n case \"joined\":\n this.emit(\"joined\", msg.payload as JoinedInfo);\n break;\n case \"peer_joined\":\n this.emit(\"peer_joined\", msg.payload);\n break;\n case \"peer_left\":\n this.emit(\"peer_left\", msg.payload);\n break;\n case \"message\":\n this.emit(\"message\", msg.payload as ChatMessage);\n break;\n case \"room_closing\":\n this.manuallyClosed = true; // 房间销毁是终态\n this.emit(\"room_closing\", msg.payload as { reason: CloseReason });\n break;\n case \"error\":\n this.emit(\"server_error\", msg.payload);\n break;\n default:\n break;\n }\n }\n\n /** 发送一条已加密的消息(密文 + nonce)。 */\n send(ciphertext: string, nonce: string): void {\n if (this.ws?.readyState === WebSocket.OPEN) {\n this.ws.send(JSON.stringify({ type: \"message\", payload: { ciphertext, nonce } }));\n }\n }\n\n close(): void {\n this.manuallyClosed = true;\n this.stopPing();\n if (this.reconnectTimer) clearTimeout(this.reconnectTimer);\n this.ws?.close();\n }\n\n private startPing(): void {\n this.stopPing();\n this.pingTimer = setInterval(() => {\n if (this.ws?.readyState === WebSocket.OPEN) {\n this.ws.send(JSON.stringify({ type: \"ping\" }));\n }\n }, 25_000);\n }\n\n private stopPing(): void {\n if (this.pingTimer) clearInterval(this.pingTimer);\n this.pingTimer = null;\n }\n\n private scheduleReconnect(): void {\n this.reconnectAttempt += 1;\n const delayMs = Math.min(1000 * 2 ** (this.reconnectAttempt - 1), 30_000);\n this.emit(\"reconnecting\", { attempt: this.reconnectAttempt, delayMs });\n this.reconnectTimer = setTimeout(() => this.openSocket(), delayMs);\n }\n}\n\n/** 把任意形式的地址规范化成 ws/wss 基础 URL(去尾部斜杠)。 */\nfunction normalizeWs(server: string): string {\n let s = server.trim().replace(/\\/+$/, \"\");\n if (s.startsWith(\"https://\")) s = \"wss://\" + s.slice(\"https://\".length);\n else if (s.startsWith(\"http://\")) s = \"ws://\" + s.slice(\"http://\".length);\n else if (!s.startsWith(\"ws://\") && !s.startsWith(\"wss://\")) s = \"wss://\" + s;\n return s;\n}\n"],"mappings":"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","names":["React","useEffect","useRef","useState","Box","Text","useApp","useState","Box","Text","TextInput","jsx","jsxs","useState","Box","Text","TextInput","jsx","jsxs","useApp","useState","useRef","useEffect","Box","Text","React"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/ui/App.tsx","../src/ui/SetupWizard.tsx","../src/ui/ChatRoom.tsx","../src/crypto/deriveKey.ts","../src/crypto/cipher.ts","../src/protocol/message.ts","../src/ws/client.ts"],"sourcesContent":["// ephem-cli 入口:解析命令行参数,未提供的走交互式问答。\n\nimport React from \"react\";\nimport { render } from \"ink\";\nimport { Command } from \"commander\";\nimport { App } from \"./ui/App.js\";\n\nconst program = new Command();\n\nprogram\n .name(\"ephem\")\n .description(\"临时、端到端加密的命令行聊天室\")\n .option(\"-s, --server <url>\", \"后端地址(也可用 EPHEM_SERVER 环境变量)\")\n .option(\"-r, --room <code>\", \"房间码,例如 correct-horse-battery\")\n .option(\"-u, --username <name>\", \"用户名\")\n .helpOption(\"-h, --help\", \"查看帮助\")\n .action((opts) => {\n const defaults = {\n server: opts.server ?? process.env.EPHEM_SERVER,\n room: opts.room,\n username: opts.username,\n };\n\n // 安全提醒:命令行参数传房间码会被记录到 shell history,优先用交互式输入。\n if (opts.room) {\n process.stderr.write(\n \"⚠ 提示:通过 --room 传入的房间码可能被记录到 shell 历史,建议优先交互式输入。\\n\",\n );\n }\n\n const instance = render(React.createElement(App, { defaults }));\n instance.waitUntilExit()\n .then(() => process.exit(0))\n .catch(() => process.exit(1));\n });\n\nprogram.parse(process.argv);\n","import React, { useCallback, useEffect, useRef, useState } from \"react\";\nimport { Box, Text, useApp, useInput } from \"ink\";\nimport { SetupWizard, type ConnectConfig } from \"./SetupWizard.js\";\nimport { ChatRoom } from \"./ChatRoom.js\";\nimport { RoomClient, type JoinedInfo } from \"../ws/client.js\";\n\ninterface Props {\n defaults: { server?: string; room?: string; username?: string };\n}\n\ntype Phase = \"setup\" | \"connecting\" | \"chat\" | \"error\";\n\nexport function App({ defaults }: Props) {\n const { exit } = useApp();\n const skipSetup = Boolean(defaults.server && defaults.room && defaults.username);\n const [phase, setPhase] = useState<Phase>(skipSetup ? \"connecting\" : \"setup\");\n const [client, setClient] = useState<RoomClient | null>(null);\n const [joined, setJoined] = useState<JoinedInfo | null>(null);\n const [error, setError] = useState<{ code: string; message: string } | null>(null);\n const cfgRef = useRef<ConnectConfig | null>(\n skipSetup\n ? { server: defaults.server!, room: defaults.room!, username: defaults.username! }\n : null,\n );\n\n const connect = useCallback((config: ConnectConfig) => {\n cfgRef.current = config;\n const c = new RoomClient(config.server, config.room, config.username);\n setClient(c);\n setPhase(\"connecting\");\n setError(null);\n setJoined(null);\n\n c.on(\"joined\", (info: JoinedInfo) => {\n setJoined(info);\n setPhase(\"chat\");\n });\n c.on(\"server_error\", (info: { code: string; message: string }) => {\n setError(info);\n setPhase(\"error\");\n });\n // 连接彻底关闭时,若仍处于 connecting 则视为失败\n c.on(\"closed\", () => {\n setPhase((p) => (p === \"connecting\" ? \"error\" : p));\n });\n c.connect();\n }, []);\n\n // 命令行参数齐全时直接连接\n useEffect(() => {\n if (skipSetup && cfgRef.current) connect(cfgRef.current);\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n // 退出清理\n useEffect(() => () => client?.close(), [client]);\n\n const handleRetry = useCallback(() => {\n client?.close();\n setClient(null);\n setJoined(null);\n setError(null);\n setPhase(\"setup\");\n }, [client]);\n\n if (phase === \"setup\") {\n return <SetupWizard defaults={defaults} onComplete={connect} />;\n }\n\n if (phase === \"connecting\") {\n return (\n <Box flexDirection=\"column\" gap={1}>\n <Text color=\"cyan\">正在连接…</Text>\n <Text color=\"gray\">服务器:{cfgRef.current?.server}</Text>\n <Text color=\"gray\">房间:{cfgRef.current?.room}</Text>\n </Box>\n );\n }\n\n if (phase === \"error\") {\n return (\n <ErrorScreen\n message={error?.message ?? \"未知错误\"}\n code={error?.code ?? \"unknown\"}\n onRetry={handleRetry}\n onExit={() => exit()}\n />\n );\n }\n\n // chat\n if (!client || !joined || !cfgRef.current) return null;\n return (\n <ChatRoom\n client={client}\n roomCode={cfgRef.current.room}\n username={cfgRef.current.username}\n joined={joined}\n onExit={() => exit()}\n />\n );\n}\n\nfunction ErrorScreen({\n message,\n code,\n onRetry,\n onExit,\n}: {\n message: string;\n code: string;\n onRetry: () => void;\n onExit: () => void;\n}) {\n useInput((input, key) => {\n if (key.return) onRetry();\n });\n return (\n <Box flexDirection=\"column\" gap={1}>\n <Text color=\"red\" bold>\n 连接失败\n </Text>\n <Text color=\"gray\">\n {message}({code})\n </Text>\n <Text color=\"gray\">按回车返回设置重试,Ctrl+C 退出</Text>\n </Box>\n );\n}\n","import React, { useState } from \"react\";\nimport { Box, Text } from \"ink\";\nimport TextInput from \"ink-text-input\";\n\nexport interface ConnectConfig {\n server: string;\n room: string;\n username: string;\n}\n\ninterface Props {\n defaults: { server?: string; room?: string; username?: string };\n onComplete: (cfg: ConnectConfig) => void;\n}\n\nconst STEPS = [\"后端地址\", \"房间码\", \"用户名\"] as const;\n\n/** 三步问答:后端地址 → 房间码 → 用户名。完成后回调 onComplete。 */\nexport function SetupWizard({ defaults, onComplete }: Props) {\n const [step, setStep] = useState(0);\n const [server, setServer] = useState(defaults.server ?? \"\");\n const [room, setRoom] = useState((defaults.room ?? \"\").toLowerCase());\n const [username, setUsername] = useState(defaults.username ?? \"\");\n const [error, setError] = useState<string | null>(null);\n\n const values = [server, room, username];\n const setters = [setServer, setRoom, setUsername];\n\n function submit(value: string) {\n const v = value.trim();\n setters[step](v);\n setError(null);\n if (step === 0 && !v) {\n setError(\"请输入后端地址,例如 wss://your-worker.workers.dev\");\n return;\n }\n if (step === 1 && !/^[a-z]+-[a-z]+-[a-z]+$/.test(v.toLowerCase())) {\n setError(\"房间码格式应为三段英文单词,例如 correct-horse-battery\");\n return;\n }\n if (step < 2) {\n setStep(step + 1);\n } else {\n onComplete({\n server: (server || \"\").trim(),\n room: (room || \"\").trim().toLowerCase(),\n username: (v || \"匿名\").slice(0, 32),\n });\n }\n };\n\n return (\n <Box flexDirection=\"column\" gap={1} borderStyle=\"round\" borderColor=\"cyan\" paddingX={1} paddingY={1}>\n <Box flexDirection=\"column\">\n <Text>\n <Text color=\"cyan\" bold>\n ephem\n </Text>\n <Text color=\"gray\"> · 临时加密聊天室</Text>\n </Text>\n <Text color=\"gray\">按回车进入下一步,Ctrl+C 退出。房间码和密钥不会落盘。</Text>\n </Box>\n\n {STEPS.map((label, i) => {\n const done = i < step;\n const active = i === step;\n return (\n <Box key={label} flexDirection=\"column\" marginTop={i === 0 ? 1 : 0}>\n <Text color={active ? \"cyan\" : done ? \"green\" : \"gray\"}>\n {done ? \"✓\" : active ? \"›\" : \"·\"} {label}\n {i === 0 && defaults.server ? \"(回车使用默认值)\" : \"\"}\n </Text>\n {active ? (\n <Box>\n <Text color=\"gray\"> › </Text>\n <TextInput\n value={values[i]}\n onChange={(v) => setters[i](v)}\n onSubmit={submit}\n placeholder={i === 0 ? \"wss://your-worker.workers.dev\" : i === 1 ? \"correct-horse-battery\" : \"你的名字\"}\n />\n </Box>\n ) : done ? (\n <Text color=\"gray\"> {i === 2 && !values[i] ? \"匿名\" : values[i] || \"(空)\"}</Text>\n ) : null}\n </Box>\n );\n })}\n\n {error ? (\n <Box marginTop={1}>\n <Text color=\"red\">错误:{error}</Text>\n </Box>\n ) : (\n <Box marginTop={1}>\n <Text color=\"gray\">提示:进入聊天后可用 /image <路径> 发送小于 1 MiB 的图片。</Text>\n </Box>\n )}\n </Box>\n );\n}\n","import React, { useEffect, useReducer, useRef, useState } from \"react\";\nimport { readFile, stat } from \"node:fs/promises\";\nimport { Box, Text, useApp, useInput, useStdout } from \"ink\";\nimport TextInput from \"ink-text-input\";\nimport type { RoomClient, JoinedInfo, ChatMessage } from \"../ws/client.js\";\nimport { deriveRoomKey } from \"../crypto/deriveKey.js\";\nimport { encrypt, decrypt } from \"../crypto/cipher.js\";\nimport {\n IMAGE_MAX_BYTES,\n detectImageMime,\n displayFileName,\n encodeImageMessage,\n encodeTextMessage,\n formatBytes,\n imageSummary,\n parseImageCommand,\n parsePlaintextMessage,\n} from \"../protocol/message.js\";\n\ninterface Props {\n client: RoomClient;\n roomCode: string;\n username: string;\n joined: JoinedInfo;\n onExit: () => void;\n}\n\ntype Line =\n | { id: number; kind: \"system\"; text: string; level?: \"info\" | \"warn\" | \"error\" }\n | { id: number; kind: \"text\"; from: string; text: string; self: boolean; time: string }\n | { id: number; kind: \"image\"; from: string; summary: string; self: boolean; time: string };\n\nlet lineId = 0;\n\nexport function ChatRoom({ client, roomCode, username, joined, onExit }: Props) {\n const { exit } = useApp();\n const { stdout } = useStdout();\n const roomKey = useRef(deriveRoomKey(roomCode));\n\n const [lines, dispatch] = useReducer(\n (state: Line[], action: { type: \"add\"; line: Line } | { type: \"clear\" }) => {\n if (action.type === \"clear\") return [];\n return [...state, action.line].slice(-500);\n },\n [],\n );\n const [input, setInput] = useState(\"\");\n const [members, setMembers] = useState(joined.currentMembers);\n const [maxMembers] = useState(joined.maxMembers);\n const [expiresAt] = useState(joined.expiresAt);\n const [remaining, setRemaining] = useState(() => Math.max(0, Math.floor((joined.expiresAt - Date.now()) / 1000)));\n const [closing, setClosing] = useState<string | null>(null);\n const [status, setStatus] = useState<\"online\" | \"reconnecting\" | \"closing\">(\"online\");\n const [statusText, setStatusText] = useState(\"已连接\");\n\n const addSystem = (text: string, level: \"info\" | \"warn\" | \"error\" = \"info\") =>\n dispatch({ type: \"add\", line: { id: ++lineId, kind: \"system\", text, level } });\n const addText = (from: string, text: string, self: boolean) =>\n dispatch({\n type: \"add\",\n line: { id: ++lineId, kind: \"text\", from, text, self, time: nowStr() },\n });\n const addImage = (from: string, summary: string, self: boolean) =>\n dispatch({\n type: \"add\",\n line: { id: ++lineId, kind: \"image\", from, summary, self, time: nowStr() },\n });\n\n // 订阅客户端事件\n useEffect(() => {\n addSystem(`已加入房间 ${roomCode}(${joined.currentMembers}/${joined.maxMembers} 人)`);\n\n const onPeerJoined = ({ username: u }: { username: string }) => {\n setMembers((m) => m + 1);\n addSystem(`${u} 加入了房间`);\n };\n const onPeerLeft = ({ username: u }: { username: string }) => {\n setMembers((m) => Math.max(0, m - 1));\n addSystem(`${u} 离开了房间`);\n };\n const onMessage = (msg: ChatMessage) => {\n try {\n const plaintext = decrypt(roomKey.current, { ciphertext: msg.ciphertext, nonce: msg.nonce });\n const parsed = parsePlaintextMessage(plaintext);\n if (parsed.kind === \"image\") addImage(msg.from, imageSummary(parsed), false);\n else addText(msg.from, parsed.text, false);\n } catch {\n addSystem(`收到来自 ${msg.from} 的无法解密的消息`, \"warn\");\n }\n };\n const onRoomClosing = ({ reason }: { reason: string }) => {\n const reasonText =\n reason === \"ttl_expired\" ? \"房间已到期\" : reason === \"empty\" ? \"房间已空\" : \"房间被手动销毁\";\n setStatus(\"closing\");\n setStatusText(reasonText);\n setClosing(reasonText);\n addSystem(`房间即将关闭:${reasonText}`, \"warn\");\n setTimeout(() => {\n client.close();\n onExit();\n exit();\n }, 1500);\n };\n const onServerError = (info: { code: string; message: string }) => {\n addSystem(`错误:${info.message} (${info.code})`, \"error\");\n };\n const onReconnecting = ({ attempt, delayMs }: { attempt: number; delayMs: number }) => {\n setStatus(\"reconnecting\");\n setStatusText(`重连 #${attempt},${Math.ceil(delayMs / 1000)}s 后`);\n addSystem(`连接断开,准备第 ${attempt} 次重连`, \"warn\");\n };\n const onJoined = () => {\n setStatus(\"online\");\n setStatusText(\"已连接\");\n };\n\n client.on(\"joined\", onJoined);\n client.on(\"peer_joined\", onPeerJoined);\n client.on(\"peer_left\", onPeerLeft);\n client.on(\"message\", onMessage);\n client.on(\"room_closing\", onRoomClosing);\n client.on(\"server_error\", onServerError);\n client.on(\"reconnecting\", onReconnecting);\n\n return () => {\n client.off(\"joined\", onJoined);\n client.off(\"peer_joined\", onPeerJoined);\n client.off(\"peer_left\", onPeerLeft);\n client.off(\"message\", onMessage);\n client.off(\"room_closing\", onRoomClosing);\n client.off(\"server_error\", onServerError);\n client.off(\"reconnecting\", onReconnecting);\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [client]);\n\n // 倒计时\n useEffect(() => {\n const t = setInterval(() => {\n const r = Math.max(0, Math.floor((expiresAt - Date.now()) / 1000));\n setRemaining(r);\n }, 1000);\n return () => clearInterval(t);\n }, [expiresAt]);\n\n // 退出时关闭连接\n useEffect(() => () => client.close(), [client]);\n\n useInput((input, key) => {\n if (key.ctrl && input.toLowerCase() === \"l\") {\n dispatch({ type: \"clear\" });\n addSystem(\"已清屏\");\n }\n });\n\n async function handleSend(text: string) {\n const t = text.trim();\n if (!t) return;\n const imagePath = parseImageCommand(t);\n if (imagePath !== null) {\n await handleImageSend(imagePath);\n setInput(\"\");\n return;\n }\n try {\n const { ciphertext, nonce } = encrypt(roomKey.current, encodeTextMessage(t));\n client.send(ciphertext, nonce);\n addText(username, t, true);\n } catch {\n addSystem(\"发送失败:加密出错\", \"error\");\n }\n setInput(\"\");\n }\n\n async function handleImageSend(filePath: string) {\n if (!filePath) {\n addSystem(\"用法:/image <图片路径>\", \"warn\");\n return;\n }\n try {\n const info = await stat(filePath);\n if (!info.isFile()) {\n addSystem(\"发送失败:路径不是文件\", \"error\");\n return;\n }\n if (info.size > IMAGE_MAX_BYTES) {\n addSystem(`发送失败:图片不能超过 ${formatBytes(IMAGE_MAX_BYTES)},当前 ${formatBytes(info.size)}`, \"error\");\n return;\n }\n const bytes = await readFile(filePath);\n const name = displayFileName(filePath);\n const mime = detectImageMime(bytes, name);\n if (!mime) {\n addSystem(\"发送失败:仅支持 jpg/png/webp/gif 图片\", \"error\");\n return;\n }\n const plaintext = encodeImageMessage({\n name,\n mime,\n size: bytes.length,\n data: bytes.toString(\"base64\"),\n });\n const { ciphertext, nonce } = encrypt(roomKey.current, plaintext);\n client.send(ciphertext, nonce);\n addImage(username, imageSummary({ name, mime, size: bytes.length }), true);\n } catch (err) {\n const message = err instanceof Error ? err.message : String(err);\n addSystem(`发送图片失败:${message}`, \"error\");\n }\n }\n\n // 可视区域:留出 header + 边框 + 输入区的空间\n const rows = stdout?.rows ?? 24;\n const columns = stdout?.columns ?? 80;\n const compact = rows < 18 || columns < 72;\n const visible = Math.max(4, rows - (compact ? 7 : 9));\n\n const cdColor = remaining < 60 ? \"red\" : remaining < 300 ? \"yellow\" : \"gray\";\n const statusColor = status === \"online\" ? \"green\" : status === \"closing\" ? \"yellow\" : \"cyan\";\n\n return (\n <Box flexDirection=\"column\" height={rows}>\n <Box borderStyle={compact ? undefined : \"round\"} borderColor=\"cyan\" paddingX={compact ? 0 : 1}>\n <Box flexGrow={1}>\n <Text color=\"cyan\" bold>\n ephem\n </Text>\n <Text color=\"gray\"> · </Text>\n <Text bold>{roomCode}</Text>\n <Text color=\"gray\">\n {\" \"}\n {members}/{maxMembers} 人\n </Text>\n <Box flexGrow={1} />\n <Text color={statusColor}>{statusText}</Text>\n <Text color=\"gray\"> </Text>\n <Text color={cdColor}>⏳ {fmtCd(remaining)}</Text>\n </Box>\n </Box>\n <Text color=\"gray\">\n Enter 发送 · /image <路径> 发图 · Ctrl+L 清屏 · Ctrl+C 退出{closing ? ` · ${closing}` : \"\"}\n </Text>\n\n <Box\n flexDirection=\"column\"\n flexGrow={1}\n marginTop={1}\n borderStyle={compact ? undefined : \"single\"}\n borderColor=\"gray\"\n paddingX={compact ? 0 : 1}\n >\n {lines.slice(-visible).map((l) =>\n l.kind === \"system\" ? (\n <Text key={l.id} color={systemColor(l.level)}>\n · {l.text}\n </Text>\n ) : l.kind === \"image\" ? (\n <MessageRow key={l.id} time={l.time} from={l.from} self={l.self} text={l.summary} image />\n ) : (\n <MessageRow key={l.id} time={l.time} from={l.from} self={l.self} text={l.text} />\n ),\n )}\n </Box>\n\n <Box marginTop={1} borderStyle={compact ? undefined : \"single\"} borderColor=\"cyan\" paddingX={compact ? 0 : 1}>\n <Text color=\"cyan\">› </Text>\n <TextInput\n value={input}\n onChange={setInput}\n onSubmit={(value) => {\n void handleSend(value);\n }}\n placeholder={closing ? \"房间即将关闭…\" : \"输入消息…\"}\n />\n </Box>\n </Box>\n );\n}\n\nfunction MessageRow({\n time,\n from,\n text,\n self,\n image = false,\n}: {\n time: string;\n from: string;\n text: string;\n self: boolean;\n image?: boolean;\n}) {\n const nameColor = self ? \"cyan\" : \"white\";\n return (\n <Box>\n <Text color=\"gray\">{time} </Text>\n <Text color={nameColor} bold>\n {from}\n </Text>\n <Text color=\"gray\"> │ </Text>\n <Text color={image ? \"magenta\" : nameColor}>{text}</Text>\n </Box>\n );\n}\n\nfunction systemColor(level?: \"info\" | \"warn\" | \"error\"): \"gray\" | \"yellow\" | \"red\" {\n if (level === \"error\") return \"red\";\n if (level === \"warn\") return \"yellow\";\n return \"gray\";\n}\n\nfunction nowStr(): string {\n return new Date().toLocaleTimeString(\"zh-CN\", { hour: \"2-digit\", minute: \"2-digit\" });\n}\n\nfunction fmtCd(sec: number): string {\n const h = Math.floor(sec / 3600);\n const m = Math.floor((sec % 3600) / 60);\n const s = sec % 60;\n return `${String(h).padStart(2, \"0\")}:${String(m).padStart(2, \"0\")}:${String(s).padStart(2, \"0\")}`;\n}\n","// 从房间码派生对称密钥:HKDF(SHA-256) → 32 字节 AES-256-GCM 密钥。\n// 全程在客户端本地完成,房间码本身不因此通过网络发给后端。\n//\n// 设计说明:这是\"共享密码派生密钥\"模式(PAKE 的简化版)。房间码同时承担\n// 路由标识和密钥种子双重职责。攻击者要验证猜测必须先连上对应房间码的 WS\n// 端点,而后端对单房间码连接尝试做了限流。\n\nimport { hkdfSync } from \"node:crypto\";\n\nconst SALT = \"ephem-v1-room-salt\";\nconst INFO = \"ephem-room-encryption-key\";\nconst KEY_LEN = 32; // AES-256\n\n/** 从房间码派生房间加密密钥(32 字节)。 */\nexport function deriveRoomKey(roomCode: string): Buffer {\n const ikm = Buffer.from(roomCode, \"utf8\");\n const salt = Buffer.from(SALT, \"utf8\");\n const info = Buffer.from(INFO, \"utf8\");\n // hkdfSync 在 Node 18+ 返回 Buffer\n return Buffer.from(hkdfSync(\"sha256\", ikm, salt, info, KEY_LEN));\n}\n","// AES-256-GCM 加解密封装。\n// 约定:每条消息用独立随机 12 字节 nonce;认证标签 (authTag, 16 字节) 拼在密文末尾。\n// 密文与 nonce 都用 base64 编码传输(JSON 友好)。\n// 后端只原样转发 { ciphertext, nonce },不解密、不校验。\n\nimport { createCipheriv, createDecipheriv, randomBytes } from \"node:crypto\";\n\nconst ALGO = \"aes-256-gcm\";\nconst NONCE_LEN = 12;\nconst TAG_LEN = 16;\n\nexport interface EncryptedPayload {\n ciphertext: string; // base64(密文 + authTag)\n nonce: string; // base64(12 字节 nonce)\n}\n\n/** 加密一条文本消息。 */\nexport function encrypt(key: Buffer, plaintext: string): EncryptedPayload {\n const nonce = randomBytes(NONCE_LEN);\n const cipher = createCipheriv(ALGO, key, nonce);\n const enc = Buffer.concat([cipher.update(plaintext, \"utf8\"), cipher.final()]);\n const tag = cipher.getAuthTag();\n const combined = Buffer.concat([enc, tag]); // authTag 拼在末尾\n return {\n ciphertext: combined.toString(\"base64\"),\n nonce: nonce.toString(\"base64\"),\n };\n}\n\n/** 解密一条消息。认证失败会抛错(说明密钥不对或密文被篡改)。 */\nexport function decrypt(key: Buffer, payload: EncryptedPayload): string {\n const combined = Buffer.from(payload.ciphertext, \"base64\");\n const nonce = Buffer.from(payload.nonce, \"base64\");\n if (combined.length < TAG_LEN + 1) {\n throw new Error(\"密文长度异常\");\n }\n const tag = combined.subarray(combined.length - TAG_LEN);\n const enc = combined.subarray(0, combined.length - TAG_LEN);\n const decipher = createDecipheriv(ALGO, key, nonce);\n decipher.setAuthTag(tag);\n const dec = Buffer.concat([decipher.update(enc), decipher.final()]);\n return dec.toString(\"utf8\");\n}\n","import { basename } from \"node:path\";\n\nexport const IMAGE_MAX_BYTES = 1024 * 1024;\n\nexport type StructuredMessage =\n | { v: 1; kind: \"text\"; text: string }\n | {\n v: 1;\n kind: \"image\";\n mime: string;\n name?: string;\n size: number;\n width?: number;\n height?: number;\n data: string;\n thumb?: { mime: string; width: number; height: number; data: string };\n };\n\nexport type ParsedMessage =\n | { kind: \"text\"; text: string; structured: boolean }\n | { kind: \"image\"; mime: string; name?: string; size: number; width?: number; height?: number };\n\nexport function encodeTextMessage(text: string): string {\n return JSON.stringify({ v: 1, kind: \"text\", text } satisfies StructuredMessage);\n}\n\nexport function encodeImageMessage(input: {\n name?: string;\n mime: string;\n size: number;\n data: string;\n}): string {\n return JSON.stringify({\n v: 1,\n kind: \"image\",\n mime: input.mime,\n name: input.name,\n size: input.size,\n data: input.data,\n } satisfies StructuredMessage);\n}\n\nexport function parsePlaintextMessage(plaintext: string): ParsedMessage {\n try {\n const msg = JSON.parse(plaintext) as Partial<StructuredMessage>;\n if (msg && msg.v === 1 && msg.kind === \"text\" && typeof msg.text === \"string\") {\n return { kind: \"text\", text: msg.text, structured: true };\n }\n if (\n msg &&\n msg.v === 1 &&\n msg.kind === \"image\" &&\n typeof msg.mime === \"string\" &&\n typeof msg.size === \"number\" &&\n typeof msg.data === \"string\"\n ) {\n return {\n kind: \"image\",\n mime: msg.mime,\n name: typeof msg.name === \"string\" ? msg.name : undefined,\n size: msg.size,\n width: typeof msg.width === \"number\" ? msg.width : undefined,\n height: typeof msg.height === \"number\" ? msg.height : undefined,\n };\n }\n } catch {\n // Old clients encrypt plain text directly; keep that path compatible.\n }\n return { kind: \"text\", text: plaintext, structured: false };\n}\n\nexport function imageSummary(image: { name?: string; mime: string; size: number; width?: number; height?: number }): string {\n const name = image.name ? `${image.name} · ` : \"\";\n const dim = image.width && image.height ? ` · ${image.width}x${image.height}` : \"\";\n return `[图片 ${name}${formatBytes(image.size)} · ${image.mime}${dim}]`;\n}\n\nexport function formatBytes(bytes: number): string {\n if (bytes < 1024) return `${bytes} B`;\n if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} KB`;\n return `${(bytes / 1024 / 1024).toFixed(2)} MB`;\n}\n\nexport function parseImageCommand(input: string): string | null {\n const trimmed = input.trim();\n if (!trimmed.toLowerCase().startsWith(\"/image\")) return null;\n const rest = trimmed.slice(\"/image\".length).trim();\n if (!rest) return \"\";\n if ((rest.startsWith('\"') && rest.endsWith('\"')) || (rest.startsWith(\"'\") && rest.endsWith(\"'\"))) {\n return rest.slice(1, -1);\n }\n return rest;\n}\n\nexport function displayFileName(filePath: string): string {\n return basename(filePath) || \"image\";\n}\n\nexport function detectImageMime(bytes: Uint8Array, fileName: string): string | null {\n if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return \"image/jpeg\";\n if (\n bytes.length >= 8 &&\n bytes[0] === 0x89 &&\n bytes[1] === 0x50 &&\n bytes[2] === 0x4e &&\n bytes[3] === 0x47 &&\n bytes[4] === 0x0d &&\n bytes[5] === 0x0a &&\n bytes[6] === 0x1a &&\n bytes[7] === 0x0a\n ) {\n return \"image/png\";\n }\n if (bytes.length >= 12 && ascii(bytes, 0, 4) === \"RIFF\" && ascii(bytes, 8, 12) === \"WEBP\") return \"image/webp\";\n if (bytes.length >= 6 && (ascii(bytes, 0, 6) === \"GIF87a\" || ascii(bytes, 0, 6) === \"GIF89a\")) return \"image/gif\";\n\n const lower = fileName.toLowerCase();\n if (lower.endsWith(\".jpg\") || lower.endsWith(\".jpeg\")) return \"image/jpeg\";\n if (lower.endsWith(\".png\")) return \"image/png\";\n if (lower.endsWith(\".webp\")) return \"image/webp\";\n if (lower.endsWith(\".gif\")) return \"image/gif\";\n return null;\n}\n\nfunction ascii(bytes: Uint8Array, start: number, end: number): string {\n return String.fromCharCode(...bytes.subarray(start, end));\n}\n","// 房间 WebSocket 客户端:连接、收发密文、心跳保活、断线指数退避重连。\n// 服务端主动拒绝(房间不存在/已满/过期)时不重连,交由 UI 处理。\n\nimport { EventEmitter } from \"node:events\";\nimport WebSocket from \"ws\";\n\nexport interface JoinedInfo {\n username: string;\n currentMembers: number;\n maxMembers: number;\n expiresAt: number;\n}\n\nexport interface ChatMessage {\n from: string;\n ciphertext: string;\n nonce: string;\n timestamp: number;\n}\n\nexport type CloseReason = \"ttl_expired\" | \"empty\" | \"manual\";\n\ninterface ReconnectingInfo {\n attempt: number;\n delayMs: number;\n}\n\n/**\n * 事件(全部通过 on 订阅):\n * joined(info) 加入成功\n * peer_joined({username})\n * peer_left({username})\n * message(msg) 收到一条密文消息\n * room_closing({reason})房间即将销毁\n * server_error({code,message}) 服务端拒绝/出错(不可恢复)\n * reconnecting(info) 断线后准备第 N 次重连\n * closed() 连接彻底关闭\n */\nexport class RoomClient extends EventEmitter {\n private ws: WebSocket | null = null;\n private reconnectAttempt = 0;\n private manuallyClosed = false;\n private rejectedByServer = false;\n private pingTimer: NodeJS.Timeout | null = null;\n private reconnectTimer: NodeJS.Timeout | null = null;\n\n constructor(\n private readonly server: string,\n private readonly roomCode: string,\n private readonly username: string,\n ) {\n super();\n }\n\n connect(): void {\n this.manuallyClosed = false;\n this.rejectedByServer = false;\n this.openSocket();\n }\n\n private openSocket(): void {\n const url = `${normalizeWs(this.server)}/room/${encodeURIComponent(this.roomCode)}?username=${encodeURIComponent(this.username)}`;\n const ws = new WebSocket(url);\n this.ws = ws;\n\n ws.on(\"open\", () => {\n this.reconnectAttempt = 0;\n this.startPing();\n // 真正\"加入成功\"由服务端 joined 消息确认;这里只表示链路通了\n });\n\n ws.on(\"message\", (raw: Buffer | string) => {\n let msg: { type?: string; payload?: unknown };\n try {\n msg = JSON.parse(raw.toString());\n } catch {\n return;\n }\n this.dispatch(msg);\n });\n\n // 服务端返回非 101 响应(房间不存在/已满/过期/限流)\n ws.on(\"unexpected-response\", (_req, res) => {\n let body = \"\";\n res.on(\"data\", (c: Buffer) => (body += c.toString()));\n res.on(\"end\", () => {\n let info = { code: `http_${res.statusCode}`, message: \"连接被服务端拒绝\" };\n try {\n const j = JSON.parse(body);\n if (j.error) info = { code: String(j.error), message: String(j.message ?? j.error) };\n } catch {\n /* keep default */\n }\n this.rejectedByServer = true;\n this.emit(\"server_error\", info);\n });\n });\n\n ws.on(\"close\", () => {\n this.stopPing();\n if (this.manuallyClosed || this.rejectedByServer) {\n this.emit(\"closed\");\n return;\n }\n this.scheduleReconnect();\n });\n\n ws.on(\"error\", () => {\n // 网络层错误;后续 close 会触发重连流程,这里不单独抛出\n });\n }\n\n private dispatch(msg: { type?: string; payload?: any }) {\n switch (msg.type) {\n case \"joined\":\n this.emit(\"joined\", msg.payload as JoinedInfo);\n break;\n case \"peer_joined\":\n this.emit(\"peer_joined\", msg.payload);\n break;\n case \"peer_left\":\n this.emit(\"peer_left\", msg.payload);\n break;\n case \"message\":\n this.emit(\"message\", msg.payload as ChatMessage);\n break;\n case \"room_closing\":\n this.manuallyClosed = true; // 房间销毁是终态\n this.emit(\"room_closing\", msg.payload as { reason: CloseReason });\n break;\n case \"error\":\n this.emit(\"server_error\", msg.payload);\n break;\n default:\n break;\n }\n }\n\n /** 发送一条已加密的消息(密文 + nonce)。 */\n send(ciphertext: string, nonce: string): void {\n if (this.ws?.readyState === WebSocket.OPEN) {\n this.ws.send(JSON.stringify({ type: \"message\", payload: { ciphertext, nonce } }));\n }\n }\n\n close(): void {\n this.manuallyClosed = true;\n this.stopPing();\n if (this.reconnectTimer) clearTimeout(this.reconnectTimer);\n this.ws?.close();\n }\n\n private startPing(): void {\n this.stopPing();\n this.pingTimer = setInterval(() => {\n if (this.ws?.readyState === WebSocket.OPEN) {\n this.ws.send(JSON.stringify({ type: \"ping\" }));\n }\n }, 25_000);\n }\n\n private stopPing(): void {\n if (this.pingTimer) clearInterval(this.pingTimer);\n this.pingTimer = null;\n }\n\n private scheduleReconnect(): void {\n this.reconnectAttempt += 1;\n const delayMs = Math.min(1000 * 2 ** (this.reconnectAttempt - 1), 30_000);\n this.emit(\"reconnecting\", { attempt: this.reconnectAttempt, delayMs });\n this.reconnectTimer = setTimeout(() => this.openSocket(), delayMs);\n }\n}\n\n/** 把任意形式的地址规范化成 ws/wss 基础 URL(去尾部斜杠)。 */\nfunction normalizeWs(server: string): string {\n let s = server.trim().replace(/\\/+$/, \"\");\n if (s.startsWith(\"https://\")) s = \"wss://\" + s.slice(\"https://\".length);\n else if (s.startsWith(\"http://\")) s = \"ws://\" + s.slice(\"http://\".length);\n else if (!s.startsWith(\"ws://\") && !s.startsWith(\"wss://\")) s = \"wss://\" + s;\n return s;\n}\n"],"mappings":"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","names":["React","useEffect","useRef","useState","Box","Text","useApp","useInput","useState","Box","Text","TextInput","jsx","jsxs","useState","input","Box","Text","TextInput","jsx","jsxs","useApp","useState","useRef","useEffect","Box","Text","useInput","React"]}
|
package/integration-test.mjs
CHANGED
|
@@ -95,6 +95,27 @@ process.on("uncaughtException", (e) => {
|
|
|
95
95
|
});
|
|
96
96
|
|
|
97
97
|
async function main() {
|
|
98
|
+
// ── 健康检查:确保后端已起来 ──────────────
|
|
99
|
+
console.log("[0] 等待后端就绪");
|
|
100
|
+
let ready = false;
|
|
101
|
+
for (let i = 0; i < 60; i++) {
|
|
102
|
+
try {
|
|
103
|
+
const res = await fetch(`${HTTP}/`, { signal: AbortSignal.timeout(2000) });
|
|
104
|
+
if (res.ok || res.status === 200) {
|
|
105
|
+
ready = true;
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
/* 还没起来,继续等 */
|
|
110
|
+
}
|
|
111
|
+
await sleep(1000);
|
|
112
|
+
}
|
|
113
|
+
if (!ready) {
|
|
114
|
+
console.error("✗ 后端在 60 秒内未就绪,跳过集成测试");
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
console.log(" ✓ 后端已就绪");
|
|
118
|
+
|
|
98
119
|
// ── 建房 ──────────────────────────────
|
|
99
120
|
console.log("\n[1] 创建房间");
|
|
100
121
|
const r = await fetch(`${HTTP}/api/rooms`, {
|
|
@@ -102,8 +123,13 @@ async function main() {
|
|
|
102
123
|
headers: { "Content-Type": "application/json", "X-Admin-Key": ADMIN_KEY },
|
|
103
124
|
body: JSON.stringify({ maxMembers: 3, ttlSeconds: 600 }),
|
|
104
125
|
});
|
|
126
|
+
if (!r.ok) {
|
|
127
|
+
const text = await r.text();
|
|
128
|
+
console.error(`✗ 建房失败 HTTP ${r.status}: ${text}`);
|
|
129
|
+
process.exit(1);
|
|
130
|
+
}
|
|
105
131
|
const room = await r.json();
|
|
106
|
-
assert(
|
|
132
|
+
assert(!!room.roomCode, `房间已创建:${room.roomCode}`);
|
|
107
133
|
const key = deriveKey(room.roomCode);
|
|
108
134
|
|
|
109
135
|
// ── 状态查询(需鉴权)────────────────
|
package/package.json
CHANGED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
|
|
3
|
+
export const IMAGE_MAX_BYTES = 1024 * 1024;
|
|
4
|
+
|
|
5
|
+
export type StructuredMessage =
|
|
6
|
+
| { v: 1; kind: "text"; text: string }
|
|
7
|
+
| {
|
|
8
|
+
v: 1;
|
|
9
|
+
kind: "image";
|
|
10
|
+
mime: string;
|
|
11
|
+
name?: string;
|
|
12
|
+
size: number;
|
|
13
|
+
width?: number;
|
|
14
|
+
height?: number;
|
|
15
|
+
data: string;
|
|
16
|
+
thumb?: { mime: string; width: number; height: number; data: string };
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type ParsedMessage =
|
|
20
|
+
| { kind: "text"; text: string; structured: boolean }
|
|
21
|
+
| { kind: "image"; mime: string; name?: string; size: number; width?: number; height?: number };
|
|
22
|
+
|
|
23
|
+
export function encodeTextMessage(text: string): string {
|
|
24
|
+
return JSON.stringify({ v: 1, kind: "text", text } satisfies StructuredMessage);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function encodeImageMessage(input: {
|
|
28
|
+
name?: string;
|
|
29
|
+
mime: string;
|
|
30
|
+
size: number;
|
|
31
|
+
data: string;
|
|
32
|
+
}): string {
|
|
33
|
+
return JSON.stringify({
|
|
34
|
+
v: 1,
|
|
35
|
+
kind: "image",
|
|
36
|
+
mime: input.mime,
|
|
37
|
+
name: input.name,
|
|
38
|
+
size: input.size,
|
|
39
|
+
data: input.data,
|
|
40
|
+
} satisfies StructuredMessage);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function parsePlaintextMessage(plaintext: string): ParsedMessage {
|
|
44
|
+
try {
|
|
45
|
+
const msg = JSON.parse(plaintext) as Partial<StructuredMessage>;
|
|
46
|
+
if (msg && msg.v === 1 && msg.kind === "text" && typeof msg.text === "string") {
|
|
47
|
+
return { kind: "text", text: msg.text, structured: true };
|
|
48
|
+
}
|
|
49
|
+
if (
|
|
50
|
+
msg &&
|
|
51
|
+
msg.v === 1 &&
|
|
52
|
+
msg.kind === "image" &&
|
|
53
|
+
typeof msg.mime === "string" &&
|
|
54
|
+
typeof msg.size === "number" &&
|
|
55
|
+
typeof msg.data === "string"
|
|
56
|
+
) {
|
|
57
|
+
return {
|
|
58
|
+
kind: "image",
|
|
59
|
+
mime: msg.mime,
|
|
60
|
+
name: typeof msg.name === "string" ? msg.name : undefined,
|
|
61
|
+
size: msg.size,
|
|
62
|
+
width: typeof msg.width === "number" ? msg.width : undefined,
|
|
63
|
+
height: typeof msg.height === "number" ? msg.height : undefined,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
} catch {
|
|
67
|
+
// Old clients encrypt plain text directly; keep that path compatible.
|
|
68
|
+
}
|
|
69
|
+
return { kind: "text", text: plaintext, structured: false };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function imageSummary(image: { name?: string; mime: string; size: number; width?: number; height?: number }): string {
|
|
73
|
+
const name = image.name ? `${image.name} · ` : "";
|
|
74
|
+
const dim = image.width && image.height ? ` · ${image.width}x${image.height}` : "";
|
|
75
|
+
return `[图片 ${name}${formatBytes(image.size)} · ${image.mime}${dim}]`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function formatBytes(bytes: number): string {
|
|
79
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
80
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} KB`;
|
|
81
|
+
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function parseImageCommand(input: string): string | null {
|
|
85
|
+
const trimmed = input.trim();
|
|
86
|
+
if (!trimmed.toLowerCase().startsWith("/image")) return null;
|
|
87
|
+
const rest = trimmed.slice("/image".length).trim();
|
|
88
|
+
if (!rest) return "";
|
|
89
|
+
if ((rest.startsWith('"') && rest.endsWith('"')) || (rest.startsWith("'") && rest.endsWith("'"))) {
|
|
90
|
+
return rest.slice(1, -1);
|
|
91
|
+
}
|
|
92
|
+
return rest;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function displayFileName(filePath: string): string {
|
|
96
|
+
return basename(filePath) || "image";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function detectImageMime(bytes: Uint8Array, fileName: string): string | null {
|
|
100
|
+
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return "image/jpeg";
|
|
101
|
+
if (
|
|
102
|
+
bytes.length >= 8 &&
|
|
103
|
+
bytes[0] === 0x89 &&
|
|
104
|
+
bytes[1] === 0x50 &&
|
|
105
|
+
bytes[2] === 0x4e &&
|
|
106
|
+
bytes[3] === 0x47 &&
|
|
107
|
+
bytes[4] === 0x0d &&
|
|
108
|
+
bytes[5] === 0x0a &&
|
|
109
|
+
bytes[6] === 0x1a &&
|
|
110
|
+
bytes[7] === 0x0a
|
|
111
|
+
) {
|
|
112
|
+
return "image/png";
|
|
113
|
+
}
|
|
114
|
+
if (bytes.length >= 12 && ascii(bytes, 0, 4) === "RIFF" && ascii(bytes, 8, 12) === "WEBP") return "image/webp";
|
|
115
|
+
if (bytes.length >= 6 && (ascii(bytes, 0, 6) === "GIF87a" || ascii(bytes, 0, 6) === "GIF89a")) return "image/gif";
|
|
116
|
+
|
|
117
|
+
const lower = fileName.toLowerCase();
|
|
118
|
+
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
|
|
119
|
+
if (lower.endsWith(".png")) return "image/png";
|
|
120
|
+
if (lower.endsWith(".webp")) return "image/webp";
|
|
121
|
+
if (lower.endsWith(".gif")) return "image/gif";
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function ascii(bytes: Uint8Array, start: number, end: number): string {
|
|
126
|
+
return String.fromCharCode(...bytes.subarray(start, end));
|
|
127
|
+
}
|
package/src/ui/ChatRoom.tsx
CHANGED
|
@@ -1,9 +1,21 @@
|
|
|
1
1
|
import React, { useEffect, useReducer, useRef, useState } from "react";
|
|
2
|
-
import {
|
|
2
|
+
import { readFile, stat } from "node:fs/promises";
|
|
3
|
+
import { Box, Text, useApp, useInput, useStdout } from "ink";
|
|
3
4
|
import TextInput from "ink-text-input";
|
|
4
5
|
import type { RoomClient, JoinedInfo, ChatMessage } from "../ws/client.js";
|
|
5
6
|
import { deriveRoomKey } from "../crypto/deriveKey.js";
|
|
6
7
|
import { encrypt, decrypt } from "../crypto/cipher.js";
|
|
8
|
+
import {
|
|
9
|
+
IMAGE_MAX_BYTES,
|
|
10
|
+
detectImageMime,
|
|
11
|
+
displayFileName,
|
|
12
|
+
encodeImageMessage,
|
|
13
|
+
encodeTextMessage,
|
|
14
|
+
formatBytes,
|
|
15
|
+
imageSummary,
|
|
16
|
+
parseImageCommand,
|
|
17
|
+
parsePlaintextMessage,
|
|
18
|
+
} from "../protocol/message.js";
|
|
7
19
|
|
|
8
20
|
interface Props {
|
|
9
21
|
client: RoomClient;
|
|
@@ -14,8 +26,9 @@ interface Props {
|
|
|
14
26
|
}
|
|
15
27
|
|
|
16
28
|
type Line =
|
|
17
|
-
| { id: number; kind: "system"; text: string }
|
|
18
|
-
| { id: number; kind: "
|
|
29
|
+
| { id: number; kind: "system"; text: string; level?: "info" | "warn" | "error" }
|
|
30
|
+
| { id: number; kind: "text"; from: string; text: string; self: boolean; time: string }
|
|
31
|
+
| { id: number; kind: "image"; from: string; summary: string; self: boolean; time: string };
|
|
19
32
|
|
|
20
33
|
let lineId = 0;
|
|
21
34
|
|
|
@@ -37,13 +50,20 @@ export function ChatRoom({ client, roomCode, username, joined, onExit }: Props)
|
|
|
37
50
|
const [expiresAt] = useState(joined.expiresAt);
|
|
38
51
|
const [remaining, setRemaining] = useState(() => Math.max(0, Math.floor((joined.expiresAt - Date.now()) / 1000)));
|
|
39
52
|
const [closing, setClosing] = useState<string | null>(null);
|
|
53
|
+
const [status, setStatus] = useState<"online" | "reconnecting" | "closing">("online");
|
|
54
|
+
const [statusText, setStatusText] = useState("已连接");
|
|
40
55
|
|
|
41
|
-
const addSystem = (text: string) =>
|
|
42
|
-
dispatch({ type: "add", line: { id: ++lineId, kind: "system", text } });
|
|
43
|
-
const
|
|
56
|
+
const addSystem = (text: string, level: "info" | "warn" | "error" = "info") =>
|
|
57
|
+
dispatch({ type: "add", line: { id: ++lineId, kind: "system", text, level } });
|
|
58
|
+
const addText = (from: string, text: string, self: boolean) =>
|
|
44
59
|
dispatch({
|
|
45
60
|
type: "add",
|
|
46
|
-
line: { id: ++lineId, kind: "
|
|
61
|
+
line: { id: ++lineId, kind: "text", from, text, self, time: nowStr() },
|
|
62
|
+
});
|
|
63
|
+
const addImage = (from: string, summary: string, self: boolean) =>
|
|
64
|
+
dispatch({
|
|
65
|
+
type: "add",
|
|
66
|
+
line: { id: ++lineId, kind: "image", from, summary, self, time: nowStr() },
|
|
47
67
|
});
|
|
48
68
|
|
|
49
69
|
// 订阅客户端事件
|
|
@@ -60,17 +80,21 @@ export function ChatRoom({ client, roomCode, username, joined, onExit }: Props)
|
|
|
60
80
|
};
|
|
61
81
|
const onMessage = (msg: ChatMessage) => {
|
|
62
82
|
try {
|
|
63
|
-
const
|
|
64
|
-
|
|
83
|
+
const plaintext = decrypt(roomKey.current, { ciphertext: msg.ciphertext, nonce: msg.nonce });
|
|
84
|
+
const parsed = parsePlaintextMessage(plaintext);
|
|
85
|
+
if (parsed.kind === "image") addImage(msg.from, imageSummary(parsed), false);
|
|
86
|
+
else addText(msg.from, parsed.text, false);
|
|
65
87
|
} catch {
|
|
66
|
-
addSystem(`收到来自 ${msg.from}
|
|
88
|
+
addSystem(`收到来自 ${msg.from} 的无法解密的消息`, "warn");
|
|
67
89
|
}
|
|
68
90
|
};
|
|
69
91
|
const onRoomClosing = ({ reason }: { reason: string }) => {
|
|
70
92
|
const reasonText =
|
|
71
93
|
reason === "ttl_expired" ? "房间已到期" : reason === "empty" ? "房间已空" : "房间被手动销毁";
|
|
94
|
+
setStatus("closing");
|
|
95
|
+
setStatusText(reasonText);
|
|
72
96
|
setClosing(reasonText);
|
|
73
|
-
addSystem(`房间即将关闭:${reasonText}
|
|
97
|
+
addSystem(`房间即将关闭:${reasonText}`, "warn");
|
|
74
98
|
setTimeout(() => {
|
|
75
99
|
client.close();
|
|
76
100
|
onExit();
|
|
@@ -78,21 +102,34 @@ export function ChatRoom({ client, roomCode, username, joined, onExit }: Props)
|
|
|
78
102
|
}, 1500);
|
|
79
103
|
};
|
|
80
104
|
const onServerError = (info: { code: string; message: string }) => {
|
|
81
|
-
addSystem(`错误:${info.message} (${info.code})
|
|
105
|
+
addSystem(`错误:${info.message} (${info.code})`, "error");
|
|
106
|
+
};
|
|
107
|
+
const onReconnecting = ({ attempt, delayMs }: { attempt: number; delayMs: number }) => {
|
|
108
|
+
setStatus("reconnecting");
|
|
109
|
+
setStatusText(`重连 #${attempt},${Math.ceil(delayMs / 1000)}s 后`);
|
|
110
|
+
addSystem(`连接断开,准备第 ${attempt} 次重连`, "warn");
|
|
111
|
+
};
|
|
112
|
+
const onJoined = () => {
|
|
113
|
+
setStatus("online");
|
|
114
|
+
setStatusText("已连接");
|
|
82
115
|
};
|
|
83
116
|
|
|
117
|
+
client.on("joined", onJoined);
|
|
84
118
|
client.on("peer_joined", onPeerJoined);
|
|
85
119
|
client.on("peer_left", onPeerLeft);
|
|
86
120
|
client.on("message", onMessage);
|
|
87
121
|
client.on("room_closing", onRoomClosing);
|
|
88
122
|
client.on("server_error", onServerError);
|
|
123
|
+
client.on("reconnecting", onReconnecting);
|
|
89
124
|
|
|
90
125
|
return () => {
|
|
126
|
+
client.off("joined", onJoined);
|
|
91
127
|
client.off("peer_joined", onPeerJoined);
|
|
92
128
|
client.off("peer_left", onPeerLeft);
|
|
93
129
|
client.off("message", onMessage);
|
|
94
130
|
client.off("room_closing", onRoomClosing);
|
|
95
131
|
client.off("server_error", onServerError);
|
|
132
|
+
client.off("reconnecting", onReconnecting);
|
|
96
133
|
};
|
|
97
134
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
98
135
|
}, [client]);
|
|
@@ -109,30 +146,82 @@ export function ChatRoom({ client, roomCode, username, joined, onExit }: Props)
|
|
|
109
146
|
// 退出时关闭连接
|
|
110
147
|
useEffect(() => () => client.close(), [client]);
|
|
111
148
|
|
|
112
|
-
|
|
149
|
+
useInput((input, key) => {
|
|
150
|
+
if (key.ctrl && input.toLowerCase() === "l") {
|
|
151
|
+
dispatch({ type: "clear" });
|
|
152
|
+
addSystem("已清屏");
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
async function handleSend(text: string) {
|
|
113
157
|
const t = text.trim();
|
|
114
158
|
if (!t) return;
|
|
159
|
+
const imagePath = parseImageCommand(t);
|
|
160
|
+
if (imagePath !== null) {
|
|
161
|
+
await handleImageSend(imagePath);
|
|
162
|
+
setInput("");
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
115
165
|
try {
|
|
116
|
-
const { ciphertext, nonce } = encrypt(roomKey.current, t);
|
|
166
|
+
const { ciphertext, nonce } = encrypt(roomKey.current, encodeTextMessage(t));
|
|
117
167
|
client.send(ciphertext, nonce);
|
|
118
|
-
|
|
168
|
+
addText(username, t, true);
|
|
119
169
|
} catch {
|
|
120
|
-
addSystem("发送失败:加密出错");
|
|
170
|
+
addSystem("发送失败:加密出错", "error");
|
|
121
171
|
}
|
|
122
172
|
setInput("");
|
|
123
173
|
}
|
|
124
174
|
|
|
125
|
-
|
|
175
|
+
async function handleImageSend(filePath: string) {
|
|
176
|
+
if (!filePath) {
|
|
177
|
+
addSystem("用法:/image <图片路径>", "warn");
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
try {
|
|
181
|
+
const info = await stat(filePath);
|
|
182
|
+
if (!info.isFile()) {
|
|
183
|
+
addSystem("发送失败:路径不是文件", "error");
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (info.size > IMAGE_MAX_BYTES) {
|
|
187
|
+
addSystem(`发送失败:图片不能超过 ${formatBytes(IMAGE_MAX_BYTES)},当前 ${formatBytes(info.size)}`, "error");
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const bytes = await readFile(filePath);
|
|
191
|
+
const name = displayFileName(filePath);
|
|
192
|
+
const mime = detectImageMime(bytes, name);
|
|
193
|
+
if (!mime) {
|
|
194
|
+
addSystem("发送失败:仅支持 jpg/png/webp/gif 图片", "error");
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const plaintext = encodeImageMessage({
|
|
198
|
+
name,
|
|
199
|
+
mime,
|
|
200
|
+
size: bytes.length,
|
|
201
|
+
data: bytes.toString("base64"),
|
|
202
|
+
});
|
|
203
|
+
const { ciphertext, nonce } = encrypt(roomKey.current, plaintext);
|
|
204
|
+
client.send(ciphertext, nonce);
|
|
205
|
+
addImage(username, imageSummary({ name, mime, size: bytes.length }), true);
|
|
206
|
+
} catch (err) {
|
|
207
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
208
|
+
addSystem(`发送图片失败:${message}`, "error");
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// 可视区域:留出 header + 边框 + 输入区的空间
|
|
126
213
|
const rows = stdout?.rows ?? 24;
|
|
127
|
-
const
|
|
214
|
+
const columns = stdout?.columns ?? 80;
|
|
215
|
+
const compact = rows < 18 || columns < 72;
|
|
216
|
+
const visible = Math.max(4, rows - (compact ? 7 : 9));
|
|
128
217
|
|
|
129
218
|
const cdColor = remaining < 60 ? "red" : remaining < 300 ? "yellow" : "gray";
|
|
219
|
+
const statusColor = status === "online" ? "green" : status === "closing" ? "yellow" : "cyan";
|
|
130
220
|
|
|
131
221
|
return (
|
|
132
222
|
<Box flexDirection="column" height={rows}>
|
|
133
|
-
{
|
|
134
|
-
|
|
135
|
-
<Box>
|
|
223
|
+
<Box borderStyle={compact ? undefined : "round"} borderColor="cyan" paddingX={compact ? 0 : 1}>
|
|
224
|
+
<Box flexGrow={1}>
|
|
136
225
|
<Text color="cyan" bold>
|
|
137
226
|
ephem
|
|
138
227
|
</Text>
|
|
@@ -143,38 +232,44 @@ export function ChatRoom({ client, roomCode, username, joined, onExit }: Props)
|
|
|
143
232
|
{members}/{maxMembers} 人
|
|
144
233
|
</Text>
|
|
145
234
|
<Box flexGrow={1} />
|
|
235
|
+
<Text color={statusColor}>{statusText}</Text>
|
|
236
|
+
<Text color="gray"> </Text>
|
|
146
237
|
<Text color={cdColor}>⏳ {fmtCd(remaining)}</Text>
|
|
147
238
|
</Box>
|
|
148
|
-
<Text color="gray">输入消息回车发送 · Ctrl+C 退出{closing ? ` · ${closing}` : ""}</Text>
|
|
149
239
|
</Box>
|
|
240
|
+
<Text color="gray">
|
|
241
|
+
Enter 发送 · /image <路径> 发图 · Ctrl+L 清屏 · Ctrl+C 退出{closing ? ` · ${closing}` : ""}
|
|
242
|
+
</Text>
|
|
150
243
|
|
|
151
|
-
|
|
152
|
-
|
|
244
|
+
<Box
|
|
245
|
+
flexDirection="column"
|
|
246
|
+
flexGrow={1}
|
|
247
|
+
marginTop={1}
|
|
248
|
+
borderStyle={compact ? undefined : "single"}
|
|
249
|
+
borderColor="gray"
|
|
250
|
+
paddingX={compact ? 0 : 1}
|
|
251
|
+
>
|
|
153
252
|
{lines.slice(-visible).map((l) =>
|
|
154
253
|
l.kind === "system" ? (
|
|
155
|
-
<Text key={l.id} color=
|
|
156
|
-
{
|
|
157
|
-
{l.text}
|
|
254
|
+
<Text key={l.id} color={systemColor(l.level)}>
|
|
255
|
+
· {l.text}
|
|
158
256
|
</Text>
|
|
257
|
+
) : l.kind === "image" ? (
|
|
258
|
+
<MessageRow key={l.id} time={l.time} from={l.from} self={l.self} text={l.summary} image />
|
|
159
259
|
) : (
|
|
160
|
-
<
|
|
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>
|
|
260
|
+
<MessageRow key={l.id} time={l.time} from={l.from} self={l.self} text={l.text} />
|
|
167
261
|
),
|
|
168
262
|
)}
|
|
169
263
|
</Box>
|
|
170
264
|
|
|
171
|
-
{
|
|
172
|
-
|
|
173
|
-
<Text color="cyan">{"> "}</Text>
|
|
265
|
+
<Box marginTop={1} borderStyle={compact ? undefined : "single"} borderColor="cyan" paddingX={compact ? 0 : 1}>
|
|
266
|
+
<Text color="cyan">› </Text>
|
|
174
267
|
<TextInput
|
|
175
268
|
value={input}
|
|
176
269
|
onChange={setInput}
|
|
177
|
-
onSubmit={
|
|
270
|
+
onSubmit={(value) => {
|
|
271
|
+
void handleSend(value);
|
|
272
|
+
}}
|
|
178
273
|
placeholder={closing ? "房间即将关闭…" : "输入消息…"}
|
|
179
274
|
/>
|
|
180
275
|
</Box>
|
|
@@ -182,6 +277,38 @@ export function ChatRoom({ client, roomCode, username, joined, onExit }: Props)
|
|
|
182
277
|
);
|
|
183
278
|
}
|
|
184
279
|
|
|
280
|
+
function MessageRow({
|
|
281
|
+
time,
|
|
282
|
+
from,
|
|
283
|
+
text,
|
|
284
|
+
self,
|
|
285
|
+
image = false,
|
|
286
|
+
}: {
|
|
287
|
+
time: string;
|
|
288
|
+
from: string;
|
|
289
|
+
text: string;
|
|
290
|
+
self: boolean;
|
|
291
|
+
image?: boolean;
|
|
292
|
+
}) {
|
|
293
|
+
const nameColor = self ? "cyan" : "white";
|
|
294
|
+
return (
|
|
295
|
+
<Box>
|
|
296
|
+
<Text color="gray">{time} </Text>
|
|
297
|
+
<Text color={nameColor} bold>
|
|
298
|
+
{from}
|
|
299
|
+
</Text>
|
|
300
|
+
<Text color="gray"> │ </Text>
|
|
301
|
+
<Text color={image ? "magenta" : nameColor}>{text}</Text>
|
|
302
|
+
</Box>
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function systemColor(level?: "info" | "warn" | "error"): "gray" | "yellow" | "red" {
|
|
307
|
+
if (level === "error") return "red";
|
|
308
|
+
if (level === "warn") return "yellow";
|
|
309
|
+
return "gray";
|
|
310
|
+
}
|
|
311
|
+
|
|
185
312
|
function nowStr(): string {
|
|
186
313
|
return new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
|
|
187
314
|
}
|
package/src/ui/SetupWizard.tsx
CHANGED
|
@@ -21,6 +21,7 @@ export function SetupWizard({ defaults, onComplete }: Props) {
|
|
|
21
21
|
const [server, setServer] = useState(defaults.server ?? "");
|
|
22
22
|
const [room, setRoom] = useState((defaults.room ?? "").toLowerCase());
|
|
23
23
|
const [username, setUsername] = useState(defaults.username ?? "");
|
|
24
|
+
const [error, setError] = useState<string | null>(null);
|
|
24
25
|
|
|
25
26
|
const values = [server, room, username];
|
|
26
27
|
const setters = [setServer, setRoom, setUsername];
|
|
@@ -28,8 +29,13 @@ export function SetupWizard({ defaults, onComplete }: Props) {
|
|
|
28
29
|
function submit(value: string) {
|
|
29
30
|
const v = value.trim();
|
|
30
31
|
setters[step](v);
|
|
32
|
+
setError(null);
|
|
31
33
|
if (step === 0 && !v) {
|
|
32
|
-
|
|
34
|
+
setError("请输入后端地址,例如 wss://your-worker.workers.dev");
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (step === 1 && !/^[a-z]+-[a-z]+-[a-z]+$/.test(v.toLowerCase())) {
|
|
38
|
+
setError("房间码格式应为三段英文单词,例如 correct-horse-battery");
|
|
33
39
|
return;
|
|
34
40
|
}
|
|
35
41
|
if (step < 2) {
|
|
@@ -44,21 +50,24 @@ export function SetupWizard({ defaults, onComplete }: Props) {
|
|
|
44
50
|
};
|
|
45
51
|
|
|
46
52
|
return (
|
|
47
|
-
<Box flexDirection="column" gap={1}>
|
|
53
|
+
<Box flexDirection="column" gap={1} borderStyle="round" borderColor="cyan" paddingX={1} paddingY={1}>
|
|
48
54
|
<Box flexDirection="column">
|
|
49
|
-
<Text
|
|
50
|
-
|
|
55
|
+
<Text>
|
|
56
|
+
<Text color="cyan" bold>
|
|
57
|
+
ephem
|
|
58
|
+
</Text>
|
|
59
|
+
<Text color="gray"> · 临时加密聊天室</Text>
|
|
51
60
|
</Text>
|
|
52
|
-
<Text color="gray">按回车进入下一步,Ctrl+C
|
|
61
|
+
<Text color="gray">按回车进入下一步,Ctrl+C 退出。房间码和密钥不会落盘。</Text>
|
|
53
62
|
</Box>
|
|
54
63
|
|
|
55
64
|
{STEPS.map((label, i) => {
|
|
56
65
|
const done = i < step;
|
|
57
66
|
const active = i === step;
|
|
58
67
|
return (
|
|
59
|
-
<Box key={label} flexDirection="column">
|
|
60
|
-
<Text color={active ? "cyan" : "gray"}>
|
|
61
|
-
{done ? "✓" : active ? "
|
|
68
|
+
<Box key={label} flexDirection="column" marginTop={i === 0 ? 1 : 0}>
|
|
69
|
+
<Text color={active ? "cyan" : done ? "green" : "gray"}>
|
|
70
|
+
{done ? "✓" : active ? "›" : "·"} {label}
|
|
62
71
|
{i === 0 && defaults.server ? "(回车使用默认值)" : ""}
|
|
63
72
|
</Text>
|
|
64
73
|
{active ? (
|
|
@@ -72,11 +81,21 @@ export function SetupWizard({ defaults, onComplete }: Props) {
|
|
|
72
81
|
/>
|
|
73
82
|
</Box>
|
|
74
83
|
) : done ? (
|
|
75
|
-
<Text color="gray"> {values[i] || "(空)"}</Text>
|
|
84
|
+
<Text color="gray"> {i === 2 && !values[i] ? "匿名" : values[i] || "(空)"}</Text>
|
|
76
85
|
) : null}
|
|
77
86
|
</Box>
|
|
78
87
|
);
|
|
79
88
|
})}
|
|
89
|
+
|
|
90
|
+
{error ? (
|
|
91
|
+
<Box marginTop={1}>
|
|
92
|
+
<Text color="red">错误:{error}</Text>
|
|
93
|
+
</Box>
|
|
94
|
+
) : (
|
|
95
|
+
<Box marginTop={1}>
|
|
96
|
+
<Text color="gray">提示:进入聊天后可用 /image <路径> 发送小于 1 MiB 的图片。</Text>
|
|
97
|
+
</Box>
|
|
98
|
+
)}
|
|
80
99
|
</Box>
|
|
81
100
|
);
|
|
82
101
|
}
|