ghostterm 1.3.0 → 2.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.
- package/CHANGELOG.md +20 -101
- package/README.md +25 -267
- package/bin/ghostterm-p2p.js +306 -0
- package/lib/auth.js +152 -0
- package/lib/pty-manager.js +336 -0
- package/lib/webrtc-peer.js +193 -0
- package/package.json +23 -19
- package/bin/ghostterm.js +0 -726
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const pty = require('node-pty');
|
|
6
|
+
const EventEmitter = require('events');
|
|
7
|
+
|
|
8
|
+
const MAX_SESSIONS = 4;
|
|
9
|
+
const BUFFER_MAX = 512 * 1024; // 512KB per session
|
|
10
|
+
const BUFFER_TRIM_TO = 384 * 1024; // trim to 384KB
|
|
11
|
+
const FLUSH_INTERVAL = 50; // 50ms output batching
|
|
12
|
+
|
|
13
|
+
function defaultShell() {
|
|
14
|
+
if (process.platform === 'win32') {
|
|
15
|
+
// Use PowerShell if available (more reliable than cmd.exe for conpty)
|
|
16
|
+
const ps = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe';
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
if (fs.existsSync(ps)) return ps;
|
|
19
|
+
return process.env.COMSPEC || 'cmd.exe';
|
|
20
|
+
}
|
|
21
|
+
return process.env.SHELL || '/bin/bash';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function defaultCwd() {
|
|
25
|
+
const desktop = path.join(os.homedir(), 'Desktop');
|
|
26
|
+
try {
|
|
27
|
+
require('fs').accessSync(desktop);
|
|
28
|
+
return desktop;
|
|
29
|
+
} catch {
|
|
30
|
+
return os.homedir();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
class PtySession {
|
|
35
|
+
constructor(id, cols, rows) {
|
|
36
|
+
this.id = id;
|
|
37
|
+
this.cols = cols || 80;
|
|
38
|
+
this.rows = rows || 24;
|
|
39
|
+
this.buffer = '';
|
|
40
|
+
this.bufferSeq = 0;
|
|
41
|
+
this.ptyProcess = null;
|
|
42
|
+
this._flushTimer = null;
|
|
43
|
+
this._pendingOutput = '';
|
|
44
|
+
this._onOutput = null; // set by manager
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
start() {
|
|
48
|
+
const shell = defaultShell();
|
|
49
|
+
const args = [];
|
|
50
|
+
const env = { ...process.env, TERM: 'xterm-256color' };
|
|
51
|
+
// Remove Claude Code env vars that prevent nested sessions
|
|
52
|
+
delete env.CLAUDECODE;
|
|
53
|
+
delete env.CLAUDE_CODE;
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
this.ptyProcess = pty.spawn(shell, args, {
|
|
57
|
+
name: 'xterm-256color',
|
|
58
|
+
cols: this.cols,
|
|
59
|
+
rows: this.rows,
|
|
60
|
+
cwd: defaultCwd(),
|
|
61
|
+
env,
|
|
62
|
+
});
|
|
63
|
+
} catch (e) {
|
|
64
|
+
console.error('Failed to spawn PTY:', e.message);
|
|
65
|
+
if (this._onOutput) {
|
|
66
|
+
this._onOutput(this.id, `\r\n\x1b[31mFailed to start terminal: ${e.message}\x1b[0m\r\n`, null, null);
|
|
67
|
+
this._onOutput(this.id, null, 1, null);
|
|
68
|
+
}
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
this.ptyProcess.onData((data) => {
|
|
73
|
+
this.buffer += data;
|
|
74
|
+
this.bufferSeq += data.length;
|
|
75
|
+
|
|
76
|
+
// Trim buffer if too large
|
|
77
|
+
if (this.buffer.length > BUFFER_MAX) {
|
|
78
|
+
this.buffer = this.buffer.slice(-BUFFER_TRIM_TO);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Send immediately (like relay version) instead of batching
|
|
82
|
+
if (this._onOutput) {
|
|
83
|
+
this._onOutput(this.id, data, null, null);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
this.ptyProcess.onExit(({ exitCode, signal }) => {
|
|
88
|
+
this._flush();
|
|
89
|
+
if (this._onOutput) {
|
|
90
|
+
this._onOutput(this.id, null, exitCode, signal);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
_flush() {
|
|
96
|
+
this._flushTimer = null;
|
|
97
|
+
if (this._pendingOutput && this._onOutput) {
|
|
98
|
+
this._onOutput(this.id, this._pendingOutput, null, null);
|
|
99
|
+
this._pendingOutput = '';
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
write(data) {
|
|
104
|
+
if (this.ptyProcess) {
|
|
105
|
+
this.ptyProcess.write(data);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
resize(cols, rows) {
|
|
110
|
+
this.cols = cols;
|
|
111
|
+
this.rows = rows;
|
|
112
|
+
if (this.ptyProcess) {
|
|
113
|
+
try {
|
|
114
|
+
this.ptyProcess.resize(cols, rows);
|
|
115
|
+
} catch {}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
kill() {
|
|
120
|
+
if (this._flushTimer) {
|
|
121
|
+
clearTimeout(this._flushTimer);
|
|
122
|
+
this._flushTimer = null;
|
|
123
|
+
}
|
|
124
|
+
if (this.ptyProcess) {
|
|
125
|
+
try {
|
|
126
|
+
this.ptyProcess.kill();
|
|
127
|
+
} catch {}
|
|
128
|
+
this.ptyProcess = null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
class PtyManager extends EventEmitter {
|
|
134
|
+
constructor() {
|
|
135
|
+
super();
|
|
136
|
+
this.sessions = new Map(); // id -> PtySession
|
|
137
|
+
this._nextId = 1;
|
|
138
|
+
this._attachedId = null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Handle incoming message from mobile client.
|
|
143
|
+
* Returns response messages to send back.
|
|
144
|
+
*/
|
|
145
|
+
handleMessage(msg) {
|
|
146
|
+
const responses = [];
|
|
147
|
+
|
|
148
|
+
switch (msg.type) {
|
|
149
|
+
case 'create-session': {
|
|
150
|
+
if (this.sessions.size >= MAX_SESSIONS) {
|
|
151
|
+
responses.push({ type: 'error', message: `Max ${MAX_SESSIONS} sessions` });
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
const id = String(this._nextId++);
|
|
155
|
+
const session = new PtySession(id, msg.cols || 80, msg.rows || 24);
|
|
156
|
+
session._onOutput = (sid, data, exitCode, signal) => {
|
|
157
|
+
if (data !== null) {
|
|
158
|
+
this.emit('send', { type: 'output', sessionId: sid, data });
|
|
159
|
+
}
|
|
160
|
+
if (exitCode !== null || signal !== null) {
|
|
161
|
+
this.emit('send', { type: 'exit', sessionId: sid, exitCode, signal });
|
|
162
|
+
this.sessions.delete(sid);
|
|
163
|
+
if (this._attachedId === sid) {
|
|
164
|
+
this._attachedId = null;
|
|
165
|
+
}
|
|
166
|
+
this._broadcastSessions();
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
session.start();
|
|
170
|
+
this.sessions.set(id, session);
|
|
171
|
+
|
|
172
|
+
// Auto-attach to new session
|
|
173
|
+
this._attachedId = id;
|
|
174
|
+
responses.push({ type: 'sessions', sessions: this._sessionList() });
|
|
175
|
+
responses.push({ type: 'attached', sessionId: id });
|
|
176
|
+
|
|
177
|
+
// Send initial history if any
|
|
178
|
+
if (session.buffer) {
|
|
179
|
+
responses.push({ type: 'history', sessionId: id, data: session.buffer, bufferSeq: session.bufferSeq });
|
|
180
|
+
}
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
case 'close-session': {
|
|
185
|
+
const id = msg.sessionId || msg.data;
|
|
186
|
+
const session = this.sessions.get(id);
|
|
187
|
+
if (session) {
|
|
188
|
+
session.kill();
|
|
189
|
+
this.sessions.delete(id);
|
|
190
|
+
if (this._attachedId === id) {
|
|
191
|
+
this._attachedId = null;
|
|
192
|
+
}
|
|
193
|
+
responses.push({ type: 'exit', sessionId: id, exitCode: 0 });
|
|
194
|
+
responses.push({ type: 'sessions', sessions: this._sessionList() });
|
|
195
|
+
}
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
case 'attach': {
|
|
200
|
+
const id = msg.sessionId || msg.data;
|
|
201
|
+
const session = this.sessions.get(id);
|
|
202
|
+
if (session) {
|
|
203
|
+
this._attachedId = id;
|
|
204
|
+
responses.push({ type: 'attached', sessionId: id });
|
|
205
|
+
responses.push({ type: 'history', sessionId: id, data: session.buffer, bufferSeq: session.bufferSeq });
|
|
206
|
+
} else {
|
|
207
|
+
responses.push({ type: 'error', message: `Session ${id} not found` });
|
|
208
|
+
}
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
case 'sync': {
|
|
213
|
+
// Delta sync: send only new data since clientSeq (matches relay logic)
|
|
214
|
+
const session = this.sessions.get(msg.sessionId);
|
|
215
|
+
if (session) {
|
|
216
|
+
const clientSeq = msg.clientSeq;
|
|
217
|
+
const bufStart = session.bufferSeq - session.buffer.length;
|
|
218
|
+
if (clientSeq >= session.bufferSeq) {
|
|
219
|
+
// Client is up to date
|
|
220
|
+
responses.push({
|
|
221
|
+
type: 'delta',
|
|
222
|
+
sessionId: msg.sessionId,
|
|
223
|
+
data: '',
|
|
224
|
+
bufferSeq: session.bufferSeq,
|
|
225
|
+
});
|
|
226
|
+
} else if (clientSeq >= bufStart) {
|
|
227
|
+
// Client is behind but within buffer range — send slice
|
|
228
|
+
const offset = clientSeq - bufStart;
|
|
229
|
+
responses.push({
|
|
230
|
+
type: 'delta',
|
|
231
|
+
sessionId: msg.sessionId,
|
|
232
|
+
data: session.buffer.slice(offset),
|
|
233
|
+
bufferSeq: session.bufferSeq,
|
|
234
|
+
});
|
|
235
|
+
} else {
|
|
236
|
+
// Client is too far behind — send full buffer as history
|
|
237
|
+
responses.push({
|
|
238
|
+
type: 'history',
|
|
239
|
+
sessionId: msg.sessionId,
|
|
240
|
+
data: session.buffer,
|
|
241
|
+
bufferSeq: session.bufferSeq,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
case 'input': {
|
|
249
|
+
const targetId = msg.sessionId || this._attachedId;
|
|
250
|
+
const session = targetId ? this.sessions.get(targetId) : null;
|
|
251
|
+
if (session) {
|
|
252
|
+
session.write(msg.data);
|
|
253
|
+
}
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
case 'resize': {
|
|
258
|
+
const targetId = msg.sessionId || this._attachedId;
|
|
259
|
+
const session = targetId ? this.sessions.get(targetId) : null;
|
|
260
|
+
if (session && msg.cols && msg.rows) {
|
|
261
|
+
session.resize(msg.cols, msg.rows);
|
|
262
|
+
}
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
case 'list-sessions': {
|
|
267
|
+
responses.push({ type: 'sessions', sessions: this._sessionList() });
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
case 'file-upload': {
|
|
272
|
+
// Handle file upload: write base64 content to temp file
|
|
273
|
+
this._handleFileUpload(msg, responses);
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
default:
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return responses;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
_handleFileUpload(msg, responses) {
|
|
285
|
+
const fs = require('fs');
|
|
286
|
+
const tmpDir = os.tmpdir();
|
|
287
|
+
const filename = msg.filename || `upload_${Date.now()}${msg.ext || ''}`;
|
|
288
|
+
const filepath = path.join(tmpDir, filename);
|
|
289
|
+
|
|
290
|
+
try {
|
|
291
|
+
const data = Buffer.from(msg.data, 'base64');
|
|
292
|
+
fs.writeFileSync(filepath, data);
|
|
293
|
+
responses.push({
|
|
294
|
+
type: 'file-uploaded',
|
|
295
|
+
path: filepath,
|
|
296
|
+
size: data.length,
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// If attached to a session, optionally paste the path
|
|
300
|
+
if (msg.pastePath && this._attachedId) {
|
|
301
|
+
const session = this.sessions.get(this._attachedId);
|
|
302
|
+
if (session) {
|
|
303
|
+
session.write(filepath);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
} catch (err) {
|
|
307
|
+
responses.push({ type: 'error', message: `File upload failed: ${err.message}` });
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
_sessionList() {
|
|
312
|
+
const list = [];
|
|
313
|
+
for (const [id, session] of this.sessions) {
|
|
314
|
+
list.push({
|
|
315
|
+
id,
|
|
316
|
+
cols: session.cols,
|
|
317
|
+
rows: session.rows,
|
|
318
|
+
attached: id === this._attachedId,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
return list;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
_broadcastSessions() {
|
|
325
|
+
this.emit('send', { type: 'sessions', sessions: this._sessionList() });
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
destroy() {
|
|
329
|
+
for (const [, session] of this.sessions) {
|
|
330
|
+
session.kill();
|
|
331
|
+
}
|
|
332
|
+
this.sessions.clear();
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
module.exports = { PtyManager };
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const nodeDataChannel = require('node-datachannel');
|
|
4
|
+
const EventEmitter = require('events');
|
|
5
|
+
|
|
6
|
+
const ICE_SERVERS = [
|
|
7
|
+
'stun:stun.l.google.com:19302',
|
|
8
|
+
'stun:stun1.l.google.com:19302',
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
const HEARTBEAT_INTERVAL = 5000;
|
|
12
|
+
const HEARTBEAT_TIMEOUT = 15000;
|
|
13
|
+
const SEND_BUFFER_HIGH = 1024 * 1024; // 1MB — pause sending when buffered
|
|
14
|
+
const SEND_BUFFER_LOW = 256 * 1024; // 256KB — resume sending
|
|
15
|
+
const MAX_MESSAGE_SIZE = 64 * 1024; // 64KB — chunk large messages
|
|
16
|
+
|
|
17
|
+
class CompanionWebRTC extends EventEmitter {
|
|
18
|
+
constructor() {
|
|
19
|
+
super();
|
|
20
|
+
this._pc = null;
|
|
21
|
+
this._dc = null;
|
|
22
|
+
this._connected = false;
|
|
23
|
+
this._heartbeatTimer = null;
|
|
24
|
+
this._lastPong = 0;
|
|
25
|
+
this._sendSignaling = null; // set externally
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
get isActive() {
|
|
29
|
+
return this._connected && this._dc !== null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Initialize peer connection and create offer.
|
|
34
|
+
* @param {Function} sendSignaling - function(msg) to send signaling messages via WS
|
|
35
|
+
*/
|
|
36
|
+
async initiate(sendSignaling) {
|
|
37
|
+
this._sendSignaling = sendSignaling;
|
|
38
|
+
|
|
39
|
+
this._pc = new nodeDataChannel.PeerConnection('companion', {
|
|
40
|
+
iceServers: ICE_SERVERS.map(s => s),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
this._pc.onLocalDescription((sdp, type) => {
|
|
44
|
+
this._sendSignaling({ type: 'webrtc_offer', sdp, sdpType: type });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
this._pc.onLocalCandidate((candidate, mid) => {
|
|
48
|
+
this._sendSignaling({ type: 'webrtc_ice', candidate, mid });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
this._pc.onStateChange((state) => {
|
|
52
|
+
this.emit('connectionState', state);
|
|
53
|
+
if (state === 'failed' || state === 'closed') {
|
|
54
|
+
this._onDisconnect();
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
this._pc.onGatheringStateChange((state) => {
|
|
59
|
+
this.emit('gatheringState', state);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Create DataChannel
|
|
63
|
+
this._dc = this._pc.createDataChannel('ghostterm', {
|
|
64
|
+
ordered: true,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
this._dc.onOpen(() => {
|
|
68
|
+
this._connected = true;
|
|
69
|
+
this._lastPong = Date.now();
|
|
70
|
+
this._startHeartbeat();
|
|
71
|
+
this.emit('open');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
this._dc.onClosed(() => {
|
|
75
|
+
this._onDisconnect();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
this._dc.onError((err) => {
|
|
79
|
+
this.emit('error', err);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
this._dc.onMessage((data) => {
|
|
83
|
+
this._handleMessage(data);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Handle incoming signaling message from the mobile side.
|
|
89
|
+
*/
|
|
90
|
+
handleSignaling(msg) {
|
|
91
|
+
if (!this._pc) return;
|
|
92
|
+
|
|
93
|
+
if (msg.type === 'webrtc_answer') {
|
|
94
|
+
this._pc.setRemoteDescription(msg.sdp, msg.sdpType || 'answer');
|
|
95
|
+
} else if (msg.type === 'webrtc_ice') {
|
|
96
|
+
this._pc.addRemoteCandidate(msg.candidate, msg.mid || '0');
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Send data over the DataChannel with flow control.
|
|
102
|
+
* Returns false if buffer is full (backpressure).
|
|
103
|
+
*/
|
|
104
|
+
send(data) {
|
|
105
|
+
if (!this._dc || !this._connected) return false;
|
|
106
|
+
try {
|
|
107
|
+
// Check backpressure — drop if buffer is overwhelmed
|
|
108
|
+
const buffered = typeof this._dc.bufferedAmount === 'function'
|
|
109
|
+
? this._dc.bufferedAmount() : 0;
|
|
110
|
+
if (buffered > SEND_BUFFER_HIGH) {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const str = typeof data === 'string' ? data : JSON.stringify(data);
|
|
115
|
+
this._dc.sendMessage(str);
|
|
116
|
+
return true;
|
|
117
|
+
} catch (err) {
|
|
118
|
+
this.emit('error', err);
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Close the connection.
|
|
125
|
+
*/
|
|
126
|
+
close() {
|
|
127
|
+
this._stopHeartbeat();
|
|
128
|
+
this._connected = false;
|
|
129
|
+
if (this._dc) {
|
|
130
|
+
try { this._dc.close(); } catch {}
|
|
131
|
+
this._dc = null;
|
|
132
|
+
}
|
|
133
|
+
if (this._pc) {
|
|
134
|
+
try { this._pc.close(); } catch {}
|
|
135
|
+
this._pc = null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
_handleMessage(raw) {
|
|
140
|
+
// Handle heartbeat
|
|
141
|
+
if (raw === '__pong__') {
|
|
142
|
+
this._lastPong = Date.now();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (raw === '__ping__') {
|
|
146
|
+
this.send('__pong__');
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
const msg = JSON.parse(raw);
|
|
152
|
+
this.emit('message', msg);
|
|
153
|
+
} catch {
|
|
154
|
+
// Binary or non-JSON data
|
|
155
|
+
this.emit('data', raw);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
_startHeartbeat() {
|
|
160
|
+
this._stopHeartbeat();
|
|
161
|
+
this._heartbeatTimer = setInterval(() => {
|
|
162
|
+
if (!this._connected) return;
|
|
163
|
+
|
|
164
|
+
// Check timeout
|
|
165
|
+
if (Date.now() - this._lastPong > HEARTBEAT_TIMEOUT) {
|
|
166
|
+
this.emit('timeout');
|
|
167
|
+
this._onDisconnect();
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Send ping
|
|
172
|
+
try {
|
|
173
|
+
if (this._dc) this._dc.sendMessage('__ping__');
|
|
174
|
+
} catch {}
|
|
175
|
+
}, HEARTBEAT_INTERVAL);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
_stopHeartbeat() {
|
|
179
|
+
if (this._heartbeatTimer) {
|
|
180
|
+
clearInterval(this._heartbeatTimer);
|
|
181
|
+
this._heartbeatTimer = null;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
_onDisconnect() {
|
|
186
|
+
if (!this._connected) return;
|
|
187
|
+
this._connected = false;
|
|
188
|
+
this._stopHeartbeat();
|
|
189
|
+
this.emit('close');
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
module.exports = { CompanionWebRTC };
|
package/package.json
CHANGED
|
@@ -1,31 +1,35 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ghostterm",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Control your PC terminal from your phone — direct P2P, no server in between",
|
|
5
5
|
"bin": {
|
|
6
|
-
"ghostterm": "bin/ghostterm.js"
|
|
7
|
-
},
|
|
8
|
-
"scripts": {
|
|
9
|
-
"start": "node bin/ghostterm.js"
|
|
6
|
+
"ghostterm": "bin/ghostterm-p2p.js"
|
|
10
7
|
},
|
|
8
|
+
"files": [
|
|
9
|
+
"bin/",
|
|
10
|
+
"lib/",
|
|
11
|
+
"README.md",
|
|
12
|
+
"CHANGELOG.md"
|
|
13
|
+
],
|
|
11
14
|
"dependencies": {
|
|
15
|
+
"node-datachannel": "^0.12.0",
|
|
12
16
|
"node-pty": "^1.0.0",
|
|
13
|
-
"ws": "^8.
|
|
17
|
+
"ws": "^8.0.0",
|
|
18
|
+
"qrcode-terminal": "^0.12.0"
|
|
19
|
+
},
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=18.0.0"
|
|
14
22
|
},
|
|
15
|
-
"keywords": [
|
|
16
|
-
"claude-code",
|
|
17
|
-
"terminal",
|
|
18
|
-
"mobile",
|
|
19
|
-
"remote",
|
|
20
|
-
"ghostterm"
|
|
21
|
-
],
|
|
22
|
-
"author": "GhostTerm",
|
|
23
23
|
"license": "MIT",
|
|
24
24
|
"repository": {
|
|
25
25
|
"type": "git",
|
|
26
|
-
"url": "git+https://github.com/
|
|
26
|
+
"url": "git+https://github.com/anthropic/ghostterm-p2p.git"
|
|
27
27
|
},
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
-
|
|
28
|
+
"keywords": [
|
|
29
|
+
"terminal",
|
|
30
|
+
"remote",
|
|
31
|
+
"p2p",
|
|
32
|
+
"webrtc",
|
|
33
|
+
"mobile"
|
|
34
|
+
]
|
|
31
35
|
}
|