peer-term 1.3.9 → 1.4.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/src/index.js CHANGED
@@ -19,18 +19,8 @@
19
19
  * --version Print version number
20
20
  */
21
21
 
22
- import * as dotenv from 'dotenv';
23
- import os from 'os';
24
- import fs from 'fs';
25
- import crypto from 'crypto';
26
- import path from 'path';
27
- import net from 'net';
28
- import readline from 'readline';
29
- import { execFile, spawn } from 'child_process';
30
- import { fileURLToPath } from 'url';
31
22
  import WebSocket from 'ws';
32
23
  import pty from 'node-pty';
33
- import minimist from 'minimist';
34
24
  import {
35
25
  generateKeyPair,
36
26
  exportPublicKey,
@@ -42,265 +32,17 @@ import {
42
32
  } from './crypto.js';
43
33
  import { HostWebRTC } from './webrtc.js';
44
34
  import logger from './logger.js';
45
- import { printBanner, printSessionBox, printHelp, printVersion } from './ui.js';
46
-
47
- // ─── File paths ──────────────────────────────────────────────────────────────
48
- const __filename = fileURLToPath(import.meta.url);
49
- const __dirname = path.dirname(__filename);
50
-
51
- // Load .env from host/ directory (parent of src/)
52
- dotenv.config({ path: path.join(__dirname, '..', '.env') });
53
-
54
- // Load package.json for version information
55
- let version = 'unknown';
56
- try {
57
- const pkgPath = path.join(__dirname, '..', 'package.json');
58
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
59
- version = pkg.version;
60
- } catch {}
61
-
62
- // ─── CLI Argument Parsing ────────────────────────────────────────────────────
63
-
64
- const argv = minimist(process.argv.slice(2), {
65
- boolean: ['readonly', 'verbose', 'help', 'version', 'secure'],
66
- string: ['expiry', 'rejoin', 'relay', 'path'],
67
- alias: { h: 'help', v: 'version', V: 'verbose' },
68
- });
69
-
70
- // Detect if --expiry or --rejoin were explicitly passed on the command line
71
- const expiryFlagPassed = process.argv.some(a => a === '--expiry' || a.startsWith('--expiry='));
72
- const rejoinFlagPassed = process.argv.some(a => a === '--rejoin' || a.startsWith('--rejoin='));
73
-
74
- // Handle --help
75
- if (argv.help) {
76
- printHelp();
77
- process.exit(0);
78
- }
79
-
80
- // Handle --version
81
- if (argv.version) {
82
- printVersion();
83
- process.exit(0);
84
- }
85
-
86
- // Enable verbose logging
87
- if (argv.verbose) {
88
- logger.setVerbose(true);
89
- }
90
-
91
- // ─── Configuration ───────────────────────────────────────────────────────────
92
- const DEFAULT_RELAYS = [
93
- 'wss://relay.dhananjaybalekar.in'
94
- ];
95
- const RELAY_URLS = argv.relay
96
- ? argv.relay.split(',').map(s => s.trim())
97
- : (process.env.RELAY_URL ? process.env.RELAY_URL.split(',').map(s => s.trim()) : DEFAULT_RELAYS);
98
- const HEARTBEAT_INTERVAL_MS = 5000;
99
- const MAX_MISSED_PINGS = 2;
100
-
101
- const UPLOAD_MAX_FILE_SIZE = 100 * 1024 * 1024; // 100 MB max
102
- const UPLOAD_MAX_CHUNKS_PER_FILE = Math.ceil(UPLOAD_MAX_FILE_SIZE / 16384);
103
- const UPLOAD_MAX_CONCURRENT = 3;
104
-
105
- // ─── Path Resolution ─────────────────────────────────────────────────────────
106
-
107
- function expandTilde(inputPath) {
108
- if (inputPath.startsWith('~/') || inputPath === '~') {
109
- return inputPath.replace(/^~/, process.env.HOME || process.env.USERPROFILE || '.');
110
- }
111
- return inputPath;
112
- }
113
-
114
- function resolveStartPath(inputPath) {
115
- if (!inputPath) return process.env.HOME || process.env.USERPROFILE || process.cwd();
116
-
117
- const resolved = path.resolve(expandTilde(inputPath));
118
-
119
- if (!fs.existsSync(resolved)) {
120
- logger.error(`Path does not exist: ${resolved}`);
121
- process.exit(1);
122
- }
123
-
124
- const stat = fs.statSync(resolved);
125
- if (!stat.isDirectory()) {
126
- logger.error(`Path is not a directory: ${resolved}`);
127
- process.exit(1);
128
- }
129
-
130
- return resolved;
131
- }
132
-
133
- // ─── Duration Parsing ────────────────────────────────────────────────────────
134
-
135
- function parseDuration(str) {
136
- const match = str.match(/^(\d+)(s|m|h)$/i);
137
- if (!match) return null;
138
- const value = parseInt(match[1], 10);
139
- const unit = match[2].toLowerCase();
140
- switch (unit) {
141
- case 's': return value * 1000;
142
- case 'm': return value * 60 * 1000;
143
- case 'h': return value * 60 * 60 * 1000;
144
- default: return null;
145
- }
146
- }
147
-
148
- function formatDuration(ms) {
149
- if (ms >= 3600000) return `${Math.round(ms / 3600000)} hour(s)`;
150
- if (ms >= 60000) return `${Math.round(ms / 60000)} minute(s)`;
151
- return `${Math.round(ms / 1000)} second(s)`;
152
- }
153
-
154
- function getExpiry() {
155
- const parsed = parseDuration(argv.expiry);
156
- if (!parsed) {
157
- logger.error(`Invalid expiry format: "${argv.expiry}". Use 30s, 5m, or 1h.`);
158
- process.exit(1);
159
- }
160
- return parsed;
161
- }
162
-
163
- // ─── Interactive Prompts ─────────────────────────────────────────────────────
164
-
165
- /**
166
- * Prompt the user for a duration value in the terminal.
167
- * Re-prompts on invalid input until a valid value is entered or Enter is pressed for default.
168
- *
169
- * @param {Object} opts
170
- * @param {string} opts.label - Main prompt question
171
- * @param {string} opts.description - Additional context shown below the question
172
- * @param {number} opts.defaultMs - Default value in milliseconds
173
- * @param {string} opts.defaultLabel - Human-readable default (e.g. "5 minutes")
174
- * @param {number} opts.minMs - Minimum allowed value in milliseconds
175
- * @param {number} opts.maxMs - Maximum allowed value in milliseconds
176
- * @param {string} opts.minLabel - Human-readable minimum (e.g. "2m")
177
- * @param {string} opts.maxLabel - Human-readable maximum (e.g. "24h")
178
- * @returns {Promise<number>} Resolved duration in milliseconds
179
- */
180
- function promptDuration({ label, description, defaultMs, defaultLabel, minMs, maxMs, minLabel, maxLabel }) {
181
- return new Promise((resolve) => {
182
- const rl = readline.createInterface({
183
- input: process.stdin,
184
- output: process.stdout,
185
- });
186
-
187
- const ask = () => {
188
- console.log('');
189
- console.log(` ${label}`);
190
- if (description) console.log(` ${description}`);
191
- console.log(` Enter a value between ${minLabel} and ${maxLabel} (e.g. ${minLabel}, 30m, 2h)`);
192
- console.log(` Default is ${defaultLabel}. Press Enter to use default.`);
193
- rl.question(' > ', (answer) => {
194
- const input = answer.trim();
195
-
196
- // Empty input — use default
197
- if (!input) {
198
- rl.close();
199
- resolve(defaultMs);
200
- return;
201
- }
202
-
203
- const parsed = parseDuration(input);
204
- if (!parsed || parsed < minMs || parsed > maxMs) {
205
- console.log(` Invalid value. Please enter a time between ${minLabel} and ${maxLabel} (e.g. 5m, 1h).`);
206
- ask();
207
- return;
208
- }
209
-
210
- rl.close();
211
- resolve(parsed);
212
- });
213
- };
214
-
215
- ask();
216
- });
217
- }
218
-
219
- /**
220
- * Get the session code expiry — from flag, interactive prompt, or silent default.
221
- */
222
- async function getExpiryInteractive() {
223
- const DEFAULT_EXPIRY = 5 * 60 * 1000; // 5 minutes
224
- const MIN_EXPIRY = 2 * 60 * 1000; // 2 minutes
225
- const MAX_EXPIRY = 24 * 60 * 60 * 1000; // 24 hours
226
-
227
- // If --expiry flag was explicitly passed, use it (validate and exit on error)
228
- if (expiryFlagPassed) {
229
- const parsed = parseDuration(argv.expiry);
230
- if (!parsed || parsed < MIN_EXPIRY || parsed > MAX_EXPIRY) {
231
- logger.error(`Invalid expiry: "${argv.expiry}". Must be between 2m and 24h.`);
232
- process.exit(1);
233
- }
234
- return parsed;
235
- }
236
-
237
- // Non-interactive — use default silently
238
- if (!process.stdin.isTTY) return DEFAULT_EXPIRY;
239
-
240
- // Interactive prompt
241
- return promptDuration({
242
- label: 'How long should the session code be valid?',
243
- description: null,
244
- defaultMs: DEFAULT_EXPIRY,
245
- defaultLabel: '5 minutes',
246
- minMs: MIN_EXPIRY,
247
- maxMs: MAX_EXPIRY,
248
- minLabel: '2m',
249
- maxLabel: '24h',
250
- });
251
- }
252
-
253
- /**
254
- * Get the reconnection window — from flag, interactive prompt, or silent default.
255
- */
256
- async function getRejoinInteractive() {
257
- const DEFAULT_REJOIN = 5 * 60 * 1000; // 5 minutes
258
- const MIN_REJOIN = 5 * 60 * 1000; // 5 minutes
259
- const MAX_REJOIN = 6 * 60 * 60 * 1000; // 6 hours
260
-
261
- // If --rejoin flag was explicitly passed, use it
262
- if (rejoinFlagPassed) {
263
- const parsed = parseDuration(argv.rejoin);
264
- if (!parsed || parsed < MIN_REJOIN || parsed > MAX_REJOIN) {
265
- logger.error(`Invalid rejoin window: "${argv.rejoin}". Must be between 5m and 6h.`);
266
- process.exit(1);
267
- }
268
- return parsed;
269
- }
270
-
271
- // Non-interactive — use default silently
272
- if (!process.stdin.isTTY) return DEFAULT_REJOIN;
273
-
274
- // Interactive prompt
275
- return promptDuration({
276
- label: 'How long should the reconnection window be?',
277
- description: 'This is how long a disconnected client or host has to rejoin before the session ends.',
278
- defaultMs: DEFAULT_REJOIN,
279
- defaultLabel: '2 minutes',
280
- minMs: MIN_REJOIN,
281
- maxMs: MAX_REJOIN,
282
- minLabel: '5m',
283
- maxLabel: '6h',
284
- });
285
- }
286
-
287
- // ─── Shell Detection ─────────────────────────────────────────────────────────
288
-
289
- function detectShell() {
290
- const platform = os.platform();
291
- if (process.env.SHELL) return process.env.SHELL;
292
- if (platform === 'win32') return process.env.COMSPEC || 'powershell.exe';
293
- try {
294
- const passwd = fs.readFileSync('/etc/passwd', 'utf-8');
295
- const username = os.userInfo().username;
296
- const line = passwd.split('\n').find((l) => l.startsWith(username + ':'));
297
- if (line) {
298
- const shell = line.split(':').pop().trim();
299
- if (shell) return shell;
300
- }
301
- } catch {}
302
- return platform === 'win32' ? 'powershell.exe' : 'bash';
303
- }
35
+ import { printSessionBox } from './ui.js';
36
+ import {
37
+ argv,
38
+ version,
39
+ RELAY_URLS,
40
+ HEARTBEAT_INTERVAL_MS,
41
+ MAX_MISSED_PINGS,
42
+ } from './config.js';
43
+ import { formatDuration } from './utils.js';
44
+ import { FileTransferHandler } from './file-transfer.js';
45
+ import { ViewerServer } from './viewer.js';
304
46
 
305
47
  // ─── Session Class ───────────────────────────────────────────────────────────
306
48
 
@@ -339,14 +81,31 @@ class Session {
339
81
  this.webrtc = null;
340
82
  this.useDataChannel = false;
341
83
 
342
- // File upload: in-progress file transfers from client
343
- // Shape: { [id]: { name, size, totalChunks, chunks: Map<index, Buffer> } }
344
- this._incomingFiles = {};
84
+ // File upload handler
85
+ this._fileTransfer = new FileTransferHandler(this._buildFileTransferContext());
86
+
87
+ // Local TCP viewer
88
+ this._viewer = null;
345
89
 
346
- // Local TCP viewer (new terminal window)
347
- this.viewerServer = null;
348
- this.viewerSocket = null;
349
- this.viewerToken = null; // Random nonce for viewer auth
90
+ // Session duration tracking
91
+ this.sessionStartedAt = null; // Timestamp: when the terminal session became active
92
+ this.sessionDurationInterval = null; // Interval: periodic CLI status log
93
+ }
94
+
95
+ /**
96
+ * Build the context object for FileTransferHandler.
97
+ * Called on construction and whenever shared key changes.
98
+ */
99
+ _buildFileTransferContext() {
100
+ return {
101
+ get sharedKey() { return this.session.sharedKey; },
102
+ get readOnly() { return this.session.readOnly; },
103
+ get ptyProcess() { return this.session.ptyProcess; },
104
+ log: (msg) => this.log(msg),
105
+ logDebug: (msg) => this.logDebug(msg),
106
+ sendEncrypted: (payload) => this._sendEncryptedToClient(payload),
107
+ session: this,
108
+ };
350
109
  }
351
110
 
352
111
  log(msg) {
@@ -458,6 +217,12 @@ class Session {
458
217
  this.log('Encrypted tunnel active');
459
218
  await this._sendSessionConfig();
460
219
 
220
+ // Mark session start time for duration tracking
221
+ if (!this.sessionStartedAt) {
222
+ this.sessionStartedAt = Date.now();
223
+ this._startDurationLogging();
224
+ }
225
+
461
226
  this.startHeartbeat();
462
227
 
463
228
  if (!this.ptyProcess) {
@@ -532,146 +297,7 @@ class Session {
532
297
  return;
533
298
  }
534
299
 
535
- switch (msg.type) {
536
- case 'code': {
537
- this.code = msg.code;
538
- this.hostToken = msg.hostToken || null;
539
- printSessionBox({
540
- code: this.code,
541
- expiry: formatDuration(this.expiryMs),
542
- rejoinWindow: formatDuration(this.rejoinMs),
543
- mode: (this.readOnly ? 'Read-Only' : 'Read-Write') + (this.secureMode ? ' (Secure)' : ''),
544
- shell: this.shell,
545
- startPath: this.startPath,
546
- shareUrl: url.replace(/^wss:\/\//i, 'https://').replace(/^ws:\/\//i, 'http://')
547
- });
548
- resolve(this.code);
549
- break;
550
- }
551
-
552
- case 'client-connected': {
553
- if (this.awaitingRejoin) {
554
- this.log('Client reconnected.');
555
- this.awaitingRejoin = false;
556
- } else {
557
- this.log('Client connected! Starting key exchange...');
558
- }
559
- this.isClientConnected = true;
560
- this.missedPings = 0;
561
-
562
- if (this.useDataChannel && this.webrtc && this.webrtc.isActive()) {
563
- this.startHeartbeat();
564
- break;
565
- }
566
-
567
- await this._beginKeyExchange();
568
- break;
569
- }
570
-
571
- case 'key-exchange': {
572
- await this._completeKeyExchange(msg.publicKey);
573
- break;
574
- }
575
-
576
- // Phase 4: WebRTC signaling messages
577
- case 'signal': {
578
- if (this.webrtc && this.sharedKey) {
579
- try {
580
- const plaintext = await decrypt(this.sharedKey, msg.payload);
581
- this.webrtc.handleSignal(JSON.parse(plaintext));
582
- } catch (err) {
583
- this.logDebug(`[WebRTC] Signal decryption failed: ${err.message}`);
584
- }
585
- }
586
- break;
587
- }
588
-
589
- case 'heartbeat': {
590
- this.missedPings = 0;
591
- break;
592
- }
593
-
594
- case 'data': {
595
- if (!this.sharedKey || !this.ptyProcess) return;
596
- try {
597
- const plaintext = await decrypt(this.sharedKey, msg.payload);
598
-
599
- // Check if this is a resize event
600
- if (msg.meta === 'resize') {
601
- try {
602
- const resizeData = JSON.parse(plaintext);
603
- if (resizeData.type === 'resize' && resizeData.cols && resizeData.rows) {
604
- this.ptyProcess.resize(resizeData.cols, resizeData.rows);
605
- this.logDebug(`Terminal resized to ${resizeData.cols}x${resizeData.rows}`);
606
- }
607
- } catch {}
608
- return;
609
- }
610
-
611
- // Check if decrypted message is a file transfer message (file-start, file-chunk, file-end)
612
- try {
613
- const parsedMsg = JSON.parse(plaintext);
614
- if (parsedMsg && typeof parsedMsg.type === 'string' && parsedMsg.type.startsWith('file-')) {
615
- if (this.readOnly) {
616
- this._sendFileError(parsedMsg.id, 'File uploads are disabled in read-only mode');
617
- return;
618
- }
619
- this._handleFileMessage(parsedMsg);
620
- return;
621
- }
622
- } catch {
623
- // Not JSON — normal keystroke data, fall through
624
- }
625
-
626
- // Normal keystroke — drop if read-only
627
- if (this.readOnly) return;
628
- this.ptyProcess.write(plaintext);
629
- } catch (err) {
630
- this.log(`Decryption failed: ${err.message}`);
631
- }
632
- break;
633
- }
634
-
635
- case 'peer-disconnected': {
636
- if (this.useDataChannel && this.webrtc && this.webrtc.isActive()) {
637
- this.log('Client relay connection lost; direct DataChannel still active.');
638
- this.missedPings = 0;
639
- this.startHeartbeat();
640
- break;
641
- }
642
-
643
- this.log(`Client disconnected. Rejoin window: ${formatDuration(this.rejoinMs)}.`);
644
- this.isClientConnected = false;
645
- this.awaitingRejoin = true;
646
- this.sharedKey = null;
647
- this.keyPair = null;
648
- this.hostPublicKeyBase64 = null;
649
- this.clientPublicKeyBase64 = null;
650
- this.securityFingerprint = null;
651
- this.fingerprintAuthorized = false;
652
- this.stopHeartbeat();
653
- // Phase 4: Clean up WebRTC on peer disconnect
654
- this._cleanupWebRTC();
655
- break;
656
- }
657
-
658
- case 'session-expired': {
659
- this.log('Rejoin window expired. Session ended.');
660
- this.destroy();
661
- break;
662
- }
663
-
664
- case 'rejoined': {
665
- this.log(`\u2705 Reconnected. Session restored. (code: ${msg.code})`);
666
- await this._restartPeerSessionAfterHostRejoin();
667
- break;
668
- }
669
-
670
- case 'error': {
671
- this.log(`Error: ${msg.msg}`);
672
- break;
673
- }
674
- }
300
+ await this._handleRelayMessage(msg, url, resolve);
675
301
  } catch (err) {
676
302
  this._handleMessageError(err);
677
303
  }
@@ -703,6 +329,161 @@ class Session {
703
329
  });
704
330
  }
705
331
 
332
+ /**
333
+ * Central relay message handler — shared by _tryConnect and _attachWsHandlers.
334
+ * The optional `resolve` parameter is only provided during initial registration.
335
+ */
336
+ async _handleRelayMessage(msg, url, resolve) {
337
+ switch (msg.type) {
338
+ case 'code': {
339
+ this.code = msg.code;
340
+ this.hostToken = msg.hostToken || null;
341
+ printSessionBox({
342
+ code: this.code,
343
+ expiry: formatDuration(this.expiryMs),
344
+ rejoinWindow: formatDuration(this.rejoinMs),
345
+ mode: (this.readOnly ? 'Read-Only' : 'Read-Write') + (this.secureMode ? ' (Secure)' : ''),
346
+ shell: this.shell,
347
+ startPath: this.startPath,
348
+ shareUrl: url.replace(/^wss:\/\//i, 'https://').replace(/^ws:\/\//i, 'http://')
349
+ });
350
+ if (resolve) resolve(this.code);
351
+ break;
352
+ }
353
+
354
+ case 'client-connected': {
355
+ if (this.awaitingRejoin) {
356
+ this.log('Client reconnected.');
357
+ this.awaitingRejoin = false;
358
+ } else {
359
+ this.log('Client connected! Starting key exchange...');
360
+ }
361
+ this.isClientConnected = true;
362
+ this.missedPings = 0;
363
+
364
+ if (this.useDataChannel && this.webrtc && this.webrtc.isActive()) {
365
+ this.startHeartbeat();
366
+ break;
367
+ }
368
+
369
+ await this._beginKeyExchange();
370
+ break;
371
+ }
372
+
373
+ case 'key-exchange': {
374
+ await this._completeKeyExchange(msg.publicKey);
375
+ break;
376
+ }
377
+
378
+ // Phase 4: WebRTC signaling messages
379
+ case 'signal': {
380
+ if (this.webrtc && this.sharedKey) {
381
+ try {
382
+ const plaintext = await decrypt(this.sharedKey, msg.payload);
383
+ this.webrtc.handleSignal(JSON.parse(plaintext));
384
+ } catch (err) {
385
+ this.logDebug(`[WebRTC] Signal decryption failed: ${err.message}`);
386
+ }
387
+ }
388
+ break;
389
+ }
390
+
391
+ case 'heartbeat': {
392
+ this.missedPings = 0;
393
+ break;
394
+ }
395
+
396
+ case 'data': {
397
+ if (!this.sharedKey || !this.ptyProcess) return;
398
+ try {
399
+ const plaintext = await decrypt(this.sharedKey, msg.payload);
400
+
401
+ // Check if this is a resize event
402
+ if (msg.meta === 'resize') {
403
+ try {
404
+ const resizeData = JSON.parse(plaintext);
405
+ if (resizeData.type === 'resize' && resizeData.cols && resizeData.rows) {
406
+ this.ptyProcess.resize(resizeData.cols, resizeData.rows);
407
+ this.logDebug(`Terminal resized to ${resizeData.cols}x${resizeData.rows}`);
408
+ }
409
+ } catch {}
410
+ return;
411
+ }
412
+
413
+ // Check if decrypted message is a file transfer message (file-start, file-chunk, file-end)
414
+ try {
415
+ const parsedMsg = JSON.parse(plaintext);
416
+ if (parsedMsg && typeof parsedMsg.type === 'string' && parsedMsg.type.startsWith('file-')) {
417
+ if (this.readOnly) {
418
+ this._fileTransfer._sendError(parsedMsg.id, 'File uploads are disabled in read-only mode');
419
+ return;
420
+ }
421
+ this._fileTransfer.handleMessage(parsedMsg);
422
+ return;
423
+ }
424
+ } catch {
425
+ // Not JSON — normal keystroke data, fall through
426
+ }
427
+
428
+ // Normal keystroke — drop if read-only
429
+ if (this.readOnly) return;
430
+ this.ptyProcess.write(plaintext);
431
+ } catch (err) {
432
+ this.log(`Decryption failed: ${err.message}`);
433
+ }
434
+ break;
435
+ }
436
+
437
+ case 'peer-disconnected': {
438
+ if (this.useDataChannel && this.webrtc && this.webrtc.isActive()) {
439
+ this.log('Client relay connection lost; direct DataChannel still active.');
440
+ this.missedPings = 0;
441
+ this.startHeartbeat();
442
+ break;
443
+ }
444
+
445
+ this.log(`Client disconnected. Rejoin window: ${formatDuration(this.rejoinMs)}.`);
446
+ this.isClientConnected = false;
447
+ this.awaitingRejoin = true;
448
+ this._resetCryptoState();
449
+ this.stopHeartbeat();
450
+ // Phase 4: Clean up WebRTC on peer disconnect
451
+ this._cleanupWebRTC();
452
+ break;
453
+ }
454
+
455
+ case 'session-expired': {
456
+ this.log('Rejoin window expired. Session ended.');
457
+ this.destroy();
458
+ break;
459
+ }
460
+
461
+ case 'rejoined': {
462
+ this.log(`\u2705 Reconnected. Session restored. (code: ${msg.code})`);
463
+ await this._restartPeerSessionAfterHostRejoin();
464
+ break;
465
+ }
466
+
467
+ case 'error': {
468
+ this.log(`Error: ${msg.msg}`);
469
+ break;
470
+ }
471
+ }
472
+ }
473
+
474
+ /**
475
+ * Reset all crypto state (shared key, key pair, fingerprint).
476
+ * Used on peer disconnect and heartbeat loss.
477
+ */
478
+ _resetCryptoState() {
479
+ this.sharedKey = null;
480
+ this.keyPair = null;
481
+ this.hostPublicKeyBase64 = null;
482
+ this.clientPublicKeyBase64 = null;
483
+ this.securityFingerprint = null;
484
+ this.fingerprintAuthorized = false;
485
+ }
486
+
706
487
  // ─── Send encrypted resize to client ────────────────────────────────
707
488
  async _sendResize(cols, rows) {
708
489
  if (!this.sharedKey) return;
@@ -735,12 +516,7 @@ class Session {
735
516
  this.log('Client heartbeat lost. Waiting for reconnect...');
736
517
  this.isClientConnected = false;
737
518
  this.awaitingRejoin = true;
738
- this.sharedKey = null;
739
- this.keyPair = null;
740
- this.hostPublicKeyBase64 = null;
741
- this.clientPublicKeyBase64 = null;
742
- this.securityFingerprint = null;
743
- this.fingerprintAuthorized = false;
519
+ this._resetCryptoState();
744
520
  this.stopHeartbeat();
745
521
  }
746
522
  }, HEARTBEAT_INTERVAL_MS);
@@ -769,8 +545,8 @@ class Session {
769
545
 
770
546
  this.ptyProcess.onData((data) => {
771
547
  // Mirror PTY output to local viewer terminal
772
- if (this.viewerSocket) {
773
- try { this.viewerSocket.write(data); } catch {}
548
+ if (this._viewer) {
549
+ this._viewer.write(data);
774
550
  }
775
551
 
776
552
  if (!this.sharedKey) return;
@@ -786,10 +562,10 @@ class Session {
786
562
  });
787
563
 
788
564
  // Open a local viewer terminal for the host
789
- this._startViewerServer();
565
+ this._startViewer();
790
566
  }
791
567
 
792
- // ─── Local TCP viewer for host terminal ─────────────────────────────
568
+ // ─── PTY Output Queue ──────────────────────────────────────────────
793
569
  _queuePtyOutput(data, sharedKey) {
794
570
  this.ptyOutputQueue = this.ptyOutputQueue
795
571
  .then(() => this._sendPtyOutput(data, sharedKey))
@@ -842,364 +618,25 @@ class Session {
842
618
  }
843
619
  }
844
620
 
845
- // ─── File Upload Handling ─────────────────────────────────────────────
846
-
847
- /**
848
- * Route file transfer messages (file-start, file-chunk, file-end).
849
- * All file messages arrive over the DataChannel only.
850
- */
851
- _handleFileMessage(msg) {
852
- // Defense-in-depth: reject all file operations in read-only sessions
853
- if (this.readOnly) {
854
- this._sendFileError(msg.id, 'File uploads are disabled in read-only mode');
855
- return;
856
- }
857
-
858
- switch (msg.type) {
859
- case 'file-start': {
860
- // Validate required fields
861
- if (!msg.id || !msg.name || msg.size === undefined || msg.totalChunks === undefined) {
862
- this._sendFileError(msg.id, 'Invalid file-start message');
863
- return;
621
+ // ─── Viewer ─────────────────────────────────────────────────────────
622
+ _startViewer() {
623
+ this._viewer = new ViewerServer({
624
+ code: this.code,
625
+ log: (msg) => this.log(msg),
626
+ logDebug: (msg) => this.logDebug(msg),
627
+ onResize: (cols, rows) => {
628
+ if (this.ptyProcess) {
629
+ this.ptyProcess.resize(cols, rows);
630
+ this._sendResize(cols, rows);
864
631
  }
865
-
866
- if (msg.size > UPLOAD_MAX_FILE_SIZE) {
867
- this._sendFileError(msg.id, `File exceeds maximum size of ${UPLOAD_MAX_FILE_SIZE / 1024 / 1024}MB`);
868
- return;
632
+ },
633
+ onKeystroke: (data) => {
634
+ if (this.ptyProcess) {
635
+ this.ptyProcess.write(data);
869
636
  }
870
-
871
- if (msg.totalChunks > UPLOAD_MAX_CHUNKS_PER_FILE) {
872
- this._sendFileError(msg.id, 'File requires too many chunks');
873
- return;
874
- }
875
-
876
- if (Object.keys(this._incomingFiles).length >= UPLOAD_MAX_CONCURRENT) {
877
- this._sendFileError(msg.id, 'Too many concurrent uploads');
878
- return;
879
- }
880
-
881
- this.log(`File upload started: "${msg.name}" (${msg.size} bytes, ${msg.totalChunks} chunks)`);
882
- this._incomingFiles[msg.id] = {
883
- name: msg.name,
884
- size: msg.size,
885
- totalChunks: msg.totalChunks,
886
- receivedSize: 0,
887
- chunks: new Map(),
888
- };
889
- break;
890
- }
891
- case 'file-chunk': {
892
- const transfer = this._incomingFiles[msg.id];
893
- if (!transfer) {
894
- this._sendFileError(msg.id, 'Unknown transfer ID');
895
- return;
896
- }
897
-
898
- const chunkData = Buffer.from(msg.data, 'base64');
899
-
900
- if (transfer.chunks.size >= transfer.totalChunks) {
901
- this._sendFileError(msg.id, 'Too many chunks received');
902
- delete this._incomingFiles[msg.id];
903
- return;
904
- }
905
-
906
- if (transfer.receivedSize + chunkData.length > transfer.size) {
907
- this._sendFileError(msg.id, 'Received size exceeds declared size');
908
- delete this._incomingFiles[msg.id];
909
- return;
910
- }
911
-
912
- transfer.chunks.set(msg.index, chunkData);
913
- transfer.receivedSize += chunkData.length;
914
- this.logDebug(`File chunk ${msg.index + 1}/${transfer.totalChunks} received for "${transfer.name}"`);
915
- break;
916
- }
917
- case 'file-end': {
918
- this._finalizeFile(msg.id);
919
- break;
920
- }
921
- case 'file-cancel': {
922
- if (this._incomingFiles[msg.id]) {
923
- delete this._incomingFiles[msg.id];
924
- this.log(`File upload cancelled by client: ${msg.id}`);
925
- }
926
- break;
927
- }
928
- }
929
- }
930
-
931
- /**
932
- * Reassemble all chunks in order and write the completed file to disk.
933
- * Saves to ~/peerterm-uploads/<filename>.
934
- */
935
- async _finalizeFile(id) {
936
- const transfer = this._incomingFiles[id];
937
- if (!transfer) {
938
- this._sendFileError(id, 'Unknown transfer ID');
939
- return;
940
- }
941
-
942
- // Check all chunks are present
943
- for (let i = 0; i < transfer.totalChunks; i++) {
944
- if (!transfer.chunks.has(i)) {
945
- this.log(`File "${transfer.name}" missing chunk ${i}`);
946
- this._sendFileError(id, `Missing chunk ${i}`);
947
- delete this._incomingFiles[id];
948
- return;
949
- }
950
- }
951
-
952
- // Reassemble in order using Buffer.concat
953
- const ordered = [];
954
- for (let i = 0; i < transfer.totalChunks; i++) {
955
- ordered.push(transfer.chunks.get(i));
956
- }
957
- const fileData = Buffer.concat(ordered);
958
-
959
- // Determine save path: ~/peerterm-uploads/<filename>
960
- const homeDir = os.homedir();
961
- const uploadDir = path.join(homeDir, 'peerterm-uploads');
962
- // Sanitize filename to prevent path traversal (e.g., "../../../etc/passwd")
963
- const baseName = path.basename(transfer.name);
964
- if (!baseName || baseName === '.' || baseName === '..') {
965
- this._sendFileError(id, 'Invalid filename');
966
- delete this._incomingFiles[id];
967
- return;
968
- }
969
- const savePath = path.join(uploadDir, baseName);
970
-
971
-
972
-
973
-
974
- try {
975
- await fs.promises.mkdir(uploadDir, { recursive: true });
976
- await fs.promises.writeFile(savePath, fileData);
977
-
978
- this.log(`File saved: ${savePath}`);
979
-
980
- // Write green ANSI message into the PTY to show the save path
981
- if (this.ptyProcess) {
982
- this.ptyProcess.write(`\r\n\x1b[32m[PeerTerm] File saved: ${savePath}\x1b[0m\r\n`);
983
- }
984
-
985
- // Send ack to client
986
- this._sendFileAck(id, transfer.name);
987
- } catch (err) {
988
- this.log(`Failed to save file "${transfer.name}": ${err.message}`);
989
- this._sendFileError(id, `Save failed: ${err.message}`);
990
- }
991
-
992
- delete this._incomingFiles[id];
993
- }
994
-
995
- /**
996
- * Send file-ack to the client confirming successful file save.
997
- */
998
- async _sendFileAck(id, name) {
999
- if (!this.sharedKey) return;
1000
- try {
1001
- const ackJson = JSON.stringify({ type: 'file-ack', id, name });
1002
- const payload = await encrypt(this.sharedKey, ackJson);
1003
- this._sendEncryptedToClient(payload);
1004
- } catch (err) {
1005
- this.log(`Failed to send file-ack: ${err.message}`);
1006
- }
1007
- }
1008
-
1009
- /**
1010
- * Send file-error to the client with an error message.
1011
- */
1012
- async _sendFileError(id, msg) {
1013
- if (!this.sharedKey) return;
1014
- try {
1015
- const errJson = JSON.stringify({ type: 'file-error', id, msg });
1016
- const payload = await encrypt(this.sharedKey, errJson);
1017
- this._sendEncryptedToClient(payload);
1018
- } catch (err) {
1019
- this.log(`Failed to send file-error: ${err.message}`);
1020
- }
1021
- }
1022
-
1023
- _startViewerServer() {
1024
- // Generate a random nonce for viewer authentication
1025
- this.viewerToken = crypto.randomBytes(16).toString('hex');
1026
-
1027
- this.viewerServer = net.createServer((socket) => {
1028
- // Close any existing viewer connection before accepting a new one
1029
- if (this.viewerSocket) {
1030
- this.logDebug('Replacing existing viewer connection.');
1031
- this.viewerSocket.removeAllListeners();
1032
- try { this.viewerSocket.destroy(); } catch {}
1033
- }
1034
-
1035
- // ── Token authentication gate ──────────────────────────────────
1036
- // The first data packet must be the viewer token followed by a newline.
1037
- // Reject the connection if no valid token arrives within 2 seconds.
1038
- let authenticated = false;
1039
- let authBuf = '';
1040
-
1041
- const authTimeout = setTimeout(() => {
1042
- if (!authenticated) {
1043
- this.logDebug('Viewer auth timeout — closing connection.');
1044
- try { socket.destroy(); } catch {}
1045
- }
1046
- }, 2000);
1047
-
1048
- const onAuthData = (data) => {
1049
- authBuf += data.toString();
1050
- const newlineIdx = authBuf.indexOf('\n');
1051
- if (newlineIdx === -1) {
1052
- // Accumulated too much data without a newline — reject
1053
- if (authBuf.length > 256) {
1054
- clearTimeout(authTimeout);
1055
- this.logDebug('Viewer auth buffer overflow — closing connection.');
1056
- try { socket.destroy(); } catch {}
1057
- }
1058
- return;
1059
- }
1060
-
1061
- const token = authBuf.slice(0, newlineIdx).trim();
1062
- const remaining = authBuf.slice(newlineIdx + 1);
1063
- clearTimeout(authTimeout);
1064
- socket.removeListener('data', onAuthData);
1065
-
1066
- if (token !== this.viewerToken) {
1067
- this.logDebug('Viewer auth failed — wrong token.');
1068
- try { socket.destroy(); } catch {}
1069
- return;
1070
- }
1071
-
1072
- // Authenticated — promote to active viewer
1073
- authenticated = true;
1074
- this.viewerSocket = socket;
1075
- this.log('Host viewer connected (authenticated).');
1076
-
1077
- // Install the real data handler
1078
- let recvBuf = '';
1079
-
1080
- // Process any leftover data that arrived after the token line
1081
- if (remaining.length > 0) {
1082
- handleViewerData(remaining);
1083
- }
1084
-
1085
- socket.on('data', (chunk) => handleViewerData(chunk.toString()));
1086
-
1087
- const handleViewerData = (chunk) => {
1088
- // Control messages start with \x00{
1089
- if (recvBuf.length > 0 || chunk.startsWith('\x00{')) {
1090
- recvBuf += chunk;
1091
- let nlIdx;
1092
- while ((nlIdx = recvBuf.indexOf('\n')) !== -1) {
1093
- const line = recvBuf.slice(0, nlIdx).trim();
1094
- recvBuf = recvBuf.slice(nlIdx + 1);
1095
-
1096
- if (line.startsWith('\x00')) {
1097
- try {
1098
- const msg = JSON.parse(line.slice(1));
1099
- if (msg.type === 'resize' &&
1100
- Number.isInteger(msg.cols) && msg.cols > 0 &&
1101
- Number.isInteger(msg.rows) && msg.rows > 0 &&
1102
- this.ptyProcess) {
1103
- this.ptyProcess.resize(msg.cols, msg.rows);
1104
- this._sendResize(msg.cols, msg.rows);
1105
- this.logDebug(`Viewer resized to ${msg.cols}x${msg.rows}`);
1106
- }
1107
- } catch {}
1108
- } else if (this.ptyProcess) {
1109
- this.ptyProcess.write(line + '\n');
1110
- }
1111
- }
1112
- // Flush any non-control data left in the buffer
1113
- if (recvBuf.length > 0 && !recvBuf.startsWith('\x00')) {
1114
- if (this.ptyProcess) this.ptyProcess.write(recvBuf);
1115
- recvBuf = '';
1116
- }
1117
- return;
1118
- }
1119
-
1120
- // Host keystrokes → PTY (host always has access)
1121
- if (this.ptyProcess) {
1122
- this.ptyProcess.write(chunk);
1123
- }
1124
- };
1125
-
1126
- socket.on('close', () => {
1127
- this.viewerSocket = null;
1128
- this.logDebug('Host viewer disconnected.');
1129
- });
1130
-
1131
- socket.on('error', () => {
1132
- this.viewerSocket = null;
1133
- });
1134
- };
1135
-
1136
- socket.on('data', onAuthData);
1137
-
1138
- socket.on('error', () => {
1139
- clearTimeout(authTimeout);
1140
- });
1141
- });
1142
-
1143
- this.viewerServer.listen(0, '127.0.0.1', () => {
1144
- const port = this.viewerServer.address().port;
1145
- this.logDebug(`Viewer server on port ${port}`);
1146
- this._openViewerTerminal(port, this.viewerToken);
1147
- });
1148
-
1149
- this.viewerServer.on('error', (err) => {
1150
- this.log(`Viewer server error: ${err.message}`);
1151
- this.viewerServer = null;
637
+ },
1152
638
  });
1153
- }
1154
-
1155
- _openViewerTerminal(port, token) {
1156
- if (!/^[A-Za-z0-9_-]+$/.test(this.code)) {
1157
- this.log('Invalid session code format. Aborting viewer terminal.');
1158
- return;
1159
- }
1160
-
1161
- const viewerScript = path.join(__dirname, 'session-viewer.js');
1162
- const platform = os.platform();
1163
- const portStr = String(port);
1164
- const nodeArgs = [viewerScript, portStr, this.code, token];
1165
-
1166
- if (platform === 'win32') {
1167
- // spawn with 'cmd' to open a new window; arguments are passed as an
1168
- // array so the install path is never interpreted by the shell.
1169
- spawn('cmd', ['/c', 'start', `PeerTerm - ${this.code}`, 'cmd', '/c', 'node', ...nodeArgs], {
1170
- stdio: 'ignore',
1171
- detached: true,
1172
- windowsHide: false,
1173
- }).unref();
1174
- } else if (platform === 'darwin') {
1175
- // osascript receives the AppleScript source as a single -e argument;
1176
- // node args are baked into the script string, but execFile does NOT
1177
- // invoke a shell so the outer path cannot break out.
1178
- const script = `tell app "Terminal" to do script "node '${viewerScript.replace(/'/g, "'\\''")}' ${portStr} ${this.code} ${token}"`;
1179
- execFile('osascript', ['-e', script], { stdio: 'ignore' }, () => {});
1180
- } else {
1181
- // Linux: try common terminal emulators in order, falling through on
1182
- // failure. Each call uses execFile (no shell), so paths with
1183
- // metacharacters are safe.
1184
- const tryTerminals = [
1185
- ['x-terminal-emulator', ['-e', 'node', ...nodeArgs]],
1186
- ['gnome-terminal', ['--', 'node', ...nodeArgs]],
1187
- ['xterm', ['-e', 'node', ...nodeArgs]],
1188
- ];
1189
-
1190
- const tryNext = (index) => {
1191
- if (index >= tryTerminals.length) {
1192
- this.log('Could not open any terminal emulator for viewer.');
1193
- return;
1194
- }
1195
- const [cmd, args] = tryTerminals[index];
1196
- execFile(cmd, args, { stdio: 'ignore' }, (err) => {
1197
- if (err) tryNext(index + 1);
1198
- });
1199
- };
1200
- tryNext(0);
1201
- }
1202
- this.log('Opening terminal viewer...');
639
+ this._viewer.start();
1203
640
  }
1204
641
 
1205
642
  // ─── Host Reconnect Logic ───────────────────────────────────────────
@@ -1299,7 +736,7 @@ class Session {
1299
736
 
1300
737
  /**
1301
738
  * Re-attach message/close/error handlers to a new WebSocket after rejoin.
1302
- * This mirrors the handlers set in _tryConnect but skips the initial registration flow.
739
+ * Uses the shared _handleRelayMessage method to avoid duplicating the switch block.
1303
740
  */
1304
741
  _attachWsHandlers(newWs) {
1305
742
  newWs.on('message', async (raw) => {
@@ -1316,127 +753,7 @@ class Session {
1316
753
  return;
1317
754
  }
1318
755
 
1319
- switch (msg.type) {
1320
- case 'client-connected': {
1321
- if (this.awaitingRejoin) {
1322
- this.log('Client reconnected.');
1323
- this.awaitingRejoin = false;
1324
- } else {
1325
- this.log('Client connected! Starting key exchange...');
1326
- }
1327
- this.isClientConnected = true;
1328
- this.missedPings = 0;
1329
-
1330
- if (this.useDataChannel && this.webrtc && this.webrtc.isActive()) {
1331
- // Keep existing keys if direct session is healthy
1332
- this.startHeartbeat();
1333
- break;
1334
- }
1335
-
1336
- await this._beginKeyExchange();
1337
- break;
1338
- }
1339
-
1340
- case 'key-exchange': {
1341
- await this._completeKeyExchange(msg.publicKey);
1342
- break;
1343
- }
1344
-
1345
- case 'signal': {
1346
- if (this.webrtc && this.sharedKey) {
1347
- try {
1348
- const plaintext = await decrypt(this.sharedKey, msg.payload);
1349
- this.webrtc.handleSignal(JSON.parse(plaintext));
1350
- } catch (err) {
1351
- this.logDebug(`[WebRTC] Signal decryption failed: ${err.message}`);
1352
- }
1353
- }
1354
- break;
1355
- }
1356
-
1357
- case 'heartbeat': {
1358
- this.missedPings = 0;
1359
- break;
1360
- }
1361
-
1362
- case 'data': {
1363
- if (!this.sharedKey || !this.ptyProcess) return;
1364
- try {
1365
- const plaintext = await decrypt(this.sharedKey, msg.payload);
1366
-
1367
- if (msg.meta === 'resize') {
1368
- try {
1369
- const resizeData = JSON.parse(plaintext);
1370
- if (resizeData.type === 'resize' && resizeData.cols && resizeData.rows) {
1371
- this.ptyProcess.resize(resizeData.cols, resizeData.rows);
1372
- this.logDebug(`Terminal resized to ${resizeData.cols}x${resizeData.rows}`);
1373
- }
1374
- } catch {}
1375
- return;
1376
- }
1377
-
1378
- // Check if decrypted message is a file transfer message
1379
- try {
1380
- const parsedMsg = JSON.parse(plaintext);
1381
- if (parsedMsg && typeof parsedMsg.type === 'string' && parsedMsg.type.startsWith('file-')) {
1382
- if (this.readOnly) {
1383
- this._sendFileError(parsedMsg.id, 'File uploads are disabled in read-only mode');
1384
- return;
1385
- }
1386
- this._handleFileMessage(parsedMsg);
1387
- return;
1388
- }
1389
- } catch {
1390
- // Not JSON — normal keystroke data, fall through
1391
- }
1392
-
1393
- if (this.readOnly) return;
1394
- this.ptyProcess.write(plaintext);
1395
- } catch (err) {
1396
- this.log(`Decryption failed: ${err.message}`);
1397
- }
1398
- break;
1399
- }
1400
-
1401
- case 'peer-disconnected': {
1402
- if (this.useDataChannel && this.webrtc && this.webrtc.isActive()) {
1403
- this.log('Client relay connection lost; direct DataChannel still active.');
1404
- this.missedPings = 0;
1405
- this.startHeartbeat();
1406
- break;
1407
- }
1408
-
1409
- this.log(`Client disconnected. Rejoin window: ${formatDuration(this.rejoinMs)}.`);
1410
- this.isClientConnected = false;
1411
- this.awaitingRejoin = true;
1412
- this.sharedKey = null;
1413
- this.keyPair = null;
1414
- this.hostPublicKeyBase64 = null;
1415
- this.clientPublicKeyBase64 = null;
1416
- this.securityFingerprint = null;
1417
- this.fingerprintAuthorized = false;
1418
- this.stopHeartbeat();
1419
- this._cleanupWebRTC();
1420
- break;
1421
- }
1422
-
1423
- case 'session-expired': {
1424
- this.log('Rejoin window expired. Session ended.');
1425
- this.destroy();
1426
- break;
1427
- }
1428
-
1429
- case 'rejoined': {
1430
- this.log(`Reconnected. Session restored. (code: ${msg.code})`);
1431
- await this._restartPeerSessionAfterHostRejoin();
1432
- break;
1433
- }
1434
-
1435
- case 'error': {
1436
- this.log(`Error: ${msg.msg}`);
1437
- break;
1438
- }
1439
- }
756
+ await this._handleRelayMessage(msg, this.relayUrl, null);
1440
757
  } catch (err) {
1441
758
  this._handleMessageError(err);
1442
759
  }
@@ -1466,16 +783,13 @@ class Session {
1466
783
  this.intentionalClose = true;
1467
784
  this.stopHeartbeat();
1468
785
  this._stopReconnecting();
786
+ this._stopDurationLogging();
1469
787
  // Clean up in-progress file uploads
1470
- this._incomingFiles = {};
788
+ this._fileTransfer.cleanup();
1471
789
  // Clean up viewer
1472
- if (this.viewerSocket) {
1473
- try { this.viewerSocket.destroy(); } catch {}
1474
- this.viewerSocket = null;
1475
- }
1476
- if (this.viewerServer) {
1477
- try { this.viewerServer.close(); } catch {}
1478
- this.viewerServer = null;
790
+ if (this._viewer) {
791
+ this._viewer.destroy();
792
+ this._viewer = null;
1479
793
  }
1480
794
  // Phase 4: Clean up WebRTC
1481
795
  this._cleanupWebRTC();
@@ -1488,7 +802,13 @@ class Session {
1488
802
  try { this.ws.send(JSON.stringify({ type: 'session-ended' })); } catch {}
1489
803
  this.ws.close();
1490
804
  }
1491
- this.log('Session ended.');
805
+ // Log session duration on end
806
+ if (this.sessionStartedAt) {
807
+ const duration = formatDuration(Date.now() - this.sessionStartedAt);
808
+ this.log(`Session ended. Duration: ${duration}`);
809
+ } else {
810
+ this.log('Session ended.');
811
+ }
1492
812
  if (this.onDestroy) this.onDestroy(this.code);
1493
813
  }
1494
814
 
@@ -1541,10 +861,10 @@ class Session {
1541
861
  const msg = JSON.parse(plaintext);
1542
862
  if (msg && typeof msg.type === 'string' && msg.type.startsWith('file-')) {
1543
863
  if (this.readOnly) {
1544
- this._sendFileError(msg.id, 'File uploads are disabled in read-only mode');
864
+ this._fileTransfer._sendError(msg.id, 'File uploads are disabled in read-only mode');
1545
865
  return;
1546
866
  }
1547
- this._handleFileMessage(msg);
867
+ this._fileTransfer.handleMessage(msg);
1548
868
  return;
1549
869
  }
1550
870
  } catch {
@@ -1582,11 +902,7 @@ class Session {
1582
902
  return;
1583
903
  }
1584
904
 
1585
- this.sharedKey = null;
1586
- this.hostPublicKeyBase64 = null;
1587
- this.clientPublicKeyBase64 = null;
1588
- this.securityFingerprint = null;
1589
- this.fingerprintAuthorized = false;
905
+ this._resetCryptoState();
1590
906
  this._cleanupWebRTC();
1591
907
 
1592
908
  if (this.ws && this.ws.readyState === WebSocket.OPEN) {
@@ -1610,7 +926,10 @@ class Session {
1610
926
  if (this.securityFingerprint && !this.fingerprintAuthorized) {
1611
927
  status = `verify fingerprint ${this.securityFingerprint}`;
1612
928
  } else if (this.isClientConnected) {
1613
- status = 'client connected';
929
+ const duration = this.sessionStartedAt
930
+ ? ` (${formatDuration(Date.now() - this.sessionStartedAt)})`
931
+ : '';
932
+ status = `client connected${duration}`;
1614
933
  } else if (this.awaitingRejoin) {
1615
934
  status = 'awaiting rejoin';
1616
935
  } else {
@@ -1619,11 +938,40 @@ class Session {
1619
938
  if (this.readOnly) status += ' (readonly)';
1620
939
  return status;
1621
940
  }
941
+
942
+ // ─── Session Duration Logging ────────────────────────────────────────
943
+
944
+ _formatElapsedCompact(ms) {
945
+ const totalSeconds = Math.floor(ms / 1000);
946
+ const hours = Math.floor(totalSeconds / 3600);
947
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
948
+ const seconds = totalSeconds % 60;
949
+ const pad = (n) => String(n).padStart(2, '0');
950
+ if (hours > 0) return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
951
+ return `${pad(minutes)}:${pad(seconds)}`;
952
+ }
953
+
954
+ _startDurationLogging() {
955
+ this._stopDurationLogging();
956
+ // Log session duration every 5 minutes
957
+ this.sessionDurationInterval = setInterval(() => {
958
+ if (!this.sessionStartedAt || this.destroyed) return;
959
+ const elapsed = Date.now() - this.sessionStartedAt;
960
+ this.log(`Session active — ${this._formatElapsedCompact(elapsed)}`);
961
+ }, 5 * 60 * 1000);
962
+ }
963
+
964
+ _stopDurationLogging() {
965
+ if (this.sessionDurationInterval) {
966
+ clearInterval(this.sessionDurationInterval);
967
+ this.sessionDurationInterval = null;
968
+ }
969
+ }
1622
970
  }
1623
971
 
1624
972
  // ─── Session Manager ─────────────────────────────────────────────────────────
1625
973
 
1626
- class SessionManager {
974
+ export class SessionManager {
1627
975
  constructor(shell, expiryMs, rejoinMs, readOnly, startPath) {
1628
976
  this.shell = shell;
1629
977
  this.expiryMs = expiryMs;
@@ -1687,114 +1035,3 @@ class SessionManager {
1687
1035
  this.sessions.clear();
1688
1036
  }
1689
1037
  }
1690
-
1691
- // ─── Main ────────────────────────────────────────────────────────────────────
1692
-
1693
- async function main() {
1694
- const shell = detectShell();
1695
- const readOnly = argv.readonly;
1696
- const startPath = resolveStartPath(argv.path);
1697
-
1698
- // Print startup banner
1699
- printBanner();
1700
-
1701
- // Interactive prompts — always ask unless flags are passed or non-interactive
1702
- const expiryMs = await getExpiryInteractive();
1703
- const rejoinMs = await getRejoinInteractive();
1704
- console.log('');
1705
-
1706
- logger.info(`Shell: ${shell}`);
1707
- logger.info(`Relays: ${RELAY_URLS.join(', ')}`);
1708
- logger.info(`Path: ${startPath}`);
1709
- logger.info(`Expiry: ${formatDuration(expiryMs)}`);
1710
- logger.info(`Rejoin Window: ${formatDuration(rejoinMs)}`);
1711
- if (readOnly) logger.info('Mode: READ-ONLY');
1712
- if (argv.verbose) logger.info('Verbose logging enabled');
1713
- console.log('');
1714
-
1715
- const manager = new SessionManager(shell, expiryMs, rejoinMs, readOnly, startPath);
1716
-
1717
- // Create first session automatically
1718
- const firstCode = await manager.createSession();
1719
- if (!firstCode) {
1720
- logger.error('Failed to start. Is the relay server running?');
1721
- process.exit(1);
1722
- }
1723
-
1724
- // ─── Interactive CLI menu ──────────────────────────────────────────
1725
- console.log(' ─────────────────────────────────────────');
1726
- console.log(' Commands:');
1727
- console.log(' [n] New session');
1728
- console.log(' [l] List sessions');
1729
- console.log(' [a <code>] Authorize verified fingerprint');
1730
- console.log(' [k <code>] Kill session');
1731
- console.log(' [q] Quit all');
1732
- console.log(' ─────────────────────────────────────────');
1733
- console.log('');
1734
-
1735
- const rl = readline.createInterface({
1736
- input: process.stdin,
1737
- output: process.stdout,
1738
- prompt: ' > ',
1739
- });
1740
-
1741
- rl.prompt();
1742
-
1743
- rl.on('line', async (line) => {
1744
- const input = line.trim();
1745
-
1746
- if (input === 'n') {
1747
- logger.info('Creating new session...');
1748
- await manager.createSession();
1749
- } else if (input === 'l') {
1750
- manager.listSessions();
1751
- } else if (input.startsWith('a ')) {
1752
- const code = input.slice(2).trim();
1753
- await manager.authorizeSession(code);
1754
- } else if (input.startsWith('k ')) {
1755
- const code = input.slice(2).trim();
1756
- manager.killSession(code);
1757
- } else if (input === 'q') {
1758
- logger.info('Shutting down all sessions...');
1759
- manager.killAll();
1760
- rl.close();
1761
- process.exit(0);
1762
- } else if (input) {
1763
- console.log(' Unknown command. Use n, l, a <code>, k <code>, or q.');
1764
- }
1765
-
1766
- rl.prompt();
1767
- });
1768
-
1769
- rl.on('close', () => {
1770
- manager.killAll();
1771
- process.exit(0);
1772
- });
1773
-
1774
- process.on('SIGINT', () => {
1775
- console.log('');
1776
- logger.info('Shutting down...');
1777
- manager.killAll();
1778
- rl.close();
1779
- process.exit(0);
1780
- });
1781
- }
1782
-
1783
- // ─── Global Error Handling ───────────────────────────────────────────────────
1784
-
1785
- process.on('uncaughtException', (err) => {
1786
- logger.error(`Uncaught exception: ${err.message}`, err);
1787
- process.exit(1);
1788
- });
1789
-
1790
- process.on('unhandledRejection', (reason) => {
1791
- const err = reason instanceof Error ? reason : new Error(String(reason));
1792
- logger.error(`Unhandled rejection: ${err.message}`, err);
1793
- });
1794
-
1795
- // ─── Run ─────────────────────────────────────────────────────────────────────
1796
-
1797
- main().catch((err) => {
1798
- logger.error(`Fatal error: ${err.message}`, err);
1799
- process.exit(1);
1800
- });