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/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
+ }