peer-term 1.3.10 → 1.4.1
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/bin/peer-term.js +1 -1
- package/package.json +2 -1
- package/src/cli.js +128 -0
- package/src/config.js +91 -0
- package/src/file-transfer.js +230 -0
- package/src/index.js +1037 -1849
- package/src/prompts.js +143 -0
- package/src/ui.js +25 -1
- package/src/utils.js +113 -0
- package/src/viewer.js +259 -0
package/src/prompts.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PeerTerm — Interactive Prompts
|
|
3
|
+
*
|
|
4
|
+
* Terminal prompts for session expiry and rejoin window configuration.
|
|
5
|
+
* Handles three modes: CLI flag, interactive TTY prompt, silent default.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import readline from 'readline';
|
|
9
|
+
import { parseDuration } from './utils.js';
|
|
10
|
+
import { argv, expiryFlagPassed, rejoinFlagPassed } from './config.js';
|
|
11
|
+
import logger from './logger.js';
|
|
12
|
+
|
|
13
|
+
// ─── Generic Duration Prompt ─────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Prompt the user for a duration value in the terminal.
|
|
17
|
+
* Re-prompts on invalid input until a valid value is entered or Enter is pressed for default.
|
|
18
|
+
*
|
|
19
|
+
* @param {Object} opts
|
|
20
|
+
* @param {string} opts.label - Main prompt question
|
|
21
|
+
* @param {string} opts.description - Additional context shown below the question
|
|
22
|
+
* @param {number} opts.defaultMs - Default value in milliseconds
|
|
23
|
+
* @param {string} opts.defaultLabel - Human-readable default (e.g. "5 minutes")
|
|
24
|
+
* @param {number} opts.minMs - Minimum allowed value in milliseconds
|
|
25
|
+
* @param {number} opts.maxMs - Maximum allowed value in milliseconds
|
|
26
|
+
* @param {string} opts.minLabel - Human-readable minimum (e.g. "2m")
|
|
27
|
+
* @param {string} opts.maxLabel - Human-readable maximum (e.g. "24h")
|
|
28
|
+
* @returns {Promise<number>} Resolved duration in milliseconds
|
|
29
|
+
*/
|
|
30
|
+
function promptDuration({ label, description, defaultMs, defaultLabel, minMs, maxMs, minLabel, maxLabel }) {
|
|
31
|
+
return new Promise((resolve) => {
|
|
32
|
+
const rl = readline.createInterface({
|
|
33
|
+
input: process.stdin,
|
|
34
|
+
output: process.stdout,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const ask = () => {
|
|
38
|
+
console.log('');
|
|
39
|
+
console.log(` ${label}`);
|
|
40
|
+
if (description) console.log(` ${description}`);
|
|
41
|
+
console.log(` Enter a value between ${minLabel} and ${maxLabel} (e.g. ${minLabel}, 30m, 2h)`);
|
|
42
|
+
console.log(` Default is ${defaultLabel}. Press Enter to use default.`);
|
|
43
|
+
rl.question(' > ', (answer) => {
|
|
44
|
+
const input = answer.trim();
|
|
45
|
+
|
|
46
|
+
// Empty input — use default
|
|
47
|
+
if (!input) {
|
|
48
|
+
rl.close();
|
|
49
|
+
resolve(defaultMs);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const parsed = parseDuration(input);
|
|
54
|
+
if (!parsed || parsed < minMs || parsed > maxMs) {
|
|
55
|
+
console.log(` Invalid value. Please enter a time between ${minLabel} and ${maxLabel} (e.g. 5m, 1h).`);
|
|
56
|
+
ask();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
rl.close();
|
|
61
|
+
resolve(parsed);
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
ask();
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ─── Expiry ──────────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Get the session code expiry — from flag, interactive prompt, or silent default.
|
|
73
|
+
*
|
|
74
|
+
* @returns {Promise<number>} Expiry in milliseconds
|
|
75
|
+
*/
|
|
76
|
+
export async function getExpiryInteractive() {
|
|
77
|
+
const DEFAULT_EXPIRY = 5 * 60 * 1000; // 5 minutes
|
|
78
|
+
const MIN_EXPIRY = 2 * 60 * 1000; // 2 minutes
|
|
79
|
+
const MAX_EXPIRY = 24 * 60 * 60 * 1000; // 24 hours
|
|
80
|
+
|
|
81
|
+
// If --expiry flag was explicitly passed, use it (validate and exit on error)
|
|
82
|
+
if (expiryFlagPassed) {
|
|
83
|
+
const parsed = parseDuration(argv.expiry);
|
|
84
|
+
if (!parsed || parsed < MIN_EXPIRY || parsed > MAX_EXPIRY) {
|
|
85
|
+
logger.error(`Invalid expiry: "${argv.expiry}". Must be between 2m and 24h.`);
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
return parsed;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Non-interactive — use default silently
|
|
92
|
+
if (!process.stdin.isTTY) return DEFAULT_EXPIRY;
|
|
93
|
+
|
|
94
|
+
// Interactive prompt
|
|
95
|
+
return promptDuration({
|
|
96
|
+
label: 'How long should the session code be valid?',
|
|
97
|
+
description: null,
|
|
98
|
+
defaultMs: DEFAULT_EXPIRY,
|
|
99
|
+
defaultLabel: '5 minutes',
|
|
100
|
+
minMs: MIN_EXPIRY,
|
|
101
|
+
maxMs: MAX_EXPIRY,
|
|
102
|
+
minLabel: '2m',
|
|
103
|
+
maxLabel: '24h',
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ─── Rejoin Window ───────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Get the reconnection window — from flag, interactive prompt, or silent default.
|
|
111
|
+
*
|
|
112
|
+
* @returns {Promise<number>} Rejoin window in milliseconds
|
|
113
|
+
*/
|
|
114
|
+
export async function getRejoinInteractive() {
|
|
115
|
+
const DEFAULT_REJOIN = 5 * 60 * 1000; // 5 minutes
|
|
116
|
+
const MIN_REJOIN = 5 * 60 * 1000; // 5 minutes
|
|
117
|
+
const MAX_REJOIN = 6 * 60 * 60 * 1000; // 6 hours
|
|
118
|
+
|
|
119
|
+
// If --rejoin flag was explicitly passed, use it
|
|
120
|
+
if (rejoinFlagPassed) {
|
|
121
|
+
const parsed = parseDuration(argv.rejoin);
|
|
122
|
+
if (!parsed || parsed < MIN_REJOIN || parsed > MAX_REJOIN) {
|
|
123
|
+
logger.error(`Invalid rejoin window: "${argv.rejoin}". Must be between 5m and 6h.`);
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
return parsed;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Non-interactive — use default silently
|
|
130
|
+
if (!process.stdin.isTTY) return DEFAULT_REJOIN;
|
|
131
|
+
|
|
132
|
+
// Interactive prompt
|
|
133
|
+
return promptDuration({
|
|
134
|
+
label: 'How long should the reconnection window be?',
|
|
135
|
+
description: 'This is how long a disconnected client or host has to rejoin before the session ends.',
|
|
136
|
+
defaultMs: DEFAULT_REJOIN,
|
|
137
|
+
defaultLabel: '2 minutes',
|
|
138
|
+
minMs: MIN_REJOIN,
|
|
139
|
+
maxMs: MAX_REJOIN,
|
|
140
|
+
minLabel: '5m',
|
|
141
|
+
maxLabel: '6h',
|
|
142
|
+
});
|
|
143
|
+
}
|
package/src/ui.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import fs from 'fs';
|
|
12
12
|
import path from 'path';
|
|
13
13
|
import { fileURLToPath } from 'url';
|
|
14
|
+
import qrcode from 'qrcode-terminal';
|
|
14
15
|
|
|
15
16
|
const __filename = fileURLToPath(import.meta.url);
|
|
16
17
|
const __dirname = path.dirname(__filename);
|
|
@@ -47,9 +48,10 @@ export function printBanner() {
|
|
|
47
48
|
* @param {string} [opts.startPath] - Starting directory for the session
|
|
48
49
|
* @param {string} [opts.shareUrl] - URL to share (e.g. "https://peerterm.dev")
|
|
49
50
|
*/
|
|
50
|
-
export function printSessionBox({ code, expiry, rejoinWindow, mode, shell, startPath, shareUrl }) {
|
|
51
|
+
export async function printSessionBox({ code, expiry, rejoinWindow, mode, shell, startPath, shareUrl }) {
|
|
51
52
|
const spacedCode = code.split('').join(' ');
|
|
52
53
|
const url = shareUrl || 'https://peerterm.dev';
|
|
54
|
+
const joinUrl = `${url}?code=${code}`;
|
|
53
55
|
|
|
54
56
|
// Truncate long shell paths to keep box readable
|
|
55
57
|
const shellDisplay = shell.length > 24 ? '...' + shell.slice(-21) : shell;
|
|
@@ -90,6 +92,28 @@ export function printSessionBox({ code, expiry, rejoinWindow, mode, shell, start
|
|
|
90
92
|
console.log(` │${emptyLine}│`);
|
|
91
93
|
console.log(` └${hr}┘`);
|
|
92
94
|
console.log('');
|
|
95
|
+
|
|
96
|
+
// Render QR code for instant scan-to-join
|
|
97
|
+
try {
|
|
98
|
+
const qrString = await new Promise((resolve) => {
|
|
99
|
+
qrcode.generate(joinUrl, { small: true }, (qr) => {
|
|
100
|
+
resolve(qr);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
console.log(' Scan to join:');
|
|
105
|
+
console.log('');
|
|
106
|
+
// Indent each QR line for alignment with the box
|
|
107
|
+
const qrLines = qrString.split('\n');
|
|
108
|
+
for (const line of qrLines) {
|
|
109
|
+
if (line.length > 0) console.log(` ${line}`);
|
|
110
|
+
}
|
|
111
|
+
console.log('');
|
|
112
|
+
console.log(` Or open: ${joinUrl}`);
|
|
113
|
+
console.log('');
|
|
114
|
+
} catch {
|
|
115
|
+
// QR generation failed — not critical, code is still shown in the box
|
|
116
|
+
}
|
|
93
117
|
}
|
|
94
118
|
|
|
95
119
|
// ─── Help Text ───────────────────────────────────────────────────────────────
|
package/src/utils.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PeerTerm — Utility Functions
|
|
3
|
+
*
|
|
4
|
+
* Generic helpers with no domain dependencies:
|
|
5
|
+
* - Duration parsing and formatting
|
|
6
|
+
* - Path resolution (tilde expansion, start path validation)
|
|
7
|
+
* - Shell detection
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import os from 'os';
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import logger from './logger.js';
|
|
14
|
+
|
|
15
|
+
// ─── Duration Parsing ────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parse a human-readable duration string into milliseconds.
|
|
19
|
+
* Accepted formats: "30s", "5m", "1h" (case-insensitive).
|
|
20
|
+
*
|
|
21
|
+
* @param {string} str - Duration string
|
|
22
|
+
* @returns {number|null} Duration in milliseconds, or null if invalid
|
|
23
|
+
*/
|
|
24
|
+
export function parseDuration(str) {
|
|
25
|
+
const match = str.match(/^(\d+)(s|m|h)$/i);
|
|
26
|
+
if (!match) return null;
|
|
27
|
+
const value = parseInt(match[1], 10);
|
|
28
|
+
const unit = match[2].toLowerCase();
|
|
29
|
+
switch (unit) {
|
|
30
|
+
case 's': return value * 1000;
|
|
31
|
+
case 'm': return value * 60 * 1000;
|
|
32
|
+
case 'h': return value * 60 * 60 * 1000;
|
|
33
|
+
default: return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Format a duration in milliseconds to a human-readable string.
|
|
39
|
+
*
|
|
40
|
+
* @param {number} ms - Duration in milliseconds
|
|
41
|
+
* @returns {string} Formatted string (e.g. "5 minute(s)")
|
|
42
|
+
*/
|
|
43
|
+
export function formatDuration(ms) {
|
|
44
|
+
if (ms >= 3600000) return `${Math.round(ms / 3600000)} hour(s)`;
|
|
45
|
+
if (ms >= 60000) return `${Math.round(ms / 60000)} minute(s)`;
|
|
46
|
+
return `${Math.round(ms / 1000)} second(s)`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ─── Path Resolution ─────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Expand a leading ~ to the user's home directory.
|
|
53
|
+
*
|
|
54
|
+
* @param {string} inputPath
|
|
55
|
+
* @returns {string}
|
|
56
|
+
*/
|
|
57
|
+
export function expandTilde(inputPath) {
|
|
58
|
+
if (inputPath.startsWith('~/') || inputPath === '~') {
|
|
59
|
+
return inputPath.replace(/^~/, process.env.HOME || process.env.USERPROFILE || '.');
|
|
60
|
+
}
|
|
61
|
+
return inputPath;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolve and validate a starting directory path.
|
|
66
|
+
* Falls back to the user's home directory if no path is provided.
|
|
67
|
+
* Exits the process if the path doesn't exist or isn't a directory.
|
|
68
|
+
*
|
|
69
|
+
* @param {string|undefined} inputPath
|
|
70
|
+
* @returns {string} Resolved absolute path
|
|
71
|
+
*/
|
|
72
|
+
export function resolveStartPath(inputPath) {
|
|
73
|
+
if (!inputPath) return process.env.HOME || process.env.USERPROFILE || process.cwd();
|
|
74
|
+
|
|
75
|
+
const resolved = path.resolve(expandTilde(inputPath));
|
|
76
|
+
|
|
77
|
+
if (!fs.existsSync(resolved)) {
|
|
78
|
+
logger.error(`Path does not exist: ${resolved}`);
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const stat = fs.statSync(resolved);
|
|
83
|
+
if (!stat.isDirectory()) {
|
|
84
|
+
logger.error(`Path is not a directory: ${resolved}`);
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return resolved;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ─── Shell Detection ─────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Detect the user's default shell.
|
|
95
|
+
* Checks $SHELL, Windows COMSPEC, /etc/passwd, and falls back to bash/powershell.
|
|
96
|
+
*
|
|
97
|
+
* @returns {string} Shell executable path or name
|
|
98
|
+
*/
|
|
99
|
+
export function detectShell() {
|
|
100
|
+
const platform = os.platform();
|
|
101
|
+
if (process.env.SHELL) return process.env.SHELL;
|
|
102
|
+
if (platform === 'win32') return process.env.COMSPEC || 'powershell.exe';
|
|
103
|
+
try {
|
|
104
|
+
const passwd = fs.readFileSync('/etc/passwd', 'utf-8');
|
|
105
|
+
const username = os.userInfo().username;
|
|
106
|
+
const line = passwd.split('\n').find((l) => l.startsWith(username + ':'));
|
|
107
|
+
if (line) {
|
|
108
|
+
const shell = line.split(':').pop().trim();
|
|
109
|
+
if (shell) return shell;
|
|
110
|
+
}
|
|
111
|
+
} catch {}
|
|
112
|
+
return platform === 'win32' ? 'powershell.exe' : 'bash';
|
|
113
|
+
}
|
package/src/viewer.js
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PeerTerm — Local Viewer Server
|
|
3
|
+
*
|
|
4
|
+
* Token-authenticated TCP server that opens a new terminal window
|
|
5
|
+
* for the host to view the shared session locally.
|
|
6
|
+
*
|
|
7
|
+
* Cross-platform terminal opener (Windows, macOS, Linux).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import crypto from 'crypto';
|
|
11
|
+
import net from 'net';
|
|
12
|
+
import os from 'os';
|
|
13
|
+
import path from 'path';
|
|
14
|
+
import { fileURLToPath } from 'url';
|
|
15
|
+
import { execFile, spawn } from 'child_process';
|
|
16
|
+
|
|
17
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
18
|
+
const __dirname = path.dirname(__filename);
|
|
19
|
+
|
|
20
|
+
export class ViewerServer {
|
|
21
|
+
/**
|
|
22
|
+
* @param {Object} opts
|
|
23
|
+
* @param {string} opts.code - Session code (for window title)
|
|
24
|
+
* @param {function} opts.log - Info logger
|
|
25
|
+
* @param {function} opts.logDebug - Debug logger
|
|
26
|
+
* @param {function} opts.onResize - Called with (cols, rows) when viewer resizes
|
|
27
|
+
* @param {function} opts.onKeystroke - Called with (data) for host keystrokes → PTY
|
|
28
|
+
*/
|
|
29
|
+
constructor({ code, log, logDebug, onResize, onKeystroke }) {
|
|
30
|
+
this._code = code;
|
|
31
|
+
this._log = log;
|
|
32
|
+
this._logDebug = logDebug;
|
|
33
|
+
this._onResize = onResize;
|
|
34
|
+
this._onKeystroke = onKeystroke;
|
|
35
|
+
|
|
36
|
+
this._server = null;
|
|
37
|
+
this._socket = null;
|
|
38
|
+
this._token = null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The active viewer socket (if connected and authenticated).
|
|
43
|
+
* Used by Session to mirror PTY output to the local viewer.
|
|
44
|
+
* @type {net.Socket|null}
|
|
45
|
+
*/
|
|
46
|
+
get socket() {
|
|
47
|
+
return this._socket;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Start the TCP viewer server on a random port and open a terminal window.
|
|
52
|
+
*/
|
|
53
|
+
start() {
|
|
54
|
+
// Generate a random nonce for viewer authentication
|
|
55
|
+
this._token = crypto.randomBytes(16).toString('hex');
|
|
56
|
+
|
|
57
|
+
this._server = net.createServer((socket) => {
|
|
58
|
+
// Close any existing viewer connection before accepting a new one
|
|
59
|
+
if (this._socket) {
|
|
60
|
+
this._logDebug('Replacing existing viewer connection.');
|
|
61
|
+
this._socket.removeAllListeners();
|
|
62
|
+
try { this._socket.destroy(); } catch {}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── Token authentication gate ──────────────────────────────────
|
|
66
|
+
// The first data packet must be the viewer token followed by a newline.
|
|
67
|
+
// Reject the connection if no valid token arrives within 2 seconds.
|
|
68
|
+
let authenticated = false;
|
|
69
|
+
let authBuf = '';
|
|
70
|
+
|
|
71
|
+
const authTimeout = setTimeout(() => {
|
|
72
|
+
if (!authenticated) {
|
|
73
|
+
this._logDebug('Viewer auth timeout — closing connection.');
|
|
74
|
+
try { socket.destroy(); } catch {}
|
|
75
|
+
}
|
|
76
|
+
}, 2000);
|
|
77
|
+
|
|
78
|
+
const onAuthData = (data) => {
|
|
79
|
+
authBuf += data.toString();
|
|
80
|
+
const newlineIdx = authBuf.indexOf('\n');
|
|
81
|
+
if (newlineIdx === -1) {
|
|
82
|
+
// Accumulated too much data without a newline — reject
|
|
83
|
+
if (authBuf.length > 256) {
|
|
84
|
+
clearTimeout(authTimeout);
|
|
85
|
+
this._logDebug('Viewer auth buffer overflow — closing connection.');
|
|
86
|
+
try { socket.destroy(); } catch {}
|
|
87
|
+
}
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const token = authBuf.slice(0, newlineIdx).trim();
|
|
92
|
+
const remaining = authBuf.slice(newlineIdx + 1);
|
|
93
|
+
clearTimeout(authTimeout);
|
|
94
|
+
socket.removeListener('data', onAuthData);
|
|
95
|
+
|
|
96
|
+
if (token !== this._token) {
|
|
97
|
+
this._logDebug('Viewer auth failed — wrong token.');
|
|
98
|
+
try { socket.destroy(); } catch {}
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Authenticated — promote to active viewer
|
|
103
|
+
authenticated = true;
|
|
104
|
+
this._socket = socket;
|
|
105
|
+
this._log('Host viewer connected (authenticated).');
|
|
106
|
+
|
|
107
|
+
// Install the real data handler
|
|
108
|
+
let recvBuf = '';
|
|
109
|
+
|
|
110
|
+
// Process any leftover data that arrived after the token line
|
|
111
|
+
if (remaining.length > 0) {
|
|
112
|
+
handleViewerData(remaining);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
socket.on('data', (chunk) => handleViewerData(chunk.toString()));
|
|
116
|
+
|
|
117
|
+
const handleViewerData = (chunk) => {
|
|
118
|
+
// Control messages start with \x00{
|
|
119
|
+
if (recvBuf.length > 0 || chunk.startsWith('\x00{')) {
|
|
120
|
+
recvBuf += chunk;
|
|
121
|
+
let nlIdx;
|
|
122
|
+
while ((nlIdx = recvBuf.indexOf('\n')) !== -1) {
|
|
123
|
+
const line = recvBuf.slice(0, nlIdx).trim();
|
|
124
|
+
recvBuf = recvBuf.slice(nlIdx + 1);
|
|
125
|
+
|
|
126
|
+
if (line.startsWith('\x00')) {
|
|
127
|
+
try {
|
|
128
|
+
const msg = JSON.parse(line.slice(1));
|
|
129
|
+
if (msg.type === 'resize' &&
|
|
130
|
+
Number.isInteger(msg.cols) && msg.cols > 0 &&
|
|
131
|
+
Number.isInteger(msg.rows) && msg.rows > 0) {
|
|
132
|
+
this._onResize(msg.cols, msg.rows);
|
|
133
|
+
this._logDebug(`Viewer resized to ${msg.cols}x${msg.rows}`);
|
|
134
|
+
}
|
|
135
|
+
} catch {}
|
|
136
|
+
} else {
|
|
137
|
+
this._onKeystroke(line + '\n');
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// Flush any non-control data left in the buffer
|
|
141
|
+
if (recvBuf.length > 0 && !recvBuf.startsWith('\x00')) {
|
|
142
|
+
this._onKeystroke(recvBuf);
|
|
143
|
+
recvBuf = '';
|
|
144
|
+
}
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Host keystrokes → PTY (host always has access)
|
|
149
|
+
this._onKeystroke(chunk);
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
socket.on('close', () => {
|
|
153
|
+
this._socket = null;
|
|
154
|
+
this._logDebug('Host viewer disconnected.');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
socket.on('error', () => {
|
|
158
|
+
this._socket = null;
|
|
159
|
+
});
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
socket.on('data', onAuthData);
|
|
163
|
+
|
|
164
|
+
socket.on('error', () => {
|
|
165
|
+
clearTimeout(authTimeout);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
this._server.listen(0, '127.0.0.1', () => {
|
|
170
|
+
const port = this._server.address().port;
|
|
171
|
+
this._logDebug(`Viewer server on port ${port}`);
|
|
172
|
+
this._openTerminal(port, this._token);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
this._server.on('error', (err) => {
|
|
176
|
+
this._log(`Viewer server error: ${err.message}`);
|
|
177
|
+
this._server = null;
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Write data to the viewer socket (for mirroring PTY output).
|
|
183
|
+
*
|
|
184
|
+
* @param {string|Buffer} data
|
|
185
|
+
*/
|
|
186
|
+
write(data) {
|
|
187
|
+
if (this._socket) {
|
|
188
|
+
try { this._socket.write(data); } catch {}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Clean up the viewer server and socket.
|
|
194
|
+
*/
|
|
195
|
+
destroy() {
|
|
196
|
+
if (this._socket) {
|
|
197
|
+
try { this._socket.destroy(); } catch {}
|
|
198
|
+
this._socket = null;
|
|
199
|
+
}
|
|
200
|
+
if (this._server) {
|
|
201
|
+
try { this._server.close(); } catch {}
|
|
202
|
+
this._server = null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Open a new terminal window connected to the viewer server.
|
|
208
|
+
* @private
|
|
209
|
+
*/
|
|
210
|
+
_openTerminal(port, token) {
|
|
211
|
+
if (!/^[A-Za-z0-9_-]+$/.test(this._code)) {
|
|
212
|
+
this._log('Invalid session code format. Aborting viewer terminal.');
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const viewerScript = path.join(__dirname, 'session-viewer.js');
|
|
217
|
+
const platform = os.platform();
|
|
218
|
+
const portStr = String(port);
|
|
219
|
+
const nodeArgs = [viewerScript, portStr, this._code, token];
|
|
220
|
+
|
|
221
|
+
if (platform === 'win32') {
|
|
222
|
+
// spawn with 'cmd' to open a new window; arguments are passed as an
|
|
223
|
+
// array so the install path is never interpreted by the shell.
|
|
224
|
+
spawn('cmd', ['/c', 'start', `PeerTerm - ${this._code}`, 'cmd', '/c', 'node', ...nodeArgs], {
|
|
225
|
+
stdio: 'ignore',
|
|
226
|
+
detached: true,
|
|
227
|
+
windowsHide: false,
|
|
228
|
+
}).unref();
|
|
229
|
+
} else if (platform === 'darwin') {
|
|
230
|
+
// osascript receives the AppleScript source as a single -e argument;
|
|
231
|
+
// node args are baked into the script string, but execFile does NOT
|
|
232
|
+
// invoke a shell so the outer path cannot break out.
|
|
233
|
+
const script = `tell app "Terminal" to do script "node '${viewerScript.replace(/'/g, "'\\''")}' ${portStr} ${this._code} ${token}"`;
|
|
234
|
+
execFile('osascript', ['-e', script], { stdio: 'ignore' }, () => {});
|
|
235
|
+
} else {
|
|
236
|
+
// Linux: try common terminal emulators in order, falling through on
|
|
237
|
+
// failure. Each call uses execFile (no shell), so paths with
|
|
238
|
+
// metacharacters are safe.
|
|
239
|
+
const tryTerminals = [
|
|
240
|
+
['x-terminal-emulator', ['-e', 'node', ...nodeArgs]],
|
|
241
|
+
['gnome-terminal', ['--', 'node', ...nodeArgs]],
|
|
242
|
+
['xterm', ['-e', 'node', ...nodeArgs]],
|
|
243
|
+
];
|
|
244
|
+
|
|
245
|
+
const tryNext = (index) => {
|
|
246
|
+
if (index >= tryTerminals.length) {
|
|
247
|
+
this._log('Could not open any terminal emulator for viewer.');
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
const [cmd, args] = tryTerminals[index];
|
|
251
|
+
execFile(cmd, args, { stdio: 'ignore' }, (err) => {
|
|
252
|
+
if (err) tryNext(index + 1);
|
|
253
|
+
});
|
|
254
|
+
};
|
|
255
|
+
tryNext(0);
|
|
256
|
+
}
|
|
257
|
+
this._log('Opening terminal viewer...');
|
|
258
|
+
}
|
|
259
|
+
}
|