ciphermesh 1.0.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.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +251 -0
  3. package/README.pt-BR.md +253 -0
  4. package/bin/ciphermesh.js +34 -0
  5. package/docs/ARCHITECTURE.md +1188 -0
  6. package/docs/SETUP.md +305 -0
  7. package/docs/demo.svg +46 -0
  8. package/package.json +87 -0
  9. package/src/client/ChatController.js +2476 -0
  10. package/src/client/Connection.js +129 -0
  11. package/src/client/FileTransfer.js +488 -0
  12. package/src/client/ImagePreview.js +88 -0
  13. package/src/client/UI.js +1830 -0
  14. package/src/client/index.js +231 -0
  15. package/src/crypto/CertPinStore.js +79 -0
  16. package/src/crypto/DeniableEncrypt.js +53 -0
  17. package/src/crypto/DoubleRatchet.js +574 -0
  18. package/src/crypto/Handshake.js +219 -0
  19. package/src/crypto/HistoryStore.js +241 -0
  20. package/src/crypto/IdentityBackup.js +70 -0
  21. package/src/crypto/KeyManager.js +134 -0
  22. package/src/crypto/MessageCrypto.js +181 -0
  23. package/src/crypto/NonceManager.js +72 -0
  24. package/src/crypto/SealedSender.js +58 -0
  25. package/src/crypto/SenderKey.js +204 -0
  26. package/src/crypto/StateManager.js +138 -0
  27. package/src/crypto/TrustStore.js +216 -0
  28. package/src/p2p/Discovery.js +80 -0
  29. package/src/p2p/P2PChatController.js +1856 -0
  30. package/src/p2p/PeerConnectionManager.js +252 -0
  31. package/src/p2p/PeerServer.js +68 -0
  32. package/src/p2p/index.js +219 -0
  33. package/src/protocol/messages.js +138 -0
  34. package/src/protocol/validators.js +175 -0
  35. package/src/server/CertManager.js +173 -0
  36. package/src/server/MessageRouter.js +80 -0
  37. package/src/server/OfflineQueue.js +124 -0
  38. package/src/server/SessionManager.js +296 -0
  39. package/src/server/WebSocketServer.js +632 -0
  40. package/src/server/index.js +89 -0
  41. package/src/shared/AuditLog.js +91 -0
  42. package/src/shared/PluginManager.js +83 -0
  43. package/src/shared/banner.js +271 -0
  44. package/src/shared/commandSuggest.js +59 -0
  45. package/src/shared/config.js +90 -0
  46. package/src/shared/constants.js +126 -0
  47. package/src/shared/coverTraffic.js +34 -0
  48. package/src/shared/dnd.js +60 -0
  49. package/src/shared/emoji.js +17 -0
  50. package/src/shared/fuzzy.js +40 -0
  51. package/src/shared/invite.js +61 -0
  52. package/src/shared/keyArt.js +66 -0
  53. package/src/shared/logger.js +38 -0
  54. package/src/shared/panic.js +38 -0
  55. package/src/shared/prompt.js +31 -0
  56. package/src/shared/terminalGraphics.js +72 -0
  57. package/src/shared/themes.js +36 -0
  58. package/src/shared/voiceNote.js +128 -0
