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/bin/peer-term.js +1 -1
- package/package.json +1 -1
- package/src/cli.js +128 -0
- package/src/config.js +91 -0
- package/src/file-transfer.js +230 -0
- package/src/index.js +269 -1032
- package/src/prompts.js +143 -0
- package/src/ui.js +142 -142
- package/src/utils.js +113 -0
- package/src/viewer.js +259 -0
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
|
+
}
|