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 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__ */ jsx(Text, { color: "cyan", bold: true, children: "ephem \xB7 \u4E34\u65F6\u52A0\u5BC6\u804A\u5929\u5BA4" }),
45
- /* @__PURE__ */ jsx(Text, { color: "gray", children: "\u6309\u56DE\u8F66\u8FDB\u5165\u4E0B\u4E00\u6B65\uFF0CCtrl+C \u9000\u51FA" })
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 ? "?" : "\xB7",
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 { Box as Box2, Text as Text2, useApp, useStdout } from "ink";
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 addSystem = (text) => dispatch({ type: "add", line: { id: ++lineId, kind: "system", text } });
145
- const addMsg = (from, text, self) => dispatch({
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: "msg", from, text, self, time: nowStr() }
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 text = decrypt(roomKey.current, { ciphertext: msg.ciphertext, nonce: msg.nonce });
162
- addMsg(msg.from, text, false);
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
- function handleSend(text) {
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
- addMsg(username, t, true);
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 visible = Math.max(4, rows - 6);
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__ */ jsxs2(Box2, { flexDirection: "column", children: [
218
- /* @__PURE__ */ jsxs2(Box2, { children: [
219
- /* @__PURE__ */ jsx2(Text2, { color: "cyan", bold: true, children: "ephem" }),
220
- /* @__PURE__ */ jsx2(Text2, { color: "gray", children: " \xB7 " }),
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
- "\u8F93\u5165\u6D88\u606F\u56DE\u8F66\u53D1\u9001 \xB7 Ctrl+C \u9000\u51FA",
237
- closing ? ` \xB7 ${closing}` : ""
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(Box2, { flexDirection: "column", flexGrow: 1, marginTop: 1, children: lines.slice(-visible).map(
241
- (l) => l.kind === "system" ? /* @__PURE__ */ jsxs2(Text2, { color: "yellow", children: [
242
- " ",
243
- l.text
244
- ] }, l.id) : /* @__PURE__ */ jsxs2(Box2, { children: [
245
- /* @__PURE__ */ jsxs2(Text2, { color: "gray", children: [
246
- l.time,
247
- " "
248
- ] }),
249
- /* @__PURE__ */ jsx2(Text2, { color: l.self ? "cyan" : "white", bold: true, children: l.from }),
250
- /* @__PURE__ */ jsxs2(Text2, { color: l.self ? "cyan" : "white", children: [
251
- ": ",
252
- l.text
253
- ] })
254
- ] }, l.id)
255
- ) }),
256
- /* @__PURE__ */ jsxs2(Box2, { marginTop: 1, children: [
257
- /* @__PURE__ */ jsx2(Text2, { color: "cyan", children: "> " }),
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: handleSend,
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
- useInput((input, key) => {
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 &lt;路径&gt; 发送小于 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 &lt;路径&gt; 发图 · 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"]}
@@ -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(r.status === 200 && !!room.roomCode, `房间已创建:${room.roomCode}`);
132
+ assert(!!room.roomCode, `房间已创建:${room.roomCode}`);
107
133
  const key = deriveKey(room.roomCode);
108
134
 
109
135
  // ── 状态查询(需鉴权)────────────────
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "ephem-cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "临时、端到端加密的命令行聊天室 CLI",
5
5
  "type": "module",
6
6
  "bin": {
7
- "ephem": "./dist/index.js"
7
+ "ephem": "dist/index.js"
8
8
  },
9
9
  "scripts": {
10
10
  "build": "tsup",
@@ -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
+ }
@@ -1,9 +1,21 @@
1
1
  import React, { useEffect, useReducer, useRef, useState } from "react";
2
- import { Box, Text, useApp, useStdout } from "ink";
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: "msg"; from: string; text: string; self: boolean; time: string };
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 addMsg = (from: string, text: string, self: boolean) =>
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: "msg", from, text, self, time: nowStr() },
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 text = decrypt(roomKey.current, { ciphertext: msg.ciphertext, nonce: msg.nonce });
64
- addMsg(msg.from, text, false);
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
- function handleSend(text: string) {
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
- addMsg(username, t, true);
168
+ addText(username, t, true);
119
169
  } catch {
120
- addSystem("发送失败:加密出错");
170
+ addSystem("发送失败:加密出错", "error");
121
171
  }
122
172
  setInput("");
123
173
  }
124
174
 
125
- // 可视区域:留出 header(2) + 输入区(3) 的空间
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 visible = Math.max(4, rows - 6);
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
- {/* Header */}
134
- <Box flexDirection="column">
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 &lt;路径&gt; 发图 · Ctrl+L 清屏 · Ctrl+C 退出{closing ? ` · ${closing}` : ""}
242
+ </Text>
150
243
 
151
- {/* 消息列表 */}
152
- <Box flexDirection="column" flexGrow={1} marginTop={1}>
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="yellow">
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
- <Box key={l.id}>
161
- <Text color="gray">{l.time} </Text>
162
- <Text color={l.self ? "cyan" : "white"} bold>
163
- {l.from}
164
- </Text>
165
- <Text color={l.self ? "cyan" : "white"}>: {l.text}</Text>
166
- </Box>
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
- <Box marginTop={1}>
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={handleSend}
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
  }
@@ -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 color="cyan" bold>
50
- ephem · 临时加密聊天室
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 退出</Text>
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 ? "?" : "·"} {label}
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 &lt;路径&gt; 发送小于 1 MiB 的图片。</Text>
97
+ </Box>
98
+ )}
80
99
  </Box>
81
100
  );
82
101
  }