peer-term 1.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/README.md +52 -0
- package/bin/peer-term.js +53 -0
- package/package.json +47 -0
- package/src/check-deps.js +83 -0
- package/src/crypto.js +147 -0
- package/src/index.js +839 -0
- package/src/logger.js +102 -0
- package/src/session-viewer.js +79 -0
- package/src/ui.js +137 -0
- package/src/webrtc.js +309 -0
package/src/logger.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PeerTerm — Logger Module
|
|
3
|
+
*
|
|
4
|
+
* Structured logger with three levels: INFO, WARN, ERROR.
|
|
5
|
+
* - Timestamps: [HH:MM:SS]
|
|
6
|
+
* - Error logs written to ~/.peerterm/logs/error.log
|
|
7
|
+
* - --verbose flag enables DEBUG level
|
|
8
|
+
* - Stack traces only go to file, never to terminal
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import os from 'os';
|
|
14
|
+
|
|
15
|
+
const LOG_DIR = path.join(os.homedir(), '.peerterm', 'logs');
|
|
16
|
+
const ERROR_LOG_PATH = path.join(LOG_DIR, 'error.log');
|
|
17
|
+
|
|
18
|
+
let verboseMode = false;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Ensure the log directory exists.
|
|
22
|
+
*/
|
|
23
|
+
function ensureLogDir() {
|
|
24
|
+
try {
|
|
25
|
+
fs.mkdirSync(LOG_DIR, { recursive: true });
|
|
26
|
+
} catch {
|
|
27
|
+
// Silent — if we can't create the log dir, we just skip file logging
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Format current time as HH:MM:SS.
|
|
33
|
+
*/
|
|
34
|
+
function timestamp() {
|
|
35
|
+
const now = new Date();
|
|
36
|
+
const hh = String(now.getHours()).padStart(2, '0');
|
|
37
|
+
const mm = String(now.getMinutes()).padStart(2, '0');
|
|
38
|
+
const ss = String(now.getSeconds()).padStart(2, '0');
|
|
39
|
+
return `${hh}:${mm}:${ss}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Append an error entry to ~/.peerterm/logs/error.log.
|
|
44
|
+
*/
|
|
45
|
+
function writeToErrorLog(message, stack) {
|
|
46
|
+
try {
|
|
47
|
+
ensureLogDir();
|
|
48
|
+
const ts = new Date().toISOString();
|
|
49
|
+
const entry = `[${ts}] ${message}\n${stack ? stack + '\n' : ''}\n`;
|
|
50
|
+
fs.appendFileSync(ERROR_LOG_PATH, entry);
|
|
51
|
+
} catch {
|
|
52
|
+
// Silent — don't crash because of logging
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const logger = {
|
|
57
|
+
/**
|
|
58
|
+
* Enable or disable verbose (DEBUG) mode.
|
|
59
|
+
*/
|
|
60
|
+
setVerbose(enabled) {
|
|
61
|
+
verboseMode = enabled;
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* INFO — always shown. Key events the user should see.
|
|
66
|
+
*/
|
|
67
|
+
info(msg) {
|
|
68
|
+
console.log(` [${timestamp()}] INFO ${msg}`);
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* WARN — always shown. Non-fatal issues.
|
|
73
|
+
*/
|
|
74
|
+
warn(msg) {
|
|
75
|
+
console.log(` [${timestamp()}] WARN ${msg}`);
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* ERROR — always shown in terminal (human-readable message only).
|
|
80
|
+
* Stack trace is written to ~/.peerterm/logs/error.log.
|
|
81
|
+
*/
|
|
82
|
+
error(msg, err) {
|
|
83
|
+
console.error(` [${timestamp()}] ERROR ${msg}`);
|
|
84
|
+
if (err && err.stack) {
|
|
85
|
+
writeToErrorLog(msg, err.stack);
|
|
86
|
+
} else {
|
|
87
|
+
writeToErrorLog(msg);
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* DEBUG — only shown when --verbose is enabled.
|
|
93
|
+
*/
|
|
94
|
+
debug(msg) {
|
|
95
|
+
if (verboseMode) {
|
|
96
|
+
console.log(` [${timestamp()}] DEBUG ${msg}`);
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export default logger;
|
|
102
|
+
export { writeToErrorLog, ERROR_LOG_PATH };
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* PeerTerm Session Viewer
|
|
4
|
+
*
|
|
5
|
+
* Opens in a new terminal window and connects to the host's PTY
|
|
6
|
+
* via a local TCP socket. Gives the host a direct terminal view.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import net from 'net';
|
|
10
|
+
|
|
11
|
+
const port = parseInt(process.argv[2], 10);
|
|
12
|
+
|
|
13
|
+
if (!port || isNaN(port) || port < 1 || port > 65535) {
|
|
14
|
+
console.error('Usage: session-viewer.js <port>\nPort must be a number between 1 and 65535.');
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function cleanup(code, msg) {
|
|
19
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
20
|
+
if (msg) {
|
|
21
|
+
if (code !== 0) console.error(msg);
|
|
22
|
+
else console.log(msg);
|
|
23
|
+
}
|
|
24
|
+
process.exit(code);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
process.on('SIGINT', () => cleanup(0, '\n Session terminated by signal.'));
|
|
28
|
+
process.on('SIGTERM', () => cleanup(0, '\n Session terminated by signal.'));
|
|
29
|
+
|
|
30
|
+
function formatResizeMessage(cols, rows) {
|
|
31
|
+
return `\x00${JSON.stringify({ type: 'resize', cols, rows })}\n`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const client = net.connect({ port, host: '127.0.0.1' }, () => {
|
|
35
|
+
client.setTimeout(0); // Clear timeout once connected
|
|
36
|
+
// Send initial terminal size
|
|
37
|
+
const cols = process.stdout.isTTY ? (process.stdout.columns || 80) : 80;
|
|
38
|
+
const rows = process.stdout.isTTY ? (process.stdout.rows || 24) : 24;
|
|
39
|
+
client.write(formatResizeMessage(cols, rows));
|
|
40
|
+
|
|
41
|
+
// Enter raw mode
|
|
42
|
+
if (process.stdin.isTTY) {
|
|
43
|
+
process.stdin.setRawMode(true);
|
|
44
|
+
}
|
|
45
|
+
process.stdin.resume();
|
|
46
|
+
|
|
47
|
+
// Host keystrokes → PTY
|
|
48
|
+
process.stdin.on('data', (data) => {
|
|
49
|
+
client.write(data);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// PTY output → host terminal
|
|
53
|
+
client.on('data', (data) => {
|
|
54
|
+
process.stdout.write(data);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Forward terminal resize
|
|
58
|
+
if (process.stdout.isTTY) {
|
|
59
|
+
process.stdout.on('resize', () => {
|
|
60
|
+
const cols = process.stdout.columns || 80;
|
|
61
|
+
const rows = process.stdout.rows || 24;
|
|
62
|
+
client.write(formatResizeMessage(cols, rows));
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
client.setTimeout(5000);
|
|
67
|
+
|
|
68
|
+
client.on('timeout', () => {
|
|
69
|
+
client.destroy();
|
|
70
|
+
cleanup(1, ' Connection timed out.');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
client.on('close', () => {
|
|
74
|
+
cleanup(0, '\n Session ended.');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
client.on('error', (err) => {
|
|
78
|
+
cleanup(1, ` Connection failed: ${err.message}`);
|
|
79
|
+
});
|
package/src/ui.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PeerTerm — CLI UI Module
|
|
3
|
+
*
|
|
4
|
+
* Handles all terminal output formatting:
|
|
5
|
+
* - ASCII art banner
|
|
6
|
+
* - Session code display box
|
|
7
|
+
* - Help text
|
|
8
|
+
* - Version display
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import { fileURLToPath } from 'url';
|
|
14
|
+
|
|
15
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
16
|
+
const __dirname = path.dirname(__filename);
|
|
17
|
+
|
|
18
|
+
// ─── ASCII Banner ────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
const BANNER = `
|
|
21
|
+
██████╗ ███████╗███████╗██████╗ ████████╗███████╗██████╗ ███╗ ███╗
|
|
22
|
+
██╔══██╗██╔════╝██╔════╝██╔══██╗╚══██╔══╝██╔════╝██╔══██╗████╗ ████║
|
|
23
|
+
██████╔╝█████╗ █████╗ ██████╔╝ ██║ █████╗ ██████╔╝██╔████╔██║
|
|
24
|
+
██╔═══╝ ██╔══╝ ██╔══╝ ██╔══██╗ ██║ ██╔══╝ ██╔══██╗██║╚██╔╝██║
|
|
25
|
+
██║ ███████╗███████╗██║ ██║ ██║ ███████╗██║ ██║██║ ╚═╝ ██║
|
|
26
|
+
╚═╝ ╚══════╝╚══════╝╚═╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝
|
|
27
|
+
Terminal sharing. Instant. Encrypted.`;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Print the startup ASCII art banner.
|
|
31
|
+
*/
|
|
32
|
+
export function printBanner() {
|
|
33
|
+
console.log(BANNER);
|
|
34
|
+
console.log('');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ─── Session Code Box ────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Print the bordered session information box.
|
|
41
|
+
*
|
|
42
|
+
* @param {Object} opts
|
|
43
|
+
* @param {string} opts.code - 6-digit session code
|
|
44
|
+
* @param {string} opts.expiry - Formatted expiry string (e.g. "5 minute(s)")
|
|
45
|
+
* @param {string} opts.mode - "Read-Write" or "Read-Only"
|
|
46
|
+
* @param {string} opts.shell - Shell path (e.g. "/bin/zsh")
|
|
47
|
+
* @param {string} [opts.startPath] - Starting directory for the session
|
|
48
|
+
* @param {string} [opts.shareUrl] - URL to share (e.g. "https://peerterm.dev")
|
|
49
|
+
*/
|
|
50
|
+
export function printSessionBox({ code, expiry, mode, shell, startPath, shareUrl }) {
|
|
51
|
+
const spacedCode = code.split('').join(' ');
|
|
52
|
+
const url = shareUrl || 'https://peerterm.dev';
|
|
53
|
+
|
|
54
|
+
// Truncate long shell paths to keep box readable
|
|
55
|
+
const shellDisplay = shell.length > 24 ? '...' + shell.slice(-21) : shell;
|
|
56
|
+
|
|
57
|
+
// Truncate long start paths similarly
|
|
58
|
+
const homeDir = process.env.HOME || process.env.USERPROFILE || '';
|
|
59
|
+
let pathDisplay = startPath || homeDir;
|
|
60
|
+
if (homeDir && pathDisplay.startsWith(homeDir)) {
|
|
61
|
+
pathDisplay = '~' + pathDisplay.slice(homeDir.length);
|
|
62
|
+
}
|
|
63
|
+
if (pathDisplay.length > 30) pathDisplay = '...' + pathDisplay.slice(-27);
|
|
64
|
+
|
|
65
|
+
// Calculate dynamic box width based on longest content
|
|
66
|
+
const infoLines = [
|
|
67
|
+
` Session Code: ${spacedCode}`,
|
|
68
|
+
` Expires in: ${expiry}`,
|
|
69
|
+
` Mode: ${mode}`,
|
|
70
|
+
` Shell: ${shellDisplay}`,
|
|
71
|
+
` Path: ${pathDisplay}`,
|
|
72
|
+
];
|
|
73
|
+
const shareLine = ` Share at: ${url}`;
|
|
74
|
+
const allLines = [...infoLines, shareLine];
|
|
75
|
+
const maxLen = Math.max(...allLines.map(l => l.length), 35);
|
|
76
|
+
const innerWidth = maxLen + 3; // padding on right
|
|
77
|
+
|
|
78
|
+
const hr = '─'.repeat(innerWidth);
|
|
79
|
+
const emptyLine = ' '.repeat(innerWidth);
|
|
80
|
+
|
|
81
|
+
console.log('');
|
|
82
|
+
console.log(` ┌${hr}┐`);
|
|
83
|
+
console.log(` │${emptyLine}│`);
|
|
84
|
+
for (const line of infoLines) {
|
|
85
|
+
console.log(` │${line.padEnd(innerWidth)}│`);
|
|
86
|
+
}
|
|
87
|
+
console.log(` │${emptyLine}│`);
|
|
88
|
+
console.log(` │${shareLine.padEnd(innerWidth)}│`);
|
|
89
|
+
console.log(` │${emptyLine}│`);
|
|
90
|
+
console.log(` └${hr}┘`);
|
|
91
|
+
console.log('');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ─── Help Text ───────────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
const HELP_TEXT = `
|
|
97
|
+
Usage: peer-term [options]
|
|
98
|
+
|
|
99
|
+
Options:
|
|
100
|
+
--expiry <time> Session expiry time (e.g. 5m, 30s, 1h) [default: 5m]
|
|
101
|
+
--readonly Share in view-only mode
|
|
102
|
+
--path <dir> Starting directory for the terminal session [default: home]
|
|
103
|
+
--relay <url> Custom relay server URL
|
|
104
|
+
--verbose Enable debug logging
|
|
105
|
+
--help Show this help message
|
|
106
|
+
--version Print version number
|
|
107
|
+
|
|
108
|
+
Examples:
|
|
109
|
+
peer-term Start session with defaults
|
|
110
|
+
peer-term --expiry 10m Custom expiry
|
|
111
|
+
peer-term --readonly View-only session
|
|
112
|
+
peer-term --path ~/projects Start in ~/projects
|
|
113
|
+
peer-term --verbose Debug logs
|
|
114
|
+
peer-term --relay wss://custom Use custom relay server
|
|
115
|
+
`;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Print CLI help text.
|
|
119
|
+
*/
|
|
120
|
+
export function printHelp() {
|
|
121
|
+
console.log(HELP_TEXT);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ─── Version ─────────────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Print the version number from package.json.
|
|
128
|
+
*/
|
|
129
|
+
export function printVersion() {
|
|
130
|
+
try {
|
|
131
|
+
const pkgPath = path.join(__dirname, '..', 'package.json');
|
|
132
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
133
|
+
console.log(`peer-term v${pkg.version}`);
|
|
134
|
+
} catch {
|
|
135
|
+
console.log('peer-term (version unknown)');
|
|
136
|
+
}
|
|
137
|
+
}
|
package/src/webrtc.js
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PeerTerm — Host-side WebRTC Module
|
|
3
|
+
*
|
|
4
|
+
* Manages a WebRTC PeerConnection + DataChannel for direct local P2P
|
|
5
|
+
* terminal data transfer when host and client are on the same LAN.
|
|
6
|
+
*
|
|
7
|
+
* Key design points:
|
|
8
|
+
* - iceServers: [] — no STUN/TURN, only local candidates
|
|
9
|
+
* - ICE candidate parsing detects "typ host" for same-LAN detection
|
|
10
|
+
* - 3-second ICE gathering timeout, 5-second DataChannel open timeout
|
|
11
|
+
* - Emits 'open', 'close', 'message' events for Session integration
|
|
12
|
+
* - Encrypted data flows unchanged — just a different transport
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import nodeDataChannel from 'node-datachannel';
|
|
16
|
+
|
|
17
|
+
// Private IP ranges (same LAN indicators)
|
|
18
|
+
const LOCAL_IP_REGEX = /^(192\.168\.|10\.|172\.(1[6-9]|2[0-9]|3[01])\.)/;
|
|
19
|
+
|
|
20
|
+
// Timeouts
|
|
21
|
+
const ICE_TIMEOUT_MS = 3000;
|
|
22
|
+
const DC_OPEN_TIMEOUT_MS = 5000;
|
|
23
|
+
|
|
24
|
+
export class HostWebRTC {
|
|
25
|
+
constructor(logFn) {
|
|
26
|
+
this._log = logFn || (() => {});
|
|
27
|
+
this._pc = null;
|
|
28
|
+
this._dc = null;
|
|
29
|
+
this._sendSignal = null;
|
|
30
|
+
this._sameLAN = false;
|
|
31
|
+
this._peerSameLAN = false;
|
|
32
|
+
this._dcOpen = false;
|
|
33
|
+
this._closed = false;
|
|
34
|
+
|
|
35
|
+
// Event callbacks
|
|
36
|
+
this._onOpenCb = null;
|
|
37
|
+
this._onCloseCb = null;
|
|
38
|
+
this._onMessageCb = null;
|
|
39
|
+
|
|
40
|
+
// Timeout handles
|
|
41
|
+
this._iceTimer = null;
|
|
42
|
+
this._dcTimer = null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Start WebRTC negotiation — create offer and begin ICE gathering.
|
|
47
|
+
* @param {Function} sendSignal — sends { type: 'signal', payload } via relay WS
|
|
48
|
+
*/
|
|
49
|
+
initiate(sendSignal) {
|
|
50
|
+
this._sendSignal = sendSignal;
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
this._log('[WebRTC] Initiating peer connection...');
|
|
54
|
+
|
|
55
|
+
this._pc = new nodeDataChannel.PeerConnection('host', {
|
|
56
|
+
iceServers: [],
|
|
57
|
+
// Disable mDNS candidates to get raw local IPs
|
|
58
|
+
enableIceUdpMux: true,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// ─── ICE candidate handling ──────────────────────────────────────
|
|
62
|
+
this._pc.onLocalCandidate((candidate, mid) => {
|
|
63
|
+
if (this._closed) return;
|
|
64
|
+
|
|
65
|
+
// Parse candidate type
|
|
66
|
+
const candidateStr = candidate;
|
|
67
|
+
this._checkLocalCandidate(candidateStr);
|
|
68
|
+
|
|
69
|
+
this._sendSignal({
|
|
70
|
+
type: 'signal',
|
|
71
|
+
payload: { kind: 'ice', candidate: candidateStr, mid },
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
this._pc.onStateChange((state) => {
|
|
76
|
+
this._log(`[WebRTC] Connection state: ${state}`);
|
|
77
|
+
if (state === 'disconnected' || state === 'failed' || state === 'closed') {
|
|
78
|
+
this._handleClose();
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
this._pc.onGatheringStateChange((state) => {
|
|
83
|
+
this._log(`[WebRTC] ICE gathering state: ${state}`);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// ─── Create DataChannel ──────────────────────────────────────────
|
|
87
|
+
this._dc = this._pc.createDataChannel('terminal');
|
|
88
|
+
|
|
89
|
+
this._dc.onOpen(() => {
|
|
90
|
+
if (this._closed) return;
|
|
91
|
+
this._clearTimers();
|
|
92
|
+
this._dcOpen = true;
|
|
93
|
+
this._log('[WebRTC] DataChannel open — relay bypassed');
|
|
94
|
+
if (this._onOpenCb) this._onOpenCb();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
this._dc.onClosed(() => {
|
|
98
|
+
if (this._closed) return;
|
|
99
|
+
this._log('[WebRTC] DataChannel closed');
|
|
100
|
+
this._handleClose();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
this._dc.onError((err) => {
|
|
104
|
+
this._log(`[WebRTC] DataChannel error: ${err}`);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
this._dc.onMessage((msg) => {
|
|
108
|
+
if (this._closed || !this._onMessageCb) return;
|
|
109
|
+
// msg can be string or Buffer
|
|
110
|
+
const data = typeof msg === 'string' ? msg : msg.toString();
|
|
111
|
+
this._onMessageCb(data);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ─── Create SDP offer ────────────────────────────────────────────
|
|
115
|
+
this._pc.setLocalDescription();
|
|
116
|
+
|
|
117
|
+
// Wait a tick for the description to be set, then send
|
|
118
|
+
setTimeout(() => {
|
|
119
|
+
if (this._closed) return;
|
|
120
|
+
const desc = this._pc.localDescription();
|
|
121
|
+
if (desc) {
|
|
122
|
+
this._sendSignal({
|
|
123
|
+
type: 'signal',
|
|
124
|
+
payload: { kind: 'offer', sdp: desc.sdp, sdpType: desc.type },
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}, 100);
|
|
128
|
+
|
|
129
|
+
// ─── ICE timeout — 3 seconds ────────────────────────────────────
|
|
130
|
+
this._iceTimer = setTimeout(() => {
|
|
131
|
+
if (this._closed || this._dcOpen) return;
|
|
132
|
+
if (!this._sameLAN || !this._peerSameLAN) {
|
|
133
|
+
this._log('[WebRTC] Not on same LAN — using relay');
|
|
134
|
+
this._sendSignal({
|
|
135
|
+
type: 'signal',
|
|
136
|
+
payload: { kind: 'webrtc-abort' },
|
|
137
|
+
});
|
|
138
|
+
this.close();
|
|
139
|
+
} else {
|
|
140
|
+
// Same LAN confirmed — wait for DataChannel to open
|
|
141
|
+
this._log('[WebRTC] Same LAN detected — waiting for DataChannel...');
|
|
142
|
+
this._dcTimer = setTimeout(() => {
|
|
143
|
+
if (this._closed || this._dcOpen) return;
|
|
144
|
+
this._log('[WebRTC] DataChannel open timeout — falling back to relay');
|
|
145
|
+
this._sendSignal({
|
|
146
|
+
type: 'signal',
|
|
147
|
+
payload: { kind: 'webrtc-abort' },
|
|
148
|
+
});
|
|
149
|
+
this.close();
|
|
150
|
+
}, DC_OPEN_TIMEOUT_MS);
|
|
151
|
+
}
|
|
152
|
+
}, ICE_TIMEOUT_MS);
|
|
153
|
+
|
|
154
|
+
} catch (err) {
|
|
155
|
+
this._log(`[WebRTC] Init failed: ${err.message}`);
|
|
156
|
+
this.close();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Handle incoming signal messages from the peer (via relay).
|
|
162
|
+
*/
|
|
163
|
+
handleSignal(payload) {
|
|
164
|
+
if (this._closed || !this._pc) return;
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
switch (payload.kind) {
|
|
168
|
+
case 'answer':
|
|
169
|
+
this._pc.setRemoteDescription(payload.sdp, payload.sdpType || 'answer');
|
|
170
|
+
break;
|
|
171
|
+
|
|
172
|
+
case 'ice':
|
|
173
|
+
if (payload.candidate) {
|
|
174
|
+
this._checkRemoteCandidate(payload.candidate);
|
|
175
|
+
this._pc.addRemoteCandidate(payload.candidate, payload.mid || '0');
|
|
176
|
+
}
|
|
177
|
+
break;
|
|
178
|
+
|
|
179
|
+
case 'webrtc-abort':
|
|
180
|
+
this._log('[WebRTC] Peer aborted WebRTC — using relay');
|
|
181
|
+
this.close();
|
|
182
|
+
break;
|
|
183
|
+
|
|
184
|
+
default:
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
} catch (err) {
|
|
188
|
+
this._log(`[WebRTC] Signal handling error: ${err.message}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Send data over the DataChannel.
|
|
194
|
+
* @param {string} data — encrypted base64 blob
|
|
195
|
+
*/
|
|
196
|
+
send(data) {
|
|
197
|
+
if (!this._dcOpen || !this._dc || this._closed) return false;
|
|
198
|
+
try {
|
|
199
|
+
this._dc.sendMessage(data);
|
|
200
|
+
return true;
|
|
201
|
+
} catch {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Returns true if the DataChannel is open and active.
|
|
208
|
+
*/
|
|
209
|
+
isActive() {
|
|
210
|
+
return this._dcOpen && !this._closed;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ─── Event registration ──────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
onOpen(cb) { this._onOpenCb = cb; }
|
|
216
|
+
onClose(cb) { this._onCloseCb = cb; }
|
|
217
|
+
onMessage(cb) { this._onMessageCb = cb; }
|
|
218
|
+
|
|
219
|
+
// ─── Cleanup ─────────────────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
close() {
|
|
222
|
+
if (this._closed) return;
|
|
223
|
+
this._closed = true;
|
|
224
|
+
this._dcOpen = false;
|
|
225
|
+
this._clearTimers();
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
if (this._dc) {
|
|
229
|
+
this._dc.close();
|
|
230
|
+
this._dc = null;
|
|
231
|
+
}
|
|
232
|
+
} catch {}
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
if (this._pc) {
|
|
236
|
+
this._pc.close();
|
|
237
|
+
this._pc = null;
|
|
238
|
+
}
|
|
239
|
+
} catch {}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ─── Private helpers ─────────────────────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
_clearTimers() {
|
|
245
|
+
if (this._iceTimer) { clearTimeout(this._iceTimer); this._iceTimer = null; }
|
|
246
|
+
if (this._dcTimer) { clearTimeout(this._dcTimer); this._dcTimer = null; }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
_handleClose() {
|
|
250
|
+
if (this._closed) return;
|
|
251
|
+
const wasOpen = this._dcOpen;
|
|
252
|
+
this._dcOpen = false;
|
|
253
|
+
this._closed = true;
|
|
254
|
+
this._clearTimers();
|
|
255
|
+
|
|
256
|
+
if (wasOpen && this._onCloseCb) {
|
|
257
|
+
this._onCloseCb();
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Check a local ICE candidate for "typ host" (local IP).
|
|
263
|
+
*/
|
|
264
|
+
_checkLocalCandidate(candidateStr) {
|
|
265
|
+
if (this._sameLAN) return;
|
|
266
|
+
if (this._isHostCandidate(candidateStr)) {
|
|
267
|
+
this._sameLAN = true;
|
|
268
|
+
this._log('[WebRTC] Local host-type ICE candidate found');
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Check a remote ICE candidate for "typ host" (peer's local IP).
|
|
274
|
+
*/
|
|
275
|
+
_checkRemoteCandidate(candidateStr) {
|
|
276
|
+
if (this._peerSameLAN) return;
|
|
277
|
+
if (this._isHostCandidate(candidateStr)) {
|
|
278
|
+
this._peerSameLAN = true;
|
|
279
|
+
this._log('[WebRTC] Remote host-type ICE candidate found');
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Parse ICE candidate string to check if it's a "host" type
|
|
285
|
+
* with a local/private IP address.
|
|
286
|
+
*/
|
|
287
|
+
_isHostCandidate(candidateStr) {
|
|
288
|
+
if (!candidateStr) return false;
|
|
289
|
+
// ICE candidate format: candidate:... typ host ...
|
|
290
|
+
const typMatch = candidateStr.match(/typ\s+(\S+)/);
|
|
291
|
+
if (!typMatch) return false;
|
|
292
|
+
const candidateType = typMatch[1];
|
|
293
|
+
if (candidateType !== 'host') return false;
|
|
294
|
+
|
|
295
|
+
// Optionally verify it's a private IP
|
|
296
|
+
const ipMatch = candidateStr.match(/(\d+\.\d+\.\d+\.\d+)/);
|
|
297
|
+
if (ipMatch && LOCAL_IP_REGEX.test(ipMatch[1])) {
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// IPv6 link-local (fe80::) also counts as local
|
|
302
|
+
if (candidateStr.includes('fe80::')) {
|
|
303
|
+
return true;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// If it says "typ host" but we can't parse the IP, still treat as host
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
}
|