@@ -0,0 +1,129 @@
1
+ import WebSocket from 'ws';
2
+ import { EventEmitter } from 'node:events';
3
+ import { RECONNECT_BASE_MS, RECONNECT_MAX_MS } from '../shared/constants.js';
4
+ import { CertPinStore, PinResult } from '../crypto/CertPinStore.js';
5
+
6
+ export class Connection extends EventEmitter {
7
+ #url;
8
+ #ws;
9
+ #reconnectDelay;
10
+ #shouldReconnect;
11
+ #connected;
12
+ #pinStore;
13
+ #host;
14
+
15
+ constructor(url) {
16
+ super();
17
+ this.#url = url;
18
+ this.#reconnectDelay = RECONNECT_BASE_MS;
19
+ this.#shouldReconnect = true;
20
+ this.#connected = false;
21
+ this.#pinStore = new CertPinStore();
22
+ try {
23
+ this.#host = new URL(url).host;
24
+ } catch {
25
+ this.#host = url;
26
+ }
27
+ }
28
+
29
+ // Trust-on-first-use pin of the server TLS certificate. Emits 'cert-pinned'
30
+ // on first sight and 'cert-mismatch' if it later changes (possible MITM).
31
+ #checkCertPin() {
32
+ if (!this.#url.startsWith('wss://')) {
33
+ return;
34
+ }
35
+ const socket = this.#ws?._socket;
36
+ const cert = socket?.getPeerCertificate?.();
37
+ const fingerprint = cert?.fingerprint256 || null;
38
+ const result = this.#pinStore.check(this.#host, fingerprint);
39
+ if (result === PinResult.PINNED) {
40
+ this.emit('cert-pinned', { host: this.#host, fingerprint });
41
+ } else if (result === PinResult.MISMATCH) {
42
+ this.emit('cert-mismatch', {
43
+ host: this.#host,
44
+ expected: this.#pinStore.getPinned(this.#host),
45
+ got: fingerprint,
46
+ });
47
+ }
48
+ }
49
+
50
+ connect() {
51
+ this.#shouldReconnect = true;
52
+ this.#createSocket();
53
+ }
54
+
55
+ #createSocket() {
56
+ const opts = this.#url.startsWith('wss://') ? { rejectUnauthorized: false } : {};
57
+ this.#ws = new WebSocket(this.#url, opts);
58
+
59
+ this.#ws.on('open', () => {
60
+ this.#connected = true;
61
+ this.#reconnectDelay = RECONNECT_BASE_MS;
62
+ this.#checkCertPin();
63
+ this.emit('connected');
64
+ });
65
+
66
+ this.#ws.on('message', (data) => {
67
+ try {
68
+ const msg = JSON.parse(data.toString('utf-8'));
69
+ this.emit('message', msg);
70
+ } catch {
71
+ // Ignore malformed messages
72
+ }
73
+ });
74
+
75
+ this.#ws.on('close', () => {
76
+ const wasConnected = this.#connected;
77
+ this.#connected = false;
78
+
79
+ if (wasConnected) {
80
+ this.emit('disconnected');
81
+ }
82
+
83
+ if (this.#shouldReconnect) {
84
+ this.#scheduleReconnect();
85
+ }
86
+ });
87
+
88
+ this.#ws.on('error', () => {
89
+ // Error is followed by 'close', reconnect handled there
90
+ });
91
+
92
+ this.#ws.on('ping', () => {
93
+ this.#ws.pong();
94
+ });
95
+ }
96
+
97
+ #scheduleReconnect() {
98
+ setTimeout(() => {
99
+ if (this.#shouldReconnect) {
100
+ this.emit('reconnecting', this.#reconnectDelay);
101
+ this.#createSocket();
102
+ this.#reconnectDelay = Math.min(this.#reconnectDelay * 2, RECONNECT_MAX_MS);
103
+ }
104
+ }, this.#reconnectDelay);
105
+ }
106
+
107
+ send(msg) {
108
+ if (this.#connected && this.#ws.readyState === WebSocket.OPEN) {
109
+ this.#ws.send(JSON.stringify(msg));
110
+ return true;
111
+ }
112
+ return false;
113
+ }
114
+
115
+ get connected() {
116
+ return this.#connected;
117
+ }
118
+
119
+ get url() {
120
+ return this.#url;
121
+ }
122
+
123
+ close() {
124
+ this.#shouldReconnect = false;
125
+ if (this.#ws) {
126
+ this.#ws.close();
127
+ }
128
+ }
129
+ }
@@ -0,0 +1,488 @@
1
+ import { createReadStream, existsSync, mkdirSync, statSync } from 'node:fs';
2
+ import { writeFile } from 'node:fs/promises';
3
+ import { basename, resolve, join } from 'node:path';
4
+ import { createHash, randomFillSync } from 'node:crypto';
5
+ import { MAX_FILE_SIZE, FILE_CHUNK_SIZE } from '../shared/constants.js';
6
+
7
+ const TRANSFER_TIMEOUT_MS = 30_000;
8
+ const SEND_INTERVAL_MS = 40; // ~25 chunks/sec
9
+ const RESUME_KEEP_MS = 5 * 60_000; // keep partials and resend cache for 5 min
10
+ const MAX_RESUME_ATTEMPTS = 3;
11
+ const MAX_RESEND_BATCH = 300;
12
+
13
+ export class FileTransfer {
14
+ #outgoing; // Map<transferId, { interval, resolve, chunks, skip }>
15
+ #sentCache; // Map<transferId, { chunks, timer }> — to resend lost chunks
16
+ #incoming; // Map<transferId, { fileName, fileSize, totalChunks, chunks, sha256, from, timer, attempts }>
17
+ #partials; // Map<sha256, { chunks, received, fileSize, totalChunks, timer }>
18
+ #downloadDir;
19
+ #transferTimeoutMs;
20
+ #resumeKeepMs;
21
+ #acceptTimeoutMs;
22
+
23
+ constructor(options = {}) {
24
+ this.#outgoing = new Map();
25
+ this.#sentCache = new Map();
26
+ this.#incoming = new Map();
27
+ this.#partials = new Map();
28
+ this.#downloadDir = resolve(options.downloadDir || './downloads');
29
+ this.#transferTimeoutMs = options.transferTimeoutMs || TRANSFER_TIMEOUT_MS;
30
+ this.#resumeKeepMs = options.resumeKeepMs || RESUME_KEEP_MS;
31
+ this.#acceptTimeoutMs = options.acceptTimeoutMs || 60_000;
32
+ if (!existsSync(this.#downloadDir)) {
33
+ mkdirSync(this.#downloadDir, { recursive: true });
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Send a file to all peers.
39
+ * @param {string} filePath - Absolute or relative path to file
40
+ * @param {Function} broadcastFn - (payloadObj) => void, broadcasts encrypted payload
41
+ * @param {object} callbacks - { onProgress(percent, text), onError(text), onComplete(text) }
42
+ */
43
+ async initSend(filePath, broadcastFn, callbacks) {
44
+ const absPath = resolve(filePath);
45
+
46
+ if (!existsSync(absPath)) {
47
+ callbacks.onError(`File not found: ${filePath}`);
48
+ return;
49
+ }
50
+
51
+ const stat = statSync(absPath);
52
+ if (!stat.isFile()) {
53
+ callbacks.onError('Path is not a file');
54
+ return;
55
+ }
56
+
57
+ if (stat.size > MAX_FILE_SIZE) {
58
+ callbacks.onError(`File too large (${(stat.size / 1024 / 1024).toFixed(1)}MB). Max: 50MB`);
59
+ return;
60
+ }
61
+
62
+ if (stat.size === 0) {
63
+ callbacks.onError('Empty file');
64
+ return;
65
+ }
66
+
67
+ const fileName = basename(absPath);
68
+ const totalChunks = Math.ceil(stat.size / FILE_CHUNK_SIZE);
69
+ const transferId = Math.random().toString(36).slice(2, 10);
70
+
71
+ // Compute SHA-256
72
+ const sha256 = await this.#computeSHA256(absPath);
73
+
74
+ // Send file_offer
75
+ broadcastFn({
76
+ action: 'file_offer',
77
+ transferId,
78
+ fileName,
79
+ fileSize: stat.size,
80
+ totalChunks,
81
+ sha256,
82
+ });
83
+
84
+ callbacks.onProgress(0, `Waiting for the recipient to accept ${fileName}...`);
85
+
86
+ // Read the chunks now, but WAIT for the receiver to accept before streaming
87
+ // (so files are never pushed without consent).
88
+ const chunks = await this.#readChunks(absPath);
89
+
90
+ return new Promise((resolveP) => {
91
+ const acceptTimer = setTimeout(() => {
92
+ this.#outgoing.delete(transferId);
93
+ callbacks.onError(`${fileName}: offer was not accepted in time`);
94
+ resolveP();
95
+ }, this.#acceptTimeoutMs);
96
+ if (acceptTimer.unref) {
97
+ acceptTimer.unref();
98
+ }
99
+
100
+ this.#outgoing.set(transferId, {
101
+ interval: null,
102
+ resolve: resolveP,
103
+ chunks,
104
+ skip: new Set(),
105
+ pending: true,
106
+ acceptTimer,
107
+ broadcastFn,
108
+ callbacks,
109
+ fileName,
110
+ totalChunks,
111
+ });
112
+ });
113
+ }
114
+
115
+ /** Receiver accepted the offer — start streaming (honouring resume `have`). */
116
+ handleFileAccept(fromSessionId, data) {
117
+ const transfer = this.#outgoing.get(data.transferId);
118
+ if (!transfer || !transfer.pending) {
119
+ return;
120
+ }
121
+ transfer.pending = false;
122
+ clearTimeout(transfer.acceptTimer);
123
+ if (Array.isArray(data.have)) {
124
+ for (const i of data.have) {
125
+ if (Number.isInteger(i) && i >= 0) {
126
+ transfer.skip.add(i);
127
+ }
128
+ }
129
+ }
130
+ this.#beginStreaming(data.transferId);
131
+ }
132
+
133
+ /** Receiver rejected the offer — abort the pending transfer. */
134
+ handleFileReject(fromSessionId, data) {
135
+ const transfer = this.#outgoing.get(data.transferId);
136
+ if (!transfer) {
137
+ return;
138
+ }
139
+ clearTimeout(transfer.acceptTimer);
140
+ if (transfer.interval) {
141
+ clearInterval(transfer.interval);
142
+ }
143
+ this.#outgoing.delete(data.transferId);
144
+ transfer.callbacks.onError(`${transfer.fileName}: rejected by the recipient`);
145
+ transfer.resolve();
146
+ }
147
+
148
+ #beginStreaming(transferId) {
149
+ const transfer = this.#outgoing.get(transferId);
150
+ if (!transfer) {
151
+ return;
152
+ }
153
+ const { chunks, skip, broadcastFn, callbacks, fileName, totalChunks, resolve } = transfer;
154
+ let chunkIndex = 0;
155
+
156
+ const interval = setInterval(() => {
157
+ try {
158
+ while (chunkIndex < chunks.length && skip.has(chunkIndex)) {
159
+ chunkIndex++;
160
+ }
161
+
162
+ if (chunkIndex >= chunks.length) {
163
+ clearInterval(interval);
164
+ this.#outgoing.delete(transferId);
165
+ this.#cacheSent(transferId, chunks);
166
+
167
+ broadcastFn({ action: 'file_complete', transferId });
168
+ callbacks.onComplete(`${fileName} sent successfully`);
169
+ resolve();
170
+ return;
171
+ }
172
+
173
+ broadcastFn({
174
+ action: 'file_chunk',
175
+ transferId,
176
+ chunkIndex,
177
+ data: chunks[chunkIndex].toString('base64'),
178
+ });
179
+
180
+ chunkIndex++;
181
+ const percent = Math.round((chunkIndex / totalChunks) * 100);
182
+ callbacks.onProgress(percent, `Sending ${fileName}`);
183
+ } catch (e) {
184
+ // A send/crypto failure must not crash the whole client — abort this
185
+ // transfer and report it.
186
+ clearInterval(interval);
187
+ this.#outgoing.delete(transferId);
188
+ callbacks.onError(`Failed to send ${fileName}: ${e.message}`);
189
+ resolve();
190
+ }
191
+ }, SEND_INTERVAL_MS);
192
+
193
+ transfer.interval = interval;
194
+ }
195
+
196
+ // Keep chunks after sending so we can answer file_resume_request
197
+ #cacheSent(transferId, chunks) {
198
+ const old = this.#sentCache.get(transferId);
199
+ if (old) {
200
+ clearTimeout(old.timer);
201
+ }
202
+ this.#sentCache.set(transferId, {
203
+ chunks,
204
+ timer: setTimeout(() => this.#sentCache.delete(transferId), this.#resumeKeepMs),
205
+ });
206
+ }
207
+
208
+ /**
209
+ * Receiver reported which chunks it already has (resume after re-offer) — skip them when sending.
210
+ */
211
+ handleFileHave(fromSessionId, data) {
212
+ const transfer = this.#outgoing.get(data.transferId);
213
+ if (!transfer || !Array.isArray(data.have)) {
214
+ return;
215
+ }
216
+ for (const i of data.have) {
217
+ if (Number.isInteger(i) && i >= 0) {
218
+ transfer.skip.add(i);
219
+ }
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Chunks requested in a file_resume_request, from the active send or the cache.
225
+ * @returns {Array<{index: number, data: string}>|null}
226
+ */
227
+ getChunksForResend(transferId, missing) {
228
+ const source = this.#sentCache.get(transferId) || this.#outgoing.get(transferId);
229
+ if (!source || !Array.isArray(missing)) {
230
+ return null;
231
+ }
232
+ return missing
233
+ .filter((i) => Number.isInteger(i) && i >= 0 && i < source.chunks.length)
234
+ .slice(0, MAX_RESEND_BATCH)
235
+ .map((i) => ({ index: i, data: source.chunks[i].toString('base64') }));
236
+ }
237
+
238
+ /**
239
+ * Handle incoming file_offer action.
240
+ * @returns {{ message: string, have: number[] }}
241
+ */
242
+ handleFileOffer(fromSessionId, data, peerNickname) {
243
+ const { transferId, fileName, fileSize, totalChunks, sha256 } = data;
244
+
245
+ // Clear any existing transfer with same id
246
+ this.#clearIncoming(transferId);
247
+
248
+ // Resume: is there a stored partial of the same file (same SHA-256)?
249
+ let chunks = new Array(totalChunks).fill(null);
250
+ let received = 0;
251
+ let have = [];
252
+ const partial = this.#partials.get(sha256);
253
+ if (partial && partial.fileSize === fileSize && partial.totalChunks === totalChunks) {
254
+ clearTimeout(partial.timer);
255
+ this.#partials.delete(sha256);
256
+ chunks = partial.chunks;
257
+ received = partial.received;
258
+ have = [];
259
+ for (let i = 0; i < chunks.length; i++) {
260
+ if (chunks[i]) {
261
+ have.push(i);
262
+ }
263
+ }
264
+ }
265
+
266
+ const timer = setTimeout(() => {
267
+ this.#stashPartial(transferId);
268
+ }, this.#transferTimeoutMs);
269
+
270
+ this.#incoming.set(transferId, {
271
+ fileName,
272
+ fileSize,
273
+ totalChunks,
274
+ sha256,
275
+ from: fromSessionId,
276
+ peerNickname,
277
+ chunks,
278
+ received,
279
+ attempts: 0,
280
+ timer,
281
+ });
282
+
283
+ const resumeNote = have.length ? ` — resuming (${have.length}/${totalChunks} chunks)` : '';
284
+ return {
285
+ message: `${peerNickname} sending ${fileName} (${(fileSize / 1024).toFixed(0)}KB)${resumeNote}`,
286
+ have,
287
+ };
288
+ }
289
+
290
+ /**
291
+ * Handle incoming file_chunk action.
292
+ * @returns {{ percent: number, text: string } | null}
293
+ */
294
+ handleFileChunk(fromSessionId, data) {
295
+ const transfer = this.#incoming.get(data.transferId);
296
+ if (!transfer || transfer.from !== fromSessionId) {
297
+ return null;
298
+ }
299
+
300
+ const { chunkIndex } = data;
301
+ if (chunkIndex < 0 || chunkIndex >= transfer.totalChunks) {
302
+ return null;
303
+ }
304
+
305
+ if (!transfer.chunks[chunkIndex]) {
306
+ transfer.chunks[chunkIndex] = Buffer.from(data.data, 'base64');
307
+ transfer.received++;
308
+ }
309
+
310
+ // Reset timeout
311
+ clearTimeout(transfer.timer);
312
+ transfer.timer = setTimeout(() => {
313
+ this.#stashPartial(data.transferId);
314
+ }, this.#transferTimeoutMs);
315
+
316
+ const percent = Math.round((transfer.received / transfer.totalChunks) * 100);
317
+ return { percent, text: `Receiving ${transfer.fileName}` };
318
+ }
319
+
320
+ /**
321
+ * Handle incoming file_complete action.
322
+ * @returns {{ success: boolean, message: string, savePath?: string, resume?: boolean, missing?: number[] }}
323
+ */
324
+ async handleFileComplete(fromSessionId, data) {
325
+ const transfer = this.#incoming.get(data.transferId);
326
+ if (!transfer || transfer.from !== fromSessionId) {
327
+ return { success: false, message: 'Unknown transfer' };
328
+ }
329
+
330
+ clearTimeout(transfer.timer);
331
+
332
+ // Missing chunks — request a resend instead of discarding everything
333
+ const missing = [];
334
+ for (let i = 0; i < transfer.totalChunks; i++) {
335
+ if (!transfer.chunks[i]) {
336
+ missing.push(i);
337
+ }
338
+ }
339
+
340
+ if (missing.length > 0) {
341
+ transfer.attempts++;
342
+ if (transfer.attempts > MAX_RESUME_ATTEMPTS) {
343
+ this.#incoming.delete(data.transferId);
344
+ return {
345
+ success: false,
346
+ message: `${transfer.fileName}: chunks missing after ${MAX_RESUME_ATTEMPTS} attempts (${transfer.received}/${transfer.totalChunks})`,
347
+ };
348
+ }
349
+ transfer.timer = setTimeout(() => {
350
+ this.#stashPartial(data.transferId);
351
+ }, this.#transferTimeoutMs);
352
+ return {
353
+ success: false,
354
+ resume: true,
355
+ missing: missing.slice(0, MAX_RESEND_BATCH),
356
+ message: `${transfer.fileName}: ${missing.length} chunk(s) missing — requesting resend (attempt ${transfer.attempts}/${MAX_RESUME_ATTEMPTS})`,
357
+ };
358
+ }
359
+
360
+ // Reassemble — trim any last-chunk padding back to the real file size
361
+ // (all chunks are padded to a uniform size on the wire to hide file size).
362
+ const reassembled = Buffer.concat(transfer.chunks);
363
+ const fullData = Number.isInteger(transfer.fileSize)
364
+ ? reassembled.subarray(0, transfer.fileSize)
365
+ : reassembled;
366
+
367
+ // Verify SHA-256
368
+ const hash = createHash('sha256').update(fullData).digest('hex');
369
+ if (hash !== transfer.sha256) {
370
+ this.#incoming.delete(data.transferId);
371
+ return { success: false, message: `SHA-256 mismatch for ${transfer.fileName}` };
372
+ }
373
+
374
+ // Save to downloads
375
+ const savePath = this.#getSafePath(transfer.fileName);
376
+ await writeFile(savePath, fullData);
377
+
378
+ this.#incoming.delete(data.transferId);
379
+ return { success: true, message: `${transfer.fileName} saved to ${savePath}`, savePath };
380
+ }
381
+
382
+ // Transfer died mid-way — stash the partial indexed by SHA-256
383
+ // so it can resume if the same file is offered again
384
+ #stashPartial(transferId) {
385
+ const transfer = this.#incoming.get(transferId);
386
+ if (!transfer) {
387
+ return;
388
+ }
389
+ clearTimeout(transfer.timer);
390
+ this.#incoming.delete(transferId);
391
+
392
+ if (transfer.received === 0 || !transfer.sha256) {
393
+ return;
394
+ }
395
+
396
+ const old = this.#partials.get(transfer.sha256);
397
+ if (old) {
398
+ clearTimeout(old.timer);
399
+ }
400
+ this.#partials.set(transfer.sha256, {
401
+ chunks: transfer.chunks,
402
+ received: transfer.received,
403
+ fileSize: transfer.fileSize,
404
+ totalChunks: transfer.totalChunks,
405
+ timer: setTimeout(() => this.#partials.delete(transfer.sha256), this.#resumeKeepMs),
406
+ });
407
+ }
408
+
409
+ #clearIncoming(transferId) {
410
+ const transfer = this.#incoming.get(transferId);
411
+ if (transfer) {
412
+ clearTimeout(transfer.timer);
413
+ this.#incoming.delete(transferId);
414
+ }
415
+ }
416
+
417
+ #getSafePath(fileName) {
418
+ // Sanitize filename
419
+ const safe = fileName.replace(/[<>:"/\\|?*]/g, '_');
420
+ let savePath = join(this.#downloadDir, safe);
421
+
422
+ // Avoid overwrite — append (1), (2), etc.
423
+ if (existsSync(savePath)) {
424
+ const dot = safe.lastIndexOf('.');
425
+ const name = dot > 0 ? safe.slice(0, dot) : safe;
426
+ const ext = dot > 0 ? safe.slice(dot) : '';
427
+ let i = 1;
428
+ do {
429
+ savePath = join(this.#downloadDir, `${name} (${i})${ext}`);
430
+ i++;
431
+ } while (existsSync(savePath));
432
+ }
433
+
434
+ return savePath;
435
+ }
436
+
437
+ async #readChunks(filePath) {
438
+ return new Promise((resolve, reject) => {
439
+ const chunks = [];
440
+ const stream = createReadStream(filePath, { highWaterMark: FILE_CHUNK_SIZE });
441
+ stream.on('data', (chunk) => chunks.push(chunk));
442
+ stream.on('end', () => {
443
+ // Pad the final (partial) chunk up to a full chunk so every chunk is the
444
+ // same size on the wire — the relay then can't read the exact file size,
445
+ // only its size rounded up to the chunk. The receiver truncates back to
446
+ // fileSize on reassembly.
447
+ const last = chunks[chunks.length - 1];
448
+ if (last && last.length < FILE_CHUNK_SIZE) {
449
+ const padded = Buffer.alloc(FILE_CHUNK_SIZE);
450
+ last.copy(padded);
451
+ randomFillSync(padded, last.length); // random tail, not compressible zeros
452
+ chunks[chunks.length - 1] = padded;
453
+ }
454
+ resolve(chunks);
455
+ });
456
+ stream.on('error', reject);
457
+ });
458
+ }
459
+
460
+ async #computeSHA256(filePath) {
461
+ return new Promise((resolve, reject) => {
462
+ const hash = createHash('sha256');
463
+ const stream = createReadStream(filePath);
464
+ stream.on('data', (chunk) => hash.update(chunk));
465
+ stream.on('end', () => resolve(hash.digest('hex')));
466
+ stream.on('error', reject);
467
+ });
468
+ }
469
+
470
+ destroy() {
471
+ for (const [, entry] of this.#outgoing) {
472
+ clearInterval(entry.interval);
473
+ }
474
+ for (const [, entry] of this.#incoming) {
475
+ clearTimeout(entry.timer);
476
+ }
477
+ for (const [, entry] of this.#sentCache) {
478
+ clearTimeout(entry.timer);
479
+ }
480
+ for (const [, entry] of this.#partials) {
481
+ clearTimeout(entry.timer);
482
+ }
483
+ this.#outgoing.clear();
484
+ this.#incoming.clear();
485
+ this.#sentCache.clear();
486
+ this.#partials.clear();
487
+ }
488
+ }
@@ -0,0 +1,88 @@
1
+ import { extname } from 'node:path';
2
+ import { readFileSync } from 'node:fs';
3
+ import { Jimp } from 'jimp';
4
+
5
+ const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.bmp']);
6
+ const MAX_PREVIEW_HEIGHT = 96; // pixels (2 per terminal row)
7
+ const INLINE_MAX_WIDTH = 1000; // pixels — cap for full-resolution inline render
8
+
9
+ // Fit the half-block preview to the terminal (leaving room for the border),
10
+ // with a sane cap.
11
+ function previewWidth() {
12
+ const cols = process.stdout.columns || 80;
13
+ return Math.max(24, Math.min(64, cols - 6));
14
+ }
15
+
16
+ export function isImageFile(filePath) {
17
+ return IMAGE_EXTENSIONS.has(extname(filePath).toLowerCase());
18
+ }
19
+
20
+ /**
21
+ * Load an image as { raw, png } buffers for inline (kitty/iTerm) rendering.
22
+ * Large images are downscaled so the escape stays reasonable.
23
+ * @param {string} filePath
24
+ * @returns {Promise<{ raw: Buffer, png: Buffer }>}
25
+ */
26
+ export async function loadImageBuffers(filePath) {
27
+ const raw = readFileSync(filePath);
28
+ const image = await Jimp.read(filePath);
29
+ if (image.width > INLINE_MAX_WIDTH) {
30
+ image.resize({ w: INLINE_MAX_WIDTH });
31
+ }
32
+ const png = await image.getBuffer('image/png');
33
+ return { raw, png };
34
+ }
35
+
36
+ /**
37
+ * Render an image as terminal half-blocks with blessed color tags.
38
+ * Each cell packs two vertical pixels: '▀' with fg = top pixel and
39
+ * bg = bottom pixel. Returns one string per terminal row.
40
+ * @param {string} filePath
41
+ * @param {number} [maxWidth] - max columns
42
+ * @returns {Promise<string[]>}
43
+ */
44
+ export async function renderImagePreview(filePath, maxWidth = previewWidth()) {
45
+ const image = await Jimp.read(filePath);
46
+
47
+ if (image.width > maxWidth) {
48
+ image.resize({ w: maxWidth });
49
+ }
50
+ if (image.height > MAX_PREVIEW_HEIGHT) {
51
+ image.resize({ h: MAX_PREVIEW_HEIGHT });
52
+ }
53
+
54
+ const { width, height } = image;
55
+ const { data } = image.bitmap;
56
+
57
+ const px = (x, y) => {
58
+ const i = (y * width + x) * 4;
59
+ if (data[i + 3] < 128) {
60
+ return null; // transparent
61
+ }
62
+ return `#${data[i].toString(16).padStart(2, '0')}${data[i + 1]
63
+ .toString(16)
64
+ .padStart(2, '0')}${data[i + 2].toString(16).padStart(2, '0')}`;
65
+ };
66
+
67
+ const lines = [];
68
+ for (let y = 0; y < height; y += 2) {
69
+ let line = '';
70
+ for (let x = 0; x < width; x++) {
71
+ const top = px(x, y);
72
+ const bottom = y + 1 < height ? px(x, y + 1) : null;
73
+
74
+ if (!top && !bottom) {
75
+ line += ' ';
76
+ } else if (top && bottom) {
77
+ line += `{${top}-fg}{${bottom}-bg}▀{/${bottom}-bg}{/${top}-fg}`;
78
+ } else if (top) {
79
+ line += `{${top}-fg}▀{/${top}-fg}`;
80
+ } else {
81
+ line += `{${bottom}-fg}▄{/${bottom}-fg}`;
82
+ }
83
+ }
84
+ lines.push(line);
85
+ }
86
+
87
+ return lines;
88
+ }