mirror of
https://github.com/SrIzan10/vdo.ninja.git
synced 2026-05-01 11:05:24 +00:00
1739 lines
68 KiB
HTML
1739 lines
68 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Manual Message Relay with Compression</title>
|
|
<style>
|
|
body {
|
|
font-family: Arial, sans-serif;
|
|
margin: 0;
|
|
padding: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100vh;
|
|
}
|
|
|
|
.container {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
}
|
|
|
|
.iframe-container {
|
|
flex: 1;
|
|
display: flex;
|
|
}
|
|
|
|
.iframe-half {
|
|
flex: 1;
|
|
position: relative;
|
|
border-right: 2px solid #ccc;
|
|
}
|
|
|
|
.iframe-half:last-child {
|
|
border-right: none;
|
|
}
|
|
|
|
iframe {
|
|
width: 100%;
|
|
height: 100%;
|
|
border: none;
|
|
}
|
|
|
|
.controls {
|
|
background: #f0f0f0;
|
|
padding: 15px;
|
|
border-top: 1px solid #ccc;
|
|
}
|
|
|
|
.control-group {
|
|
margin-bottom: 15px;
|
|
}
|
|
|
|
.control-group:last-child {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
label {
|
|
display: block;
|
|
margin-bottom: 5px;
|
|
font-weight: bold;
|
|
}
|
|
|
|
textarea {
|
|
width: 100%;
|
|
height: 60px;
|
|
font-family: monospace;
|
|
padding: 8px;
|
|
box-sizing: border-box;
|
|
margin-bottom: 5px;
|
|
}
|
|
|
|
button {
|
|
padding: 8px 15px;
|
|
background: #4285f4;
|
|
color: white;
|
|
border: none;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
margin-right: 5px;
|
|
}
|
|
|
|
button:hover {
|
|
background: #3367d6;
|
|
}
|
|
|
|
.status {
|
|
margin-top: 5px;
|
|
font-size: 0.9em;
|
|
color: #666;
|
|
}
|
|
|
|
.debug-output {
|
|
margin-top: 10px;
|
|
border: 1px solid #ddd;
|
|
padding: 10px;
|
|
font-family: monospace;
|
|
font-size: 12px;
|
|
max-height: 950px;
|
|
overflow-y: auto;
|
|
background: #f9f9f9;
|
|
}
|
|
|
|
.debug-output > div {
|
|
margin: 10px 0;
|
|
}
|
|
|
|
.connection-status {
|
|
padding: 8px;
|
|
margin-bottom: 10px;
|
|
background: #eee;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.compression-stats {
|
|
margin-top: 5px;
|
|
color: #666;
|
|
font-size: 0.9em;
|
|
}
|
|
|
|
.compression-toggle {
|
|
margin-top: 10px;
|
|
}
|
|
|
|
.transfer-buttons {
|
|
display: flex;
|
|
justify-content: center;
|
|
padding: 10px 0;
|
|
background: #e0e0e0;
|
|
}
|
|
|
|
.transfer-btn {
|
|
background: #ff5722;
|
|
margin: 0 10px;
|
|
}
|
|
|
|
.transfer-btn:hover {
|
|
background: #e64a19;
|
|
}
|
|
|
|
.iframe-label {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
background: rgba(0,0,0,0.7);
|
|
color: white;
|
|
padding: 4px 8px;
|
|
font-size: 12px;
|
|
z-index: 10;
|
|
}
|
|
|
|
.auto-transfer {
|
|
background: #4caf50;
|
|
}
|
|
|
|
.auto-transfer:hover {
|
|
background: #388e3c;
|
|
}
|
|
|
|
.auto-transfer-active {
|
|
background: #f44336;
|
|
}
|
|
|
|
.auto-transfer-active:hover {
|
|
background: #d32f2f;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<div class="iframe-container" id="iframe-container">
|
|
<div class="iframe-half" id="iframe-left">
|
|
<div class="iframe-label">Left Session</div>
|
|
<!-- Left iframe will be added here via JS -->
|
|
</div>
|
|
<div class="iframe-half" id="iframe-right">
|
|
<div class="iframe-label">Right Session</div>
|
|
<!-- Right iframe will be added here via JS -->
|
|
</div>
|
|
</div>
|
|
|
|
<div class="transfer-buttons">
|
|
<button id="leftToRight" class="transfer-btn">Transfer Left → Right</button>
|
|
<button id="autoTransfer" class="auto-transfer">Enable Auto Transfer</button>
|
|
<button id="rightToLeft" class="transfer-btn">Transfer Right → Left</button>
|
|
</div>
|
|
|
|
<div class="controls">
|
|
<div class="connection-status" id="connectionStatus">
|
|
Connection Status: Waiting for connections...
|
|
</div>
|
|
|
|
<div class="control-group">
|
|
<label for="outputArea">Outgoing messages:</label>
|
|
<textarea id="outputArea" readonly></textarea>
|
|
<button id="debugToggle">Toggle Debug</button>
|
|
<span id="compressionStats" class="compression-stats"></span>
|
|
<div id="outputStatus" class="status"></div>
|
|
<div id="debugOutput" class="debug-output" style="display:none;"></div>
|
|
<div class="compression-toggle">
|
|
<input type="checkbox" id="enableCompression" checked>
|
|
<label for="enableCompression" style="display:inline; font-weight:normal;">Enable Compression</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// URL Params Helper
|
|
(function(w) {
|
|
w.URLSearchParams = w.URLSearchParams || function(searchString) {
|
|
var self = this;
|
|
searchString = searchString.replace("??", "?");
|
|
self.searchString = searchString;
|
|
self.get = function(name) {
|
|
var results = new RegExp('[\?&]' + name + '=([^&#]*)').exec(self.searchString);
|
|
if (results == null) {
|
|
return null;
|
|
} else {
|
|
return decodeURI(results[1]) || 0;
|
|
}
|
|
};
|
|
};
|
|
})(window);
|
|
|
|
function compressIP(ip) {
|
|
if (ip === '127.0.0.1') return 'L';
|
|
if (ip === '0.0.0.0') return 'Z';
|
|
if (ip.startsWith('192.168.')) return 'P' + ip.split('.').slice(2).join('');
|
|
if (ip.startsWith('10.')) return 'T' + ip.split('.').slice(1).join('');
|
|
if (ip.startsWith('172.')) return 'S' + ip.split('.').slice(1).join('');
|
|
const parts = ip.split('.');
|
|
let num = 0;
|
|
for (let i = 0; i < 4; i++) {
|
|
num = num * 256 + parseInt(parts[i]);
|
|
}
|
|
return num.toString(36).toUpperCase();
|
|
}
|
|
|
|
function decompressIP(compressed) {
|
|
if (compressed === 'L') return '127.0.0.1';
|
|
if (compressed === 'Z') return '0.0.0.0';
|
|
if (compressed.startsWith('P')) {
|
|
const suffix = compressed.substring(1);
|
|
if (suffix.length === 0) return '192.168.0.0';
|
|
if (suffix.length === 1) return `192.168.${suffix}.0`;
|
|
return `192.168.${suffix.substring(0, 1)}.${suffix.substring(1)}`;
|
|
}
|
|
if (compressed.startsWith('T')) {
|
|
const suffix = compressed.substring(1);
|
|
if (suffix.length === 0) return '10.0.0.0';
|
|
if (suffix.length === 1) return `10.${suffix}.0.0`;
|
|
if (suffix.length === 2) return `10.${suffix.substring(0, 1)}.${suffix.substring(1)}.0`;
|
|
return `10.${suffix.substring(0, 1)}.${suffix.substring(1, 2)}.${suffix.substring(2)}`;
|
|
}
|
|
if (compressed.startsWith('S')) {
|
|
const suffix = compressed.substring(1);
|
|
if (suffix.length === 0) return '172.0.0.0';
|
|
if (suffix.length === 1) return `172.${suffix}.0.0`;
|
|
if (suffix.length === 2) return `172.${suffix.substring(0, 1)}.${suffix.substring(1)}.0`;
|
|
return `172.${suffix.substring(0, 1)}.${suffix.substring(1, 2)}.${suffix.substring(2)}`;
|
|
}
|
|
const num = parseInt(compressed, 36);
|
|
return [
|
|
(num >> 24) & 0xFF,
|
|
(num >> 16) & 0xFF,
|
|
(num >> 8) & 0xFF,
|
|
num & 0xFF
|
|
].join('.');
|
|
}
|
|
|
|
function priorityToCompact(priority) {
|
|
return priority.toString(36);
|
|
}
|
|
|
|
function compactToPriority(compact) {
|
|
return parseInt(compact, 36);
|
|
}
|
|
|
|
function hexToCompact(hex) {
|
|
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_";
|
|
let result = '';
|
|
for (let i = 0; i < hex.length; i += 6) {
|
|
const chunk = hex.substr(i, 6);
|
|
if (chunk.length < 6) {
|
|
const value = parseInt(chunk, 16);
|
|
result += chars[value % 64];
|
|
if (chunk.length > 2) {
|
|
result += chars[Math.floor(value / 64) % 64];
|
|
}
|
|
} else {
|
|
const value = parseInt(chunk, 16);
|
|
result += chars[value & 0x3F];
|
|
result += chars[(value >> 6) & 0x3F];
|
|
result += chars[(value >> 12) & 0x3F];
|
|
result += chars[(value >> 18) & 0x3F];
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function compactToHex(compact) {
|
|
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_";
|
|
let result = '';
|
|
for (let i = 0; i < compact.length; i += 4) {
|
|
let value = 0;
|
|
for (let j = 0; j < 4 && i + j < compact.length; j++) {
|
|
const char = compact[i + j];
|
|
const charValue = chars.indexOf(char);
|
|
if (charValue === -1) {
|
|
throw new Error(`Invalid character in compact string: ${char}`);
|
|
}
|
|
value |= charValue << (j * 6);
|
|
}
|
|
const hex = value.toString(16).padStart(6, '0');
|
|
result += hex;
|
|
}
|
|
// Ensure result is exactly 64 characters (32 bytes)
|
|
return result.substring(0, 64);
|
|
}
|
|
|
|
function compressSDPForQR(sdp) {
|
|
const iceUfrag = sdp.match(/a=ice-ufrag:([^\r\n]+)/)[1];
|
|
const icePwd = sdp.match(/a=ice-pwd:([^\r\n]+)/)[1];
|
|
const fingerprintMatch = sdp.match(/a=fingerprint:sha-256 ([^\r\n]+)/);
|
|
const fingerprint = fingerprintMatch[1].replace(/:/g, '');
|
|
const isOffer = sdp.match(/a=setup:actpass/) !== null;
|
|
const candidates = [];
|
|
const candidateRegex = /a=candidate:([^\r\n]+)/g;
|
|
let candidateMatch;
|
|
while ((candidateMatch = candidateRegex.exec(sdp)) !== null) {
|
|
const parts = candidateMatch[1].split(' ');
|
|
if (parts[4].includes(':')) continue;
|
|
const foundation = parts[0];
|
|
const component = parts[1];
|
|
const protocol = parts[2].toLowerCase() === 'udp' ? '1' : '2';
|
|
const priority = parseInt(parts[3]);
|
|
const ip = compressIP(parts[4]);
|
|
const port = parseInt(parts[5]);
|
|
let typeCode;
|
|
switch(parts[7]) {
|
|
case 'host': typeCode = '1'; break;
|
|
case 'srflx': typeCode = '2'; break;
|
|
case 'relay': typeCode = '3'; break;
|
|
default: typeCode = '0';
|
|
}
|
|
let relInfo = '';
|
|
const rAddrIndex = parts.indexOf('raddr');
|
|
const rPortIndex = parts.indexOf('rport');
|
|
if (rAddrIndex !== -1 && rPortIndex !== -1 &&
|
|
rAddrIndex + 1 < parts.length && rPortIndex + 1 < parts.length) {
|
|
relInfo = compressIP(parts[rAddrIndex + 1]) + parts[rPortIndex + 1];
|
|
}
|
|
candidates.push(`${foundation}${component}${protocol}${compressNumber(priority)}${ip}${port}${typeCode}${relInfo}`);
|
|
}
|
|
const qrFingerprint = fingerprintToQR(fingerprint);
|
|
let result = `Q${isOffer ? '1' : '0'}${iceUfrag}~${icePwd}~${qrFingerprint}`;
|
|
if (candidates.length > 0) {
|
|
result += `~${candidates.join(',')}`;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function decompressSDPFromQR(compressed) {
|
|
if (!compressed.startsWith('Q')) {
|
|
throw new Error('Unsupported QR compression format');
|
|
}
|
|
const isOffer = compressed[1] === '1';
|
|
const parts = compressed.substring(2).split('~');
|
|
const iceUfrag = parts[0];
|
|
const icePwd = parts[1];
|
|
const fingerprint = qrToFingerprint(parts[2]);
|
|
const formattedFingerprint = fingerprint.match(/.{2}/g).join(':');
|
|
let sdp = [
|
|
'v=0',
|
|
'o=- ' + Math.floor(Date.now()) + ' 2 IN IP4 127.0.0.1',
|
|
's=-',
|
|
't=0 0',
|
|
'a=group:BUNDLE 0',
|
|
'a=extmap-allow-mixed',
|
|
'a=msid-semantic: WMS',
|
|
'm=application 9 UDP/DTLS/SCTP webrtc-datachannel',
|
|
'c=IN IP4 0.0.0.0',
|
|
`a=ice-ufrag:${iceUfrag}`,
|
|
`a=ice-pwd:${icePwd}`,
|
|
'a=ice-options:trickle',
|
|
`a=fingerprint:sha-256 ${formattedFingerprint}`,
|
|
`a=setup:${isOffer ? 'actpass' : 'active'}`,
|
|
'a=mid:0'
|
|
].join('\r\n');
|
|
if (parts.length > 3 && parts[3]) {
|
|
const candidates = parts[3].split(',');
|
|
let candidateLines = '';
|
|
for (const candidate of candidates) {
|
|
let i = 0;
|
|
let j = i;
|
|
while (j < candidate.length && /^\d$/.test(candidate[j])) j++;
|
|
const foundation = candidate.substring(i, j);
|
|
i = j;
|
|
if (i >= candidate.length) continue;
|
|
const component = candidate.substring(i, i + 1);
|
|
i += 1;
|
|
if (i >= candidate.length) continue;
|
|
const protocolCode = candidate.substring(i, i + 1);
|
|
const protocol = protocolCode === '1' ? 'UDP' : 'TCP';
|
|
i += 1;
|
|
if (i >= candidate.length) continue;
|
|
j = i;
|
|
while (j < candidate.length && /^[0-9A-Z]$/.test(candidate[j])) j++;
|
|
const priorityEncoded = candidate.substring(i, j);
|
|
const priority = decompressNumber(priorityEncoded);
|
|
i = j;
|
|
if (i >= candidate.length) continue;
|
|
j = i;
|
|
while (j < candidate.length && !/^\d$/.test(candidate[j]) && candidate[j] !== '1' &&
|
|
candidate[j] !== '2' && candidate[j] !== '3' && candidate[j] !== '0') j++;
|
|
const ipEncoded = candidate.substring(i, j);
|
|
const ip = decompressIP(ipEncoded);
|
|
i = j;
|
|
if (i >= candidate.length) continue;
|
|
j = i;
|
|
while (j < candidate.length && /^\d$/.test(candidate[j])) j++;
|
|
const port = candidate.substring(i, j);
|
|
i = j;
|
|
if (i >= candidate.length) continue;
|
|
const typeCode = candidate.substring(i, i + 1);
|
|
i += 1;
|
|
let type;
|
|
switch(typeCode) {
|
|
case '1': type = 'host'; break;
|
|
case '2': type = 'srflx'; break;
|
|
case '3': type = 'relay'; break;
|
|
default: type = 'unknown';
|
|
}
|
|
candidateLines += `\r\na=candidate:${foundation} ${component} ${protocol} ${priority} ${ip} ${port} typ ${type}`;
|
|
if (i < candidate.length && (type === 'srflx' || type === 'relay')) {
|
|
j = i;
|
|
while (j < candidate.length && !/^\d$/.test(candidate[j])) j++;
|
|
const relatedIPEncoded = candidate.substring(i, j);
|
|
const relatedIP = decompressIP(relatedIPEncoded);
|
|
i = j;
|
|
j = i;
|
|
while (j < candidate.length && /^\d$/.test(candidate[j])) j++;
|
|
const relatedPort = candidate.substring(i, j);
|
|
if (relatedIP && relatedPort) {
|
|
candidateLines += ` raddr ${relatedIP} rport ${relatedPort}`;
|
|
}
|
|
}
|
|
if (protocol === 'TCP') {
|
|
candidateLines += ' tcptype active';
|
|
}
|
|
}
|
|
sdp += candidateLines;
|
|
if (candidateLines) {
|
|
sdp += '\r\na=end-of-candidates';
|
|
}
|
|
}
|
|
return sdp;
|
|
}
|
|
|
|
function compressNumber(num) {
|
|
// Convert to base36 for compactness, prefer uppercase for QR
|
|
return num.toString(36).toUpperCase();
|
|
}
|
|
|
|
function decompressNumber(compressed) {
|
|
return parseInt(compressed, 36);
|
|
}
|
|
|
|
function fingerprintToQR(hex) {
|
|
let result = '';
|
|
for (let i = 0; i < hex.length; i += 4) {
|
|
const chunk = hex.substr(i, 4);
|
|
if (chunk.length < 4) {
|
|
const value = parseInt(chunk, 16);
|
|
result += value.toString(36).toUpperCase().padStart(3, '0').substring(0, 3);
|
|
} else {
|
|
const value = parseInt(chunk, 16);
|
|
result += value.toString(36).toUpperCase().padStart(3, '0').substring(0, 3);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function qrToFingerprint(qrFp) {
|
|
let result = '';
|
|
for (let i = 0; i < qrFp.length; i += 3) {
|
|
const chunk = qrFp.substr(i, 3);
|
|
const value = parseInt(chunk, 36);
|
|
result += value.toString(16).padStart(4, '0').substring(0, 4);
|
|
}
|
|
return result.padEnd(64, '0');
|
|
}
|
|
function compressSDP(sdp) {
|
|
try {
|
|
// Extract key fields from SDP
|
|
const iceUfragMatch = sdp.match(/a=ice-ufrag:([^\r\n]+)/);
|
|
const icePwdMatch = sdp.match(/a=ice-pwd:([^\r\n]+)/);
|
|
const fingerprintMatch = sdp.match(/a=fingerprint:sha-256 ([^\r\n]+)/);
|
|
|
|
if (!iceUfragMatch || !icePwdMatch || !fingerprintMatch) {
|
|
updateDebugOutput("SDP missing required fields");
|
|
throw new Error("SDP missing required fields");
|
|
}
|
|
|
|
const iceUfrag = iceUfragMatch[1];
|
|
const icePwd = icePwdMatch[1];
|
|
const fingerprint = fingerprintMatch[1].replace(/:/g, '');
|
|
const isOffer = sdp.match(/a=setup:actpass/) !== null;
|
|
|
|
// Extract host and srflx candidates only (UDP IPv4 only)
|
|
const candidateRegex = /a=candidate:([^\r\n]+)/g;
|
|
let candidateMatch;
|
|
let hostCandidates = [];
|
|
let srflxCandidates = [];
|
|
|
|
while ((candidateMatch = candidateRegex.exec(sdp)) !== null) {
|
|
const parts = candidateMatch[1].split(' ');
|
|
|
|
// Skip non-UDP, IPv6, mDNS, TCP and relay candidates
|
|
if (parts[2].toLowerCase() !== 'udp' ||
|
|
parts[4].includes(':') ||
|
|
parts[4].includes('.local') ||
|
|
parts[7] === 'relay') {
|
|
continue;
|
|
}
|
|
|
|
// Create compact representation of candidate
|
|
const foundation = parts[0];
|
|
const component = parts[1];
|
|
const priority = priorityToCompact(parseInt(parts[3]));
|
|
const ip = compressIP(parts[4]);
|
|
const port = parts[5];
|
|
const type = parts[7];
|
|
|
|
// Format: foundation|component|priority|ip|port
|
|
let candidateStr = `${foundation}|${component}|${priority}|${ip}|${port}`;
|
|
|
|
// Add related address info for srflx candidates
|
|
if (type === 'srflx') {
|
|
const rAddrIndex = parts.indexOf('raddr');
|
|
const rPortIndex = parts.indexOf('rport');
|
|
|
|
if (rAddrIndex !== -1 && rPortIndex !== -1 &&
|
|
rAddrIndex + 1 < parts.length && rPortIndex + 1 < parts.length) {
|
|
const relatedIP = compressIP(parts[rAddrIndex + 1]);
|
|
candidateStr += `|${relatedIP}|${parts[rPortIndex + 1]}`;
|
|
}
|
|
|
|
srflxCandidates.push(candidateStr);
|
|
} else if (type === 'host') {
|
|
hostCandidates.push(candidateStr);
|
|
}
|
|
}
|
|
|
|
// Compress fingerprint using hexToCompact
|
|
const compactFingerprint = hexToCompact(fingerprint);
|
|
|
|
// Create string format: C2|o/a|ufrag|pwd|fingerprint|host1^host2^...|srflx1^srflx2^...
|
|
let result = `C2|${isOffer ? 'o' : 'a'}|${iceUfrag}|${icePwd}|${compactFingerprint}`;
|
|
|
|
// Add host candidates
|
|
result += '|' + hostCandidates.join('^');
|
|
|
|
// Add srflx candidates
|
|
result += '|' + srflxCandidates.join('^');
|
|
|
|
return result;
|
|
} catch (e) {
|
|
console.error("Error in compressSDP:", e);
|
|
updateDebugOutput(`SDP compression error: ${e.message}`);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
function decompressSDP(compressed) {
|
|
try {
|
|
if (typeof compressed !== 'string') {
|
|
compressed = JSON.stringify(compressed);
|
|
}
|
|
|
|
// Handle old format
|
|
if (compressed.startsWith('{') && compressed.endsWith('}')) {
|
|
try {
|
|
const jsonData = JSON.parse(compressed);
|
|
// This is our old JSON format, call the JSON handler
|
|
return decompressJSONSDP(jsonData);
|
|
} catch (e) {
|
|
// Not valid JSON, continue with string parsing
|
|
updateDebugOutput(`Not valid JSON format: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
// Handle old C1 format
|
|
if (compressed.startsWith('C1|')) {
|
|
return decompressOldSDP(compressed);
|
|
}
|
|
|
|
// Parse the new C2 string format
|
|
// C2|o/a|ufrag|pwd|fingerprint|host1^host2^...|srflx1^srflx2^...
|
|
const parts = compressed.split('|');
|
|
|
|
if (parts[0] !== 'C2' || parts.length < 6) {
|
|
throw new Error('Invalid compressed SDP format');
|
|
}
|
|
|
|
const isOffer = parts[1] === 'o';
|
|
const iceUfrag = parts[2];
|
|
const icePwd = parts[3];
|
|
|
|
// Decompress fingerprint if it doesn't contain colons
|
|
let fingerprint = parts[4];
|
|
if (fingerprint.indexOf(':') === -1) {
|
|
if (fingerprint.length < 32) {
|
|
// This is likely a compact fingerprint, convert back to hex
|
|
fingerprint = compactToHex(fingerprint);
|
|
}
|
|
// Format with colons
|
|
fingerprint = fingerprint.match(/.{2}/g).join(':').toUpperCase();
|
|
}
|
|
|
|
// Build the basic SDP structure
|
|
let sdpLines = [
|
|
'v=0',
|
|
'o=- ' + Math.floor(Date.now()) + ' 2 IN IP4 127.0.0.1',
|
|
's=-',
|
|
't=0 0',
|
|
'a=group:BUNDLE 0',
|
|
'a=extmap-allow-mixed',
|
|
'a=msid-semantic: WMS',
|
|
'm=application 9 UDP/DTLS/SCTP webrtc-datachannel',
|
|
'c=IN IP4 0.0.0.0',
|
|
`a=ice-ufrag:${iceUfrag}`,
|
|
`a=ice-pwd:${icePwd}`,
|
|
'a=ice-options:trickle',
|
|
`a=fingerprint:sha-256 ${fingerprint}`,
|
|
`a=setup:${isOffer ? 'actpass' : 'active'}`,
|
|
'a=mid:0',
|
|
'a=sctp-port:5000',
|
|
'a=max-message-size:262144'
|
|
];
|
|
|
|
// Add host candidates
|
|
if (parts[5] && parts[5] !== '') {
|
|
const hostCandidates = parts[5].split('^');
|
|
for (const candidateStr of hostCandidates) {
|
|
if (!candidateStr) continue;
|
|
|
|
const candParts = candidateStr.split('|');
|
|
if (candParts.length < 5) continue;
|
|
|
|
const foundation = candParts[0];
|
|
const component = candParts[1];
|
|
const priority = compactToPriority(candParts[2]).toString();
|
|
const ip = decompressIP(candParts[3]);
|
|
const port = candParts[4];
|
|
|
|
sdpLines.push(`a=candidate:${foundation} ${component} udp ${priority} ${ip} ${port} typ host generation 0`);
|
|
}
|
|
}
|
|
|
|
// Add srflx candidates
|
|
if (parts[6] && parts[6] !== '') {
|
|
const srflxCandidates = parts[6].split('^');
|
|
for (const candidateStr of srflxCandidates) {
|
|
if (!candidateStr) continue;
|
|
|
|
const candParts = candidateStr.split('|');
|
|
if (candParts.length < 5) continue;
|
|
|
|
const foundation = candParts[0];
|
|
const component = candParts[1];
|
|
const priority = compactToPriority(candParts[2]).toString();
|
|
const ip = decompressIP(candParts[3]);
|
|
const port = candParts[4];
|
|
|
|
let candLine = `a=candidate:${foundation} ${component} udp ${priority} ${ip} ${port} typ srflx`;
|
|
|
|
// Add raddr/rport if available
|
|
if (candParts.length >= 7) {
|
|
const relatedIP = decompressIP(candParts[5]);
|
|
candLine += ` raddr ${relatedIP} rport ${candParts[6]}`;
|
|
}
|
|
|
|
candLine += ' generation 0';
|
|
sdpLines.push(candLine);
|
|
}
|
|
}
|
|
|
|
// Join with proper line endings
|
|
const result = sdpLines.join('\r\n') + '\r\n';
|
|
return result;
|
|
} catch (e) {
|
|
console.error("Error in decompressSDP:", e);
|
|
updateDebugOutput(`SDP decompression error: ${e.message}`);
|
|
throw e;
|
|
}
|
|
}
|
|
// Fallback handler for old C1 format
|
|
function decompressOldSDP(compressed) {
|
|
try {
|
|
const parts = compressed.split('|');
|
|
|
|
const isOffer = parts[1] === 'o';
|
|
const iceUfrag = parts[2];
|
|
const icePwd = parts[3];
|
|
let fingerprint = parts[4];
|
|
|
|
if (fingerprint.indexOf(':') === -1) {
|
|
fingerprint = fingerprint.match(/.{2}/g).join(':').toUpperCase();
|
|
}
|
|
|
|
let sdpLines = [
|
|
'v=0',
|
|
'o=- ' + Math.floor(Date.now()) + ' 2 IN IP4 127.0.0.1',
|
|
's=-',
|
|
't=0 0',
|
|
'a=group:BUNDLE 0',
|
|
'a=extmap-allow-mixed',
|
|
'a=msid-semantic: WMS',
|
|
'm=application 9 UDP/DTLS/SCTP webrtc-datachannel',
|
|
'c=IN IP4 0.0.0.0',
|
|
`a=ice-ufrag:${iceUfrag}`,
|
|
`a=ice-pwd:${icePwd}`,
|
|
'a=ice-options:trickle',
|
|
`a=fingerprint:sha-256 ${fingerprint}`,
|
|
`a=setup:${isOffer ? 'actpass' : 'active'}`,
|
|
'a=mid:0',
|
|
'a=sctp-port:5000',
|
|
'a=max-message-size:262144'
|
|
];
|
|
|
|
return sdpLines.join('\r\n') + '\r\n';
|
|
} catch (e) {
|
|
console.error("Error in decompressOldSDP:", e);
|
|
updateDebugOutput(`Old SDP decompression error: ${e.message}`);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
// Fallback handler for JSON format
|
|
function decompressJSONSDP(jsonData) {
|
|
try {
|
|
const isOffer = jsonData.t === 'o';
|
|
const iceUfrag = jsonData.u;
|
|
const icePwd = jsonData.p;
|
|
|
|
let fingerprint = jsonData.f;
|
|
if (fingerprint.indexOf(':') === -1) {
|
|
fingerprint = fingerprint.match(/.{2}/g).join(':').toUpperCase();
|
|
}
|
|
|
|
let sdpLines = [
|
|
'v=0',
|
|
'o=- ' + Math.floor(Date.now()) + ' 2 IN IP4 127.0.0.1',
|
|
's=-',
|
|
't=0 0',
|
|
'a=group:BUNDLE 0',
|
|
'a=extmap-allow-mixed',
|
|
'a=msid-semantic: WMS',
|
|
'm=application 9 UDP/DTLS/SCTP webrtc-datachannel',
|
|
'c=IN IP4 0.0.0.0',
|
|
`a=ice-ufrag:${iceUfrag}`,
|
|
`a=ice-pwd:${icePwd}`,
|
|
'a=ice-options:trickle',
|
|
`a=fingerprint:sha-256 ${fingerprint}`,
|
|
`a=setup:${isOffer ? 'actpass' : 'active'}`,
|
|
'a=mid:0',
|
|
'a=sctp-port:5000',
|
|
'a=max-message-size:262144'
|
|
];
|
|
|
|
// Add candidates if they exist
|
|
if (jsonData.c && Array.isArray(jsonData.c)) {
|
|
sdpLines = sdpLines.concat(jsonData.c);
|
|
}
|
|
// Add host candidates from h array if it exists
|
|
else if (jsonData.h && Array.isArray(jsonData.h)) {
|
|
for (const c of jsonData.h) {
|
|
if (typeof c === 'string') {
|
|
const parts = c.split(',');
|
|
if (parts.length >= 5) {
|
|
const priority = compactToPriority(parts[2]).toString();
|
|
sdpLines.push(`a=candidate:${parts[0]} ${parts[1]} udp ${priority} ${parts[3]} ${parts[4]} typ host generation 0`);
|
|
}
|
|
} else if (typeof c === 'object') {
|
|
sdpLines.push(`a=candidate:${c.f} ${c.c} udp ${c.p} ${c.i} ${c.o} typ host generation 0`);
|
|
}
|
|
}
|
|
|
|
// Add srflx candidates from s array if it exists
|
|
if (jsonData.s && Array.isArray(jsonData.s)) {
|
|
for (const c of jsonData.s) {
|
|
if (typeof c === 'string') {
|
|
const parts = c.split(',');
|
|
if (parts.length >= 5) {
|
|
const priority = compactToPriority(parts[2]).toString();
|
|
let line = `a=candidate:${parts[0]} ${parts[1]} udp ${priority} ${parts[3]} ${parts[4]} typ srflx`;
|
|
if (parts.length >= 7) {
|
|
line += ` raddr ${parts[5]} rport ${parts[6]}`;
|
|
}
|
|
line += ' generation 0';
|
|
sdpLines.push(line);
|
|
}
|
|
} else if (typeof c === 'object') {
|
|
let line = `a=candidate:${c.f} ${c.c} udp ${c.p} ${c.i} ${c.o} typ srflx`;
|
|
if (c.r && c.s) {
|
|
line += ` raddr ${c.r} rport ${c.s}`;
|
|
}
|
|
line += ' generation 0';
|
|
sdpLines.push(line);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return sdpLines.join('\r\n') + '\r\n';
|
|
} catch (e) {
|
|
console.error("Error in decompressJSONSDP:", e);
|
|
updateDebugOutput(`JSON SDP decompression error: ${e.message}`);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
// Compress ICE candidates to a more compact string format
|
|
function compressICECandidates(candidates) {
|
|
try {
|
|
if (!candidates || !candidates.length) return "";
|
|
|
|
// Filter for valid candidates - only UDP IPv4 host and srflx
|
|
const validCandidates = candidates.filter(c => {
|
|
if (!c || !c.candidate) return false;
|
|
|
|
const parts = c.candidate.split(' ');
|
|
return parts.length >= 8 &&
|
|
parts[2].toLowerCase() === 'udp' &&
|
|
!parts[4].includes(':') &&
|
|
!parts[4].includes('.local') &&
|
|
(parts[7] === 'host' || parts[7] === 'srflx');
|
|
});
|
|
|
|
if (!validCandidates.length) return "";
|
|
|
|
// Separate host and srflx candidates
|
|
const hostCandidates = [];
|
|
const srflxCandidates = [];
|
|
|
|
validCandidates.forEach(c => {
|
|
const parts = c.candidate.split(' ');
|
|
const foundation = parts[0].replace('candidate:', '');
|
|
const component = parts[1];
|
|
const priority = priorityToCompact(parseInt(parts[3]));
|
|
const ip = parts[4];
|
|
const port = parts[5];
|
|
const type = parts[7];
|
|
|
|
// Format: foundation,component,priority,ip,port
|
|
let candidateStr = `${foundation},${component},${priority},${ip},${port}`;
|
|
|
|
// Add related address info for srflx candidates
|
|
if (type === 'srflx') {
|
|
const rAddrIndex = parts.indexOf('raddr');
|
|
const rPortIndex = parts.indexOf('rport');
|
|
|
|
if (rAddrIndex !== -1 && rPortIndex !== -1 &&
|
|
rAddrIndex + 1 < parts.length && rPortIndex + 1 < parts.length) {
|
|
candidateStr += `,${parts[rAddrIndex + 1]},${parts[rPortIndex + 1]}`;
|
|
}
|
|
|
|
srflxCandidates.push(candidateStr);
|
|
} else if (type === 'host') {
|
|
hostCandidates.push(candidateStr);
|
|
}
|
|
});
|
|
|
|
// Format: 'h:' + hosts joined by ^ + '|s:' + srflx joined by ^
|
|
let result = '';
|
|
|
|
if (hostCandidates.length > 0) {
|
|
result += 'h:' + hostCandidates.join('^');
|
|
}
|
|
|
|
if (srflxCandidates.length > 0) {
|
|
if (result) result += '|';
|
|
result += 's:' + srflxCandidates.join('^');
|
|
}
|
|
|
|
return result;
|
|
} catch (e) {
|
|
console.error("Error compressing ICE candidates:", e);
|
|
updateDebugOutput(`ICE compression error: ${e.message}`);
|
|
return "";
|
|
}
|
|
}
|
|
|
|
// Decompress ICE candidates from compact string format
|
|
function decompressICECandidates(compressed) {
|
|
try {
|
|
if (!compressed) return [];
|
|
|
|
const candidates = [];
|
|
|
|
// Handle the old format for backward compatibility
|
|
if (compressed.includes(',') && !compressed.includes('h:') && !compressed.includes('s:')) {
|
|
return decompressOldICEFormat(compressed);
|
|
}
|
|
|
|
const sections = compressed.split('|');
|
|
|
|
for (const section of sections) {
|
|
if (!section) continue;
|
|
|
|
let type, candidateStrs;
|
|
|
|
if (section.startsWith('h:')) {
|
|
type = 'host';
|
|
candidateStrs = section.substring(2).split('^');
|
|
} else if (section.startsWith('s:')) {
|
|
type = 'srflx';
|
|
candidateStrs = section.substring(2).split('^');
|
|
} else {
|
|
// Unexpected format, skip this section
|
|
updateDebugOutput(`Unexpected section format: ${section}`);
|
|
continue;
|
|
}
|
|
|
|
for (const candidateStr of candidateStrs) {
|
|
if (!candidateStr) continue;
|
|
|
|
const parts = candidateStr.split(',');
|
|
if (parts.length < 5) continue;
|
|
|
|
const foundation = parts[0];
|
|
const component = parts[1];
|
|
const priority = compactToPriority(parts[2]);
|
|
const ip = parts[3];
|
|
const port = parts[4];
|
|
|
|
let candidateLine = `candidate:${foundation} ${component} UDP ${priority} ${ip} ${port} typ ${type}`;
|
|
|
|
// Add raddr/rport for srflx if available
|
|
if (type === 'srflx' && parts.length >= 7) {
|
|
candidateLine += ` raddr ${parts[5]} rport ${parts[6]}`;
|
|
}
|
|
|
|
// Add generation
|
|
candidateLine += ' generation 0';
|
|
|
|
candidates.push({
|
|
candidate: candidateLine,
|
|
sdpMid: "0",
|
|
sdpMLineIndex: 0
|
|
});
|
|
}
|
|
}
|
|
|
|
return candidates;
|
|
} catch (e) {
|
|
console.error("Error decompressing ICE candidates:", e);
|
|
updateDebugOutput(`ICE decompression error: ${e.message}`);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
// Handle the old format for backward compatibility
|
|
function decompressOldICEFormat(compressed) {
|
|
try {
|
|
const candidates = [];
|
|
|
|
const oldCandidates = compressed.split('/');
|
|
for (const c of oldCandidates) {
|
|
const parts = c.split(',');
|
|
if (parts.length < 6) {
|
|
updateDebugOutput(`Skipping incomplete candidate data: ${c}`);
|
|
continue;
|
|
}
|
|
|
|
const foundation = parts[0];
|
|
const component = parts[1];
|
|
const protocol = parts[2] === 'u' ? 'UDP' : 'TCP';
|
|
const priority = compactToPriority(parts[3]);
|
|
const ip = parts[4];
|
|
const port = parts[5];
|
|
const typeCode = parts[6];
|
|
|
|
let type = 'host';
|
|
switch(typeCode) {
|
|
case 'h': type = 'host'; break;
|
|
case 's': type = 'srflx'; break;
|
|
case 'r': type = 'relay'; break;
|
|
default: type = 'host';
|
|
}
|
|
|
|
// Skip relay candidates - we don't want them
|
|
if (type === 'relay') continue;
|
|
|
|
// Build candidate line
|
|
let candidateLine = `candidate:${foundation} ${component} ${protocol} ${priority} ${ip} ${port} typ ${type}`;
|
|
|
|
// Add raddr/rport if available for srflx candidates
|
|
if (type === 'srflx' && parts.length >= 9) {
|
|
candidateLine += ` raddr ${parts[7]} rport ${parts[8]}`;
|
|
}
|
|
|
|
// Add generation
|
|
candidateLine += ' generation 0';
|
|
|
|
candidates.push({
|
|
candidate: candidateLine,
|
|
sdpMid: "0",
|
|
sdpMLineIndex: 0
|
|
});
|
|
}
|
|
|
|
return candidates;
|
|
} catch (e) {
|
|
console.error("Error decompressing old ICE format:", e);
|
|
updateDebugOutput(`Old ICE format decompression error: ${e.message}`);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
// Update compressMessage to handle the new compression format
|
|
function compressMessage(message) {
|
|
try {
|
|
// Handle either string or object input
|
|
let parsedMsg;
|
|
if (typeof message === 'string') {
|
|
parsedMsg = JSON.parse(message);
|
|
} else {
|
|
parsedMsg = message;
|
|
}
|
|
|
|
// For SDP offers/answers with descriptions
|
|
if (parsedMsg.description && parsedMsg.description.sdp) {
|
|
// Extract key fields
|
|
const { UUID, from, session } = parsedMsg;
|
|
if (from) {
|
|
myUUID = from;
|
|
}
|
|
|
|
// Compress SDP with candidates preserved
|
|
return JSON.stringify({
|
|
type: "O",
|
|
ses: session || null,
|
|
sdp: compressSDP(parsedMsg.description.sdp)
|
|
});
|
|
}
|
|
|
|
// For ICE candidates
|
|
else if (parsedMsg.candidates && parsedMsg.candidates.length) {
|
|
|
|
return null;
|
|
|
|
// Extract key fields
|
|
const { UUID, type, from, session } = parsedMsg;
|
|
if (from) {
|
|
myUUID = from;
|
|
}
|
|
|
|
// Compress candidates - only keep UDP IPv4 host and srflx candidates
|
|
const validCandidates = parsedMsg.candidates.filter(c => {
|
|
if (!c || !c.candidate) return false;
|
|
|
|
const parts = c.candidate.split(' ');
|
|
return parts.length >= 8 &&
|
|
parts[2].toLowerCase() === 'udp' &&
|
|
!parts[4].includes(':') &&
|
|
!parts[4].includes('.local') &&
|
|
(parts[7] === 'host' || parts[7] === 'srflx');
|
|
});
|
|
|
|
// Skip empty candidate sets
|
|
if (!validCandidates.length) {
|
|
return null; // Skip this message completely
|
|
}
|
|
|
|
// Create compact message
|
|
return JSON.stringify({
|
|
type: "C",
|
|
ses: session || null,
|
|
cand: compressICECandidates(validCandidates)
|
|
});
|
|
}
|
|
|
|
// For basic messages (join, play, etc.)
|
|
else if (parsedMsg.request) {
|
|
// Extract key fields and create compact message
|
|
const { request, from } = parsedMsg;
|
|
if (from) {
|
|
myUUID = from;
|
|
}
|
|
return JSON.stringify({
|
|
type: "B",
|
|
req: request || null
|
|
});
|
|
}
|
|
|
|
// If we don't recognize the format, return the original message
|
|
updateDebugOutput("Unknown message format, using original");
|
|
return typeof message === 'string' ? message : JSON.stringify(message);
|
|
} catch (e) {
|
|
console.error("Compression error:", e);
|
|
updateDebugOutput(`Message compression error: ${e.message}`);
|
|
return typeof message === 'string' ? message : JSON.stringify(message);
|
|
}
|
|
}
|
|
|
|
// Update decompressMessage to handle the new format
|
|
function decompressMessage(message) {
|
|
try {
|
|
// Parse the message first
|
|
let parsedMsg;
|
|
if (typeof message === 'string') {
|
|
parsedMsg = JSON.parse(message);
|
|
} else {
|
|
parsedMsg = message;
|
|
}
|
|
|
|
// Check if it's our compressed format
|
|
if (parsedMsg.type) {
|
|
// SDP offer/answer message
|
|
if (parsedMsg.type === "O" && parsedMsg.sdp) {
|
|
try {
|
|
// Determine if it's an offer or answer based on first character of SDP
|
|
let isOffer = false;
|
|
const sdpData = parsedMsg.sdp;
|
|
|
|
if (typeof sdpData === 'string') {
|
|
if (sdpData.startsWith('C1|')) {
|
|
isOffer = sdpData.split('|')[1] === 'o';
|
|
} else if (sdpData.startsWith('C2|')) {
|
|
isOffer = sdpData.split('|')[1] === 'o';
|
|
} else if (sdpData.startsWith('{') && sdpData.endsWith('}')) {
|
|
try {
|
|
const sdpObj = JSON.parse(sdpData);
|
|
isOffer = sdpObj.t === 'o';
|
|
} catch (e) {
|
|
updateDebugOutput(`Error parsing SDP JSON: ${e.message}`);
|
|
}
|
|
}
|
|
} else if (typeof sdpData === 'object') {
|
|
isOffer = sdpData.t === 'o';
|
|
}
|
|
|
|
// Build original format
|
|
const result = {
|
|
UUID: myUUID,
|
|
streamID: "local",
|
|
description: {
|
|
type: isOffer ? "offer" : "answer",
|
|
sdp: decompressSDP(parsedMsg.sdp)
|
|
},
|
|
from: "remote",
|
|
roomid: "default"
|
|
};
|
|
|
|
if (parsedMsg.ses) result.session = parsedMsg.ses;
|
|
|
|
return JSON.stringify(result);
|
|
} catch (e) {
|
|
console.error("Error decompressing SDP:", e);
|
|
updateDebugOutput(`SDP decompression failed: ${e.message}`);
|
|
throw e;
|
|
}
|
|
}
|
|
// ICE candidates message - no changes needed
|
|
else if (parsedMsg.type === "C") {
|
|
try {
|
|
// Decompress candidates
|
|
const candidates = decompressICECandidates(parsedMsg.cand || "");
|
|
|
|
// Skip if no valid candidates
|
|
if (!candidates || candidates.length === 0) {
|
|
updateDebugOutput("No valid candidates after decompression, skipping");
|
|
return null;
|
|
}
|
|
|
|
// Build original format
|
|
const result = {
|
|
UUID: myUUID,
|
|
candidates: candidates,
|
|
from: "remote",
|
|
roomid: "default",
|
|
type: "remote"
|
|
};
|
|
|
|
if (parsedMsg.ses) result.session = parsedMsg.ses;
|
|
|
|
return JSON.stringify(result);
|
|
} catch (e) {
|
|
console.error("Error decompressing candidates:", e);
|
|
updateDebugOutput(`Candidate decompression failed: ${e.message}`);
|
|
throw e;
|
|
}
|
|
}
|
|
// Basic control message - no changes needed
|
|
else if (parsedMsg.type === "B") {
|
|
// Build original format
|
|
const result = {
|
|
request: parsedMsg.req,
|
|
streamID: "remote",
|
|
from: "remote",
|
|
roomid: "default"
|
|
};
|
|
|
|
return JSON.stringify(result);
|
|
}
|
|
}
|
|
|
|
// If it's not our compressed format, return it unchanged
|
|
return typeof message === 'string' ? message : JSON.stringify(message);
|
|
} catch (e) {
|
|
console.error("Decompression error:", e);
|
|
updateDebugOutput(`Message decompression error: ${e.message}`);
|
|
// Return original in case of error
|
|
return typeof message === 'string' ? message : JSON.stringify(message);
|
|
}
|
|
}
|
|
|
|
// ====================== Application Code ======================
|
|
// Initialize variables
|
|
var myUUID = "123";
|
|
// Initialize variables
|
|
let messageQueues = { left: [], right: [] };
|
|
let connectionStates = { left: {}, right: {} };
|
|
let currentConnectionIds = { left: null, right: null };
|
|
let processingTimeouts = { left: null, right: null };
|
|
let iframes = { left: null, right: null };
|
|
let roomid = "default";
|
|
let debugMode = false;
|
|
let compressionEnabled = true;
|
|
let autoTransferEnabled = false;
|
|
let messageBuffers = { left: [], right: [] };
|
|
|
|
// Parse URL parameters
|
|
var urlEdited = window.location.search.replace(/\?\?/g, "?");
|
|
urlEdited = urlEdited.replace(/\?/g, "&");
|
|
urlEdited = urlEdited.replace(/\&/, "?");
|
|
|
|
if (urlEdited !== window.location.search) {
|
|
console.warn(window.location.search + " changed to " + urlEdited);
|
|
window.history.pushState({path: urlEdited.toString()}, '', urlEdited.toString());
|
|
}
|
|
|
|
var urlParams = new URLSearchParams(urlEdited);
|
|
|
|
// Get room ID
|
|
if (urlParams.has("room")) {
|
|
roomid = urlParams.get("room");
|
|
}
|
|
|
|
// Helper functions
|
|
function updateDebugOutput(message) {
|
|
if (!debugMode) return;
|
|
|
|
const debugEl = document.getElementById("debugOutput");
|
|
const timestamp = new Date().toLocaleTimeString();
|
|
debugEl.innerHTML += `<div>${timestamp}: ${message}</div>`;
|
|
debugEl.scrollTop = debugEl.scrollHeight;
|
|
}
|
|
|
|
function updateConnectionStatus() {
|
|
const statusEl = document.getElementById("connectionStatus");
|
|
|
|
const leftActive = Object.values(connectionStates.left).filter(s => s !== "completed" && s !== "failed").length;
|
|
const rightActive = Object.values(connectionStates.right).filter(s => s !== "completed" && s !== "failed").length;
|
|
|
|
if (leftActive > 0 || rightActive > 0) {
|
|
statusEl.innerHTML = `Connection Status: Left (${leftActive} active) | Right (${rightActive} active)`;
|
|
} else {
|
|
statusEl.innerHTML = `Connection Status: Waiting for connections...`;
|
|
}
|
|
}
|
|
|
|
function handleAutoRelay(message, sourceSide) {
|
|
try {
|
|
// Check if it's a JSON string
|
|
let parsedMsg = typeof message === 'string' ? JSON.parse(message) : message;
|
|
|
|
// Handle both packed and unpacked messages
|
|
let innerMessage = parsedMsg;
|
|
if (parsedMsg.m) {
|
|
// This is a packed message from the queue
|
|
innerMessage = JSON.parse(parsedMsg.m);
|
|
}
|
|
|
|
// Check if it's a basic command type with request property
|
|
if (innerMessage.type === "B" && innerMessage.req) {
|
|
const command = innerMessage.req;
|
|
|
|
// Handle specifically joinroom and play commands
|
|
if (command === "joinroom" || command === "play") {
|
|
updateDebugOutput(`Auto-relaying '${command}' command from ${sourceSide}`);
|
|
|
|
// Create the original format message for both sides
|
|
const relayMessage = JSON.stringify({
|
|
request: command,
|
|
streamID: "remote",
|
|
from: "remote",
|
|
roomid: "default"
|
|
});
|
|
|
|
if (iframes.left && iframes.left.contentWindow ) {
|
|
iframes.left.contentWindow.postMessage({"function":"routeMessage", "value": relayMessage}, '*');
|
|
updateDebugOutput(`Auto-relayed '${command}' to left iframe`);
|
|
}
|
|
|
|
// Return true to indicate we handled it
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Not a command we auto-relay
|
|
return false;
|
|
} catch (e) {
|
|
updateDebugOutput(`Error in auto-relay: ${e.message}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Update the manualSend function to check for auto-relay commands
|
|
function manualSend(message, side) {
|
|
try {
|
|
// Check if this is a duplicate message
|
|
if (messageQueues[side].length > 0) {
|
|
try {
|
|
const lastMsg = JSON.parse(messageQueues[side][messageQueues[side].length - 1]);
|
|
const newMsg = JSON.parse(message);
|
|
|
|
// Compare stringified versions to detect duplicates
|
|
if (JSON.stringify(lastMsg.m) === JSON.stringify(newMsg)) {
|
|
return;
|
|
}
|
|
} catch (e) {
|
|
// Continue if parsing fails
|
|
}
|
|
}
|
|
|
|
// Extract UUID from original message for tracking
|
|
let uuid = null;
|
|
try {
|
|
const parsedMsg = JSON.parse(message);
|
|
uuid = parsedMsg.UUID;
|
|
|
|
if (uuid) {
|
|
// Update connection state
|
|
if (!connectionStates[side][uuid]) {
|
|
connectionStates[side][uuid] = "initializing";
|
|
updateConnectionStatus();
|
|
}
|
|
|
|
// If this is a new connection and we're not processing one already
|
|
if (!currentConnectionIds[side] && uuid) {
|
|
currentConnectionIds[side] = uuid;
|
|
updateConnectionStatus();
|
|
|
|
// Set timeout for connection
|
|
if (processingTimeouts[side]) clearTimeout(processingTimeouts[side]);
|
|
processingTimeouts[side] = setTimeout(() => {
|
|
if (currentConnectionIds[side] === uuid) {
|
|
connectionStates[side][currentConnectionIds[side]] = "failed";
|
|
currentConnectionIds[side] = null;
|
|
updateConnectionStatus();
|
|
}
|
|
}, 60000); // 60 seconds timeout
|
|
}
|
|
}
|
|
} catch (e) {
|
|
updateDebugOutput(`Error parsing message for UUID (${side}): ${e.message}`);
|
|
}
|
|
|
|
let compressedMessage = message;
|
|
let originalLength = message.length;
|
|
let compressionRatio = "0%";
|
|
|
|
// Compress the message if enabled
|
|
if (compressionEnabled) {
|
|
try {
|
|
const compressed = compressMessage(message);
|
|
|
|
console.info(compressed);
|
|
|
|
// Skip if compression returned null
|
|
if (compressed === null) {
|
|
updateDebugOutput(`Compression returned null, skipping message (${side})`);
|
|
return;
|
|
}
|
|
|
|
compressedMessage = compressed;
|
|
compressionRatio = ((compressedMessage.length / originalLength) * 100).toFixed(1) + "%";
|
|
|
|
// Check if this is a command we should auto-relay
|
|
try {
|
|
const parsedCompressed = JSON.parse(compressedMessage);
|
|
if (handleAutoRelay(parsedCompressed, side)) {
|
|
// We handled it with auto-relay, so no need to add to queue
|
|
updateDebugOutput(`Auto-relayed command from ${side}, not adding to queue`);
|
|
|
|
// Update compression stats display anyway
|
|
document.getElementById("compressionStats").textContent =
|
|
`Compression: ${originalLength} → ${compressedMessage.length} bytes (${compressionRatio})`;
|
|
|
|
// Update status
|
|
const statusEl = document.getElementById("outputStatus");
|
|
statusEl.textContent = `${side.toUpperCase()}: Auto-relayed command - ${new Date().toLocaleTimeString()}`;
|
|
|
|
return; // Exit early since we handled the command
|
|
}
|
|
} catch (e) {
|
|
updateDebugOutput(`Error checking for auto-relay: ${e.message}`);
|
|
}
|
|
} catch (e) {
|
|
updateDebugOutput(`Compression error: ${e.message}, using original message (${side})`);
|
|
compressedMessage = message;
|
|
compressionRatio = "100%";
|
|
}
|
|
} else {
|
|
compressionRatio = "100% (disabled)";
|
|
|
|
// Even if compression is disabled, check if we can auto-relay
|
|
try {
|
|
if (handleAutoRelay(message, side)) {
|
|
// We handled it with auto-relay, so no need to add to queue
|
|
updateDebugOutput(`Auto-relayed command from ${side}, not adding to queue (compression disabled)`);
|
|
|
|
// Update compression stats display anyway
|
|
document.getElementById("compressionStats").textContent =
|
|
`Compression: ${originalLength} bytes (disabled)`;
|
|
|
|
// Update status
|
|
const statusEl = document.getElementById("outputStatus");
|
|
statusEl.textContent = `${side.toUpperCase()}: Auto-relayed command - ${new Date().toLocaleTimeString()}`;
|
|
|
|
return; // Exit early since we handled the command
|
|
}
|
|
} catch (e) {
|
|
updateDebugOutput(`Error checking for auto-relay: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
// Update compression stats display
|
|
document.getElementById("compressionStats").textContent =
|
|
`Compression: ${originalLength} → ${compressedMessage.length} bytes (${compressionRatio})`;
|
|
|
|
const msg = {m: compressedMessage, t: Date.now()};
|
|
const packed = JSON.stringify(msg);
|
|
|
|
// Add to message queue
|
|
messageQueues[side].push(packed);
|
|
|
|
// Add to buffer for auto-transfer if enabled
|
|
if (autoTransferEnabled) {
|
|
// Store in the buffer
|
|
messageBuffers[side].push(packed);
|
|
|
|
// Determine the target side
|
|
const targetSide = side === 'left' ? 'right' : 'left';
|
|
|
|
// Use setTimeout to ensure messages are processed in the correct order
|
|
setTimeout(() => {
|
|
try {
|
|
transferMessage(targetSide, packed);
|
|
} catch (err) {
|
|
updateDebugOutput(`Error in auto-transfer: ${err.message}`);
|
|
}
|
|
}, 100);
|
|
}
|
|
|
|
// Update status
|
|
const statusEl = document.getElementById("outputStatus");
|
|
statusEl.textContent = `${side.toUpperCase()}: Message added to queue (${messageQueues[side].length} pending) - ${new Date().toLocaleTimeString()}`;
|
|
|
|
// Update the output area if it's currently empty
|
|
const outputArea = document.getElementById("outputArea");
|
|
if (outputArea.value === "") {
|
|
outputArea.value = `[${side.toUpperCase()}] ${packed}`;
|
|
}
|
|
} catch (e) {
|
|
updateDebugOutput(`Error in manualSend (${side}): ${e.message}`);
|
|
}
|
|
}
|
|
|
|
function processIncomingMessage(input, targetSide) {
|
|
const statusEl = document.getElementById("outputStatus");
|
|
|
|
try {
|
|
const msg = JSON.parse(input);
|
|
if (!msg.t || !msg.m) {
|
|
statusEl.textContent = "Error: Invalid message format";
|
|
updateDebugOutput("Invalid message format: missing t or m property");
|
|
return;
|
|
}
|
|
|
|
// Don't be too strict about message age
|
|
if (Date.now() - msg.t > 1200000) { // 20 minutes
|
|
updateDebugOutput("Message is old (>20 minutes) but processing anyway");
|
|
}
|
|
|
|
let decompressedMessage = msg.m;
|
|
let skipMessage = false;
|
|
|
|
// Try to decompress if needed and enabled
|
|
if (compressionEnabled) {
|
|
try {
|
|
// First check if it's our compressed format
|
|
let parsedIncoming;
|
|
try {
|
|
parsedIncoming = JSON.parse(msg.m);
|
|
|
|
// Look for our signature
|
|
if (parsedIncoming.type && ['O', 'C', 'B'].includes(parsedIncoming.type)) {
|
|
// updateDebugOutput(`Detected compressed message type: ${parsedIncoming.type}`);
|
|
|
|
// Skip empty candidate sets completely
|
|
if (parsedIncoming.type === 'C' && (!parsedIncoming.cand || parsedIncoming.cand === '')) {
|
|
updateDebugOutput("Skipping empty candidate set");
|
|
skipMessage = true;
|
|
statusEl.textContent = "Skipped empty candidate message - " + new Date().toLocaleTimeString();
|
|
return;
|
|
}
|
|
|
|
// Decompress
|
|
let decompressed = decompressMessage(msg.m);
|
|
|
|
// Skip if decompression returned null
|
|
if (decompressed === null) {
|
|
updateDebugOutput("Skipping null decompressed message");
|
|
skipMessage = true;
|
|
statusEl.textContent = "Skipped empty message - " + new Date().toLocaleTimeString();
|
|
return;
|
|
}
|
|
|
|
// Modify the UUID based on target side before sending
|
|
try {
|
|
let parsedDecompressed = JSON.parse(decompressed);
|
|
if (parsedDecompressed.UUID) {
|
|
parsedDecompressed.UUID = targetSide === 'left' ? myUUID.left : myUUID.right;
|
|
decompressed = JSON.stringify(parsedDecompressed);
|
|
}
|
|
} catch (uuidErr) {
|
|
updateDebugOutput(`Error modifying UUID: ${uuidErr.message}`);
|
|
}
|
|
|
|
decompressedMessage = decompressed;
|
|
// updateDebugOutput(`Successfully decompressed message: ${decompressedMessage.substring(0, 50)}...`);
|
|
}
|
|
} catch (e) {
|
|
// Not our compressed format or not JSON
|
|
updateDebugOutput(`Message doesn't appear to be compressed: ${e.message}`);
|
|
}
|
|
} catch (e) {
|
|
updateDebugOutput(`Error checking compression format: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
if (!skipMessage && iframes[targetSide] && iframes[targetSide].contentWindow) {
|
|
// Send the message to the target iframe
|
|
updateDebugOutput(`Sending to ${targetSide} iframe: ${decompressedMessage}`);
|
|
iframes[targetSide].contentWindow.postMessage({"function":"routeMessage", "value":decompressedMessage}, '*');
|
|
statusEl.textContent = `Message processed successfully to ${targetSide} - ${new Date().toLocaleTimeString()}`;
|
|
} else if (!iframes[targetSide] || !iframes[targetSide].contentWindow) {
|
|
updateDebugOutput(`Target iframe ${targetSide} not ready yet`);
|
|
statusEl.textContent = `Error: Target iframe ${targetSide} not ready - ${new Date().toLocaleTimeString()}`;
|
|
}
|
|
} catch(e) {
|
|
statusEl.textContent = "Error: " + e.message;
|
|
console.error("Failed to process message:", e);
|
|
updateDebugOutput(`Error processing message: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
function transferMessage(targetSide, message) {
|
|
try {
|
|
// Check if this is a message we should auto-relay
|
|
try {
|
|
const msgObj = JSON.parse(message);
|
|
const sourceSide = targetSide === 'left' ? 'right' : 'left';
|
|
|
|
if (handleAutoRelay(msgObj, sourceSide)) {
|
|
// We handled it with auto-relay, so just remove from queue
|
|
if (sourceSide === 'left' && messageQueues.left.length > 0) {
|
|
messageQueues.left.shift();
|
|
} else if (sourceSide === 'right' && messageQueues.right.length > 0) {
|
|
messageQueues.right.shift();
|
|
}
|
|
|
|
// Update display
|
|
const outputArea = document.getElementById("outputArea");
|
|
outputArea.value = "";
|
|
|
|
return; // Exit early since we handled the command
|
|
}
|
|
} catch (e) {
|
|
updateDebugOutput(`Error checking for auto-relay during transfer: ${e.message}`);
|
|
}
|
|
|
|
// Otherwise, process normally
|
|
processIncomingMessage(message, targetSide);
|
|
|
|
// Remove from the queue after successful transfer
|
|
if (targetSide === 'right' && messageQueues.left.length > 0) {
|
|
messageQueues.left.shift();
|
|
} else if (targetSide === 'left' && messageQueues.right.length > 0) {
|
|
messageQueues.right.shift();
|
|
}
|
|
|
|
// Update display
|
|
const outputArea = document.getElementById("outputArea");
|
|
outputArea.value = "";
|
|
} catch (e) {
|
|
updateDebugOutput(`Error transferring message: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
// Function to toggle auto transfer
|
|
function toggleAutoTransfer() {
|
|
autoTransferEnabled = !autoTransferEnabled;
|
|
|
|
const btn = document.getElementById("autoTransfer");
|
|
if (autoTransferEnabled) {
|
|
btn.textContent = "Disable Auto Transfer";
|
|
btn.classList.add("auto-transfer-active");
|
|
// updateDebugOutput("Auto transfer enabled");
|
|
} else {
|
|
btn.textContent = "Enable Auto Transfer";
|
|
btn.classList.remove("auto-transfer-active");
|
|
// updateDebugOutput("Auto transfer disabled");
|
|
}
|
|
}
|
|
|
|
// Initialize the page once DOM is loaded
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
// Create and add left iframe
|
|
iframes.left = document.createElement("iframe");
|
|
iframes.left.allow = "autoplay;camera;microphone;fullscreen;picture-in-picture;display-capture;";
|
|
iframes.left.src = "../?bypass&waitice&password=false&room=default&push=local&s&debug";
|
|
document.getElementById("iframe-left").appendChild(iframes.left);
|
|
|
|
// Create and add right iframe with a slight delay to prevent initialization conflicts
|
|
setTimeout(() => {
|
|
iframes.right = document.createElement("iframe");
|
|
iframes.right.allow = "autoplay;camera;microphone;fullscreen;picture-in-picture;display-capture;";
|
|
iframes.right.src = "../?bypass&waitice&password=false&room=default&push=local&solo";
|
|
document.getElementById("iframe-right").appendChild(iframes.right);
|
|
|
|
updateDebugOutput("Both iframes initialized");
|
|
}, 500);
|
|
|
|
// Setup debug toggle
|
|
document.getElementById("debugToggle").addEventListener("click", function() {
|
|
debugMode = !debugMode;
|
|
document.getElementById("debugOutput").style.display = debugMode ? "block" : "none";
|
|
});
|
|
|
|
// Setup compression toggle
|
|
document.getElementById("enableCompression").addEventListener("change", function() {
|
|
compressionEnabled = this.checked;
|
|
|
|
// Clear any previous messageQueue when changing compression mode
|
|
// to avoid mixing compressed and uncompressed messages
|
|
if (messageQueues.left.length > 0 || messageQueues.right.length > 0) {
|
|
if (confirm("Changing compression mode will clear the current message queues. Continue?")) {
|
|
messageQueues.left = [];
|
|
messageQueues.right = [];
|
|
document.getElementById("outputArea").value = "";
|
|
document.getElementById("outputStatus").textContent = "Message queues cleared due to compression mode change";
|
|
} else {
|
|
// Revert the checkbox if user cancels
|
|
this.checked = compressionEnabled;
|
|
return;
|
|
}
|
|
}
|
|
|
|
compressionEnabled = this.checked;
|
|
// updateDebugOutput(`Compression ${compressionEnabled ? 'enabled' : 'disabled'}`);
|
|
});
|
|
|
|
// Setup manual transfer buttons
|
|
document.getElementById("leftToRight").addEventListener("click", function() {
|
|
if (messageQueues.left.length > 0) {
|
|
const message = messageQueues.left[0];
|
|
transferMessage('right', message);
|
|
// updateDebugOutput("Manually transferred message from left to right");
|
|
|
|
// Update output area to show next message if available
|
|
if (messageQueues.left.length > 0) {
|
|
document.getElementById("outputArea").value = `[LEFT] ${messageQueues.left[0]}`;
|
|
} else {
|
|
document.getElementById("outputArea").value = "";
|
|
}
|
|
} else {
|
|
// updateDebugOutput("No messages in left queue to transfer");
|
|
}
|
|
});
|
|
|
|
document.getElementById("rightToLeft").addEventListener("click", function() {
|
|
if (messageQueues.right.length > 0) {
|
|
const message = messageQueues.right[0];
|
|
transferMessage('left', message);
|
|
// updateDebugOutput("Manually transferred message from right to left");
|
|
|
|
// Update output area to show next message if available
|
|
if (messageQueues.right.length > 0) {
|
|
document.getElementById("outputArea").value = `[RIGHT] ${messageQueues.right[0]}`;
|
|
} else {
|
|
document.getElementById("outputArea").value = "";
|
|
}
|
|
} else {
|
|
// updateDebugOutput("No messages in right queue to transfer");
|
|
}
|
|
});
|
|
|
|
// Setup auto transfer toggle
|
|
document.getElementById("autoTransfer").addEventListener("click", toggleAutoTransfer);
|
|
|
|
// Set up the message event handler for iframe communication
|
|
window.addEventListener("message", function(e) {
|
|
// Check if message is from one of our iframes
|
|
if (e.source === iframes.left.contentWindow) {
|
|
if ("bypass" in e.data) {
|
|
manualSend(e.data.bypass, 'left');
|
|
}
|
|
} else if (e.source === iframes.right.contentWindow) {
|
|
if ("bypass" in e.data) {
|
|
manualSend(e.data.bypass, 'right');
|
|
}
|
|
}
|
|
});
|
|
|
|
// Enable debug mode by default for easier troubleshooting
|
|
debugMode = true;
|
|
document.getElementById("debugOutput").style.display = "block";
|
|
// updateDebugOutput("Debug mode enabled by default");
|
|
// updateDebugOutput("Dual iframe setup with auto-transfer capability");
|
|
});
|
|
</script>
|
|
</body>
|
|
</html> |