added support for usernames through ip or fingerprint, and also control based on it and restarting sessions

This commit is contained in:
jenz 2026-06-06 00:37:03 +01:00
parent 0f98ae862f
commit 4e4bd6a145
3 changed files with 450 additions and 132 deletions

View File

@ -44,9 +44,49 @@
max-width: 100vw;
max-height: 100vh;
object-fit: contain;
cursor: none;
display: block;
}
#gameCanvas.watching { cursor: default; }
#gameCanvas.controlling { cursor: none; }
#queue-panel {
position: fixed;
top: 10px;
right: 10px;
background: rgba(0,0,0,0.75);
padding: 10px 14px;
border-radius: 4px;
font-size: 12px;
z-index: 10;
min-width: 180px;
}
#queue-panel h3 { font-size: 12px; color: #ffcc00; margin-bottom: 6px; }
#queue-panel .controller-row { color: #4fc; margin-bottom: 4px; }
#queue-panel .queue-row { color: #aaa; margin-bottom: 2px; }
#queue-panel .you { color: #fff; font-weight: bold; }
#loading-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.85);
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 100;
}
#loading-overlay.visible { display: flex; }
#loading-overlay p { color: #fff; font-size: 14px; margin-bottom: 16px; }
.spinner {
width: 48px;
height: 48px;
border: 4px solid #333;
border-top-color: #4fc;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
#faq {
position: fixed;
bottom: 40px;
@ -65,11 +105,24 @@
</head>
<body>
<div id="status">Connecting...</div>
<video id="gameCanvas" autoplay muted playsinline></video>
<div id="hint">Click to capture mouse | ESC to release</div>
<div id="queue-panel">
<h3>Controller</h3>
<div id="queue-list"></div>
</div>
<div id="loading-overlay">
<p>Switching player — please wait...</p>
<div class="spinner"></div>
</div>
<video id="gameCanvas" class="watching" autoplay muted playsinline></video>
<div id="hint">Click to take control</div>
<div id="faq">
<h2>FAQ</h2>
<div class="faq-item">
<div class="faq-item">
<h3>What is this?</h3>
<p>This is the unloze webclient. You can play Zombie escape, Minigame and Zombie Riot with it. Visit our website at: https://unloze.com</p>
</div>
@ -80,11 +133,11 @@
</div>
<div class="faq-item">
<h3>How can i join the server?</h3>
<p>The IP is 51.195.188.106:27015. </p>
<p>The IP is 51.195.188.106:27015.</p>
</div>
<div class="faq-item">
<h3>Want to chat with us?</h3>
<p>We have our own self hosted stoat instance: https://unloze.com/stoat/ </p>
<p>We have our own self hosted stoat instance: https://unloze.com/stoat/</p>
</div>
<div class="faq-item">
<h3>⚠️ Opening the in-game menu</h3>
@ -98,15 +151,110 @@
</div>
<script type="module">
const fpPromise = import('https://openfpcdn.io/fingerprintjs/v5')
.then(FingerprintJS => FingerprintJS.load());
function hashString(str) {
let hash = 5381;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) + hash) + str.charCodeAt(i);
hash = hash & 0x7fffffff; // keep positive 31-bit
}
return hash;
}
function visitorIdToName(input) {
const adjectives = [
'Fast', 'Slow', 'Red', 'Blue', 'Green', 'Dark', 'Bright', 'Wild',
'Cool', 'Hot', 'Big', 'Small', 'Sharp', 'Brave', 'Swift', 'Bold'
];
const nouns = [
'Wolf', 'Eagle', 'Tiger', 'Bear', 'Fox', 'Lion', 'Hawk', 'Shark',
'Snake', 'Raven', 'Storm', 'Rock', 'Fire', 'Ice', 'Wind', 'Steel'
];
const h1 = hashString(input);
const h2 = hashString(input + '1');
const h3 = hashString(input + '2');
const adjIndex = h1 % adjectives.length;
const nounIndex = h2 % nouns.length;
const number = h3 % 9999;
return `${adjectives[adjIndex]} ${nouns[nounIndex]} ${number}`;
}
let visitorName = null;
async function initVisitorName() {
try {
const fp = await fpPromise;
const result = await fp.get();
if (result.visitorId) {
visitorName = visitorIdToName(result.visitorId);
return;
}
} catch (e) {
}
// Fallback: request IP from server
// Wait until WebSocket is open
const waitForIp = new Promise(resolve => {
const handler = e => {
if (e.data instanceof Blob) return;
const msg = JSON.parse(e.data);
if (msg.type === 'your_ip') {
visitorName = visitorIdToName(msg.ip);
sigWs.removeEventListener('message', handler);
resolve();
}
};
sigWs.addEventListener('message', handler);
sigWs.send(JSON.stringify({ type: 'get_ip' }));
});
await waitForIp;
}
const SIG_URL = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}`;
const statusEl = document.getElementById('status');
const canvas = document.getElementById('gameCanvas');
const statusEl = document.getElementById('status');
const canvas = document.getElementById('gameCanvas');
const hintEl = document.getElementById('hint');
const queueList = document.getElementById('queue-list');
const loadingEl = document.getElementById('loading-overlay');
let sigWs = null;
let pc = null;
let sessionId = null;
let pointerLocked = false;
let sigWs = null;
let pc = null;
let sessionId = null;
let pointerLocked = false;
let isControlling = false;
let isInQueue = false;
let isWaitingForRestart = false;
function setStatus(msg) {
statusEl.textContent = msg;
}
function setLoading(visible) {
loadingEl.classList.toggle('visible', visible);
}
function updateQueuePanel(controller, queue, restarting) {
let html = '';
if (restarting) {
html = '<div class="controller-row">⟳ Restarting...</div>';
} else if (controller) {
const isMe = visitorName && controller === visitorName;
html += `<div class="controller-row${isMe ? ' you' : ''}">🎮 ${controller}${isMe ? ' (you)' : ''}</div>`;
} else {
html += '<div class="controller-row">— nobody —</div>';
}
if (queue.length > 0) {
html += '<div style="margin-top:6px;color:#ffcc00;font-size:11px">Queue:</div>';
queue.forEach((name, i) => {
const isMe = visitorName && name === visitorName;
html += `<div class="queue-row${isMe ? ' you' : ''}">${i + 1}. ${name}${isMe ? ' (you)' : ''}</div>`;
});
}
queueList.innerHTML = html;
}
// ── Microphone / PTT ──────────────────────────────────────────────────
let micStream = null;
@ -118,17 +266,11 @@
if (micStream) return true;
try {
micStream = await navigator.mediaDevices.getUserMedia({
audio: {
sampleRate: 48000,
channelCount: 1,
echoCancellation: true,
noiseSuppression: true,
},
audio: { sampleRate: 48000, channelCount: 1, echoCancellation: true, noiseSuppression: true },
video: false,
});
return true;
} catch (e) {
console.error('[mic] getUserMedia failed:', e);
return false;
}
}
@ -137,10 +279,8 @@
if (micActive) return;
const ok = await initMic();
if (!ok) return;
audioContext = new AudioContext({ sampleRate: 48000 });
const source = audioContext.createMediaStreamSource(micStream);
micProcessor = audioContext.createScriptProcessor(4096, 1, 1);
micProcessor.onaudioprocess = e => {
if (!micActive) return;
@ -149,11 +289,8 @@
for (let i = 0; i < float32.length; i++) {
int16[i] = Math.max(-32768, Math.min(32767, float32[i] * 32768));
}
if (sigWs && sigWs.readyState === WebSocket.OPEN) {
sigWs.send(int16.buffer);
}
if (sigWs && sigWs.readyState === WebSocket.OPEN) sigWs.send(int16.buffer);
};
source.connect(micProcessor);
micProcessor.connect(audioContext.destination);
micActive = true;
@ -169,12 +306,38 @@
}
}
// ── WebRTC ────────────────────────────────────────────────────────────
function setStatus(msg) {
statusEl.textContent = msg;
console.log('[client]', msg);
// ── Control ───────────────────────────────────────────────────────────
function requestControl() {
if (!visitorName || !sigWs || sigWs.readyState !== WebSocket.OPEN) return;
if (isControlling || isInQueue || isWaitingForRestart) return;
sigWs.send(JSON.stringify({ type: 'request_control', visitorName }));
isInQueue = true;
hintEl.textContent = 'Waiting in queue...';
}
function enterControl() {
isControlling = true;
isInQueue = false;
isWaitingForRestart = false;
canvas.classList.replace('watching', 'controlling');
canvas.muted = false;
canvas.requestPointerLock();
hintEl.textContent = 'Mouse captured — ESC to release';
}
function exitControl() {
isControlling = false;
isInQueue = false;
isWaitingForRestart = false;
canvas.classList.replace('controlling', 'watching');
hintEl.textContent = 'Click to take control';
stopMic();
if (document.pointerLockElement === canvas) {
document.exitPointerLock();
}
}
// ── WebRTC ────────────────────────────────────────────────────────────
async function setupPeerConnection() {
pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.cloudflare.com:3478' }],
@ -186,10 +349,7 @@
pc.ontrack = event => {
remoteStream.addTrack(event.track);
if (event.track.kind === 'video') {
setStatus('Streaming — click to play');
}
console.log(`[client] got ${event.track.kind} track`);
if (event.track.kind === 'video') setStatus('Streaming');
};
await pc.setLocalDescription(await pc.createOffer({
@ -197,11 +357,7 @@
offerToReceiveAudio: true,
}));
sigWs.send(JSON.stringify({
type: 'browser_connect',
sdp: pc.localDescription.sdp,
}));
sigWs.send(JSON.stringify({ type: 'browser_connect', sdp: pc.localDescription.sdp }));
setStatus('Waiting for session...');
}
@ -210,10 +366,12 @@
setStatus('Connecting...');
sigWs = new WebSocket(SIG_URL);
sigWs.onopen = () => {
setStatus('Connected — setting up stream...');
setupPeerConnection();
};
sigWs.onopen = () => {
setStatus('Connected — setting up stream...');
initVisitorName().then(() => {
});
setupPeerConnection();
};
sigWs.onmessage = async e => {
if (e.data instanceof Blob) return;
@ -231,27 +389,34 @@
if (result.requiresImmediateRenegotiation) {
await pc.setRemoteDescription(new RTCSessionDescription(result.sessionDescription));
await pc.setLocalDescription(await pc.createAnswer());
sigWs.send(JSON.stringify({
type: 'renegotiate',
sessionId: sessionId,
sdp: pc.localDescription.sdp,
}));
sigWs.send(JSON.stringify({ type: 'renegotiate', sessionId, sdp: pc.localDescription.sdp }));
}
}
else if (msg.type === 'queue_state') {
updateQueuePanel(msg.controller, msg.queue, msg.restarting);
isWaitingForRestart = msg.restarting;
setLoading(msg.restarting && (isControlling || isInQueue));
}
else if (msg.type === 'control_granted') {
setLoading(false);
enterControl();
}
else if (msg.type === 'control_released') {
exitControl();
hintEl.textContent = msg.reason === 'idle'
? 'Control released (idle) — click to take control'
: 'Click to take control';
}
else if (msg.type === 'waiting') {
setStatus('Waiting for game instance...');
}
else if (msg.type === 'error') {
setStatus('Error: ' + msg.message);
console.error('[client] server error:', msg.message);
}
};
sigWs.onerror = () => setStatus('Connection error');
sigWs.onclose = () => {
stopMic();
setStatus('Disconnected');
};
sigWs.onclose = () => { stopMic(); exitControl(); setStatus('Disconnected'); };
}
function waitForIce() {
@ -278,15 +443,25 @@
}
canvas.addEventListener('click', () => {
canvas.muted = false;
canvas.requestPointerLock();
if (isControlling) {
// Already controller, just recapture pointer lock
canvas.muted = false;
canvas.requestPointerLock();
} else {
requestControl();
}
});
document.addEventListener('pointerlockchange', () => {
pointerLocked = document.pointerLockElement === canvas;
document.getElementById('hint').textContent = pointerLocked
? 'Mouse captured — ESC to release'
: 'Click to capture mouse | ESC to release';
if (!pointerLocked && isControlling) {
hintEl.textContent = 'Mouse released — click to recapture or wait to release control';
} else if (pointerLocked) {
hintEl.textContent = 'Mouse captured — ESC to release';
if (sigWs && sigWs.readyState === WebSocket.OPEN) {
sigWs.send(JSON.stringify({ type: 'controller_active' }));
}
}
});
const heldKeys = new Set();
@ -297,28 +472,37 @@
stopMic();
});
setInterval(() => {
if (pointerLocked && isControlling && sigWs && sigWs.readyState === WebSocket.OPEN) {
sigWs.send(JSON.stringify({ type: 'controller_active' }));
}
}, 3000);
let lastMouseMove = 0;
document.addEventListener('mousemove', e => {
if (!pointerLocked) return;
if (!pointerLocked || !isControlling) return;
const now = performance.now();
if (now - lastMouseMove < 16) return;
lastMouseMove = now;
sendInput({ type: 'mouse_move', dx: e.movementX, dy: e.movementY });
if (sigWs && sigWs.readyState === WebSocket.OPEN) {
sigWs.send(JSON.stringify({ type: 'controller_active' }));
}
});
canvas.addEventListener('mousedown', e => {
if (!pointerLocked) return;
if (!pointerLocked || !isControlling) return;
sendInput({ type: 'mouse_down', button: e.button + 1 });
});
canvas.addEventListener('mouseup', e => {
if (!pointerLocked) return;
if (!pointerLocked || !isControlling) return;
sendInput({ type: 'mouse_up', button: e.button + 1 });
});
let lastMouseWheel = 0;
canvas.addEventListener('wheel', e => {
if (!pointerLocked) return;
if (!pointerLocked || !isControlling) return;
e.preventDefault();
const now = performance.now();
if (now - lastMouseWheel < 16) return;
@ -356,31 +540,29 @@
if (e.key === 'q' && e.ctrlKey) { e.preventDefault(); return; }
if (e.key === 'F11') { e.preventDefault(); return; }
if (e.key === 'Escape') {
sendInput({ type: 'key_down', key: 'Escape' });
sendInput({ type: 'key_up', key: 'Escape' });
if (isControlling) {
sendInput({ type: 'key_down', key: 'Escape' });
sendInput({ type: 'key_up', key: 'Escape' });
}
return;
}
if (!pointerLocked) return;
if (!pointerLocked || !isControlling) return;
e.preventDefault();
const key = KEY_MAP[e.code] || e.code.toLowerCase();
if (e.code === 'KeyK' && !e.repeat) {
startMic();
}
if (e.code === 'KeyK' && !e.repeat) startMic();
heldKeys.add(key);
sendInput({ type: 'key_down', key });
});
document.addEventListener('keyup', e => {
if (e.key === 'Escape') {
sendInput({ type: 'key_up', key: 'Escape' });
if (isControlling) sendInput({ type: 'key_up', key: 'Escape' });
return;
}
if (!pointerLocked) return;
if (!pointerLocked || !isControlling) return;
e.preventDefault();
const key = KEY_MAP[e.code] || e.code.toLowerCase();
if (e.code === 'KeyK') {
stopMic();
}
if (e.code === 'KeyK') stopMic();
heldKeys.delete(key);
sendInput({ type: 'key_up', key });
});

View File

@ -3,7 +3,10 @@
rm -f /tmp/.X99-lock
rm -f /tmp/.X11-unix/X99
#no wine crash dialog in case of nosteam crashing.
# Clean up stale PulseAudio sockets from previous run
rm -rf /tmp/pulse-*
rm -f /run/user/$(id -u)/pulse/native
wine reg add "HKCU\\Software\\Wine\\WineDbg" /v ShowCrashDialog /t REG_DWORD /d 0 /f
Xvfb :99 -screen 0 800x600x24 &
@ -13,13 +16,19 @@ matchbox-window-manager -use_titlebar no &
sleep 1
pulseaudio --start --exit-idle-time=-1
sleep 1
# virtual microphone
# Wait for PulseAudio to be ready with retries
for i in $(seq 1 10); do
pactl info > /dev/null 2>&1 && break
echo "Waiting for PulseAudio... attempt $i"
sleep 1
done
# Virtual microphone
pactl load-module module-null-sink sink_name=mic_sink
pactl load-module module-virtual-source source_name=virtual_mic master=mic_sink.monitor
pactl set-default-source virtual_mic
sleep 1
wine ~/Counter-Strike\ Source/revLoader.exe &
sleep 5
python3 ~/streaming-agent/gst_agent.py

View File

@ -2,7 +2,7 @@
* Signaling Server
*
* Handles two tracks: video and game audio.
* Also relays browser microphone audio to container PulseAudio via pacat.
* Also manages controller queue and container restart for name changes.
* All Cloudflare API calls happen server-side.
*
* Run: CF_APP_ID=xxx CF_APP_TOKEN=xxx node signaling-server.js
@ -10,34 +10,26 @@
'use strict';
const express = require('express');
const http = require('http');
const WebSocket = require('ws');
const fetch = require('node-fetch');
const path = require('path');
const { spawn } = require('child_process');
const express = require('express');
const http = require('http');
const WebSocket = require('ws');
const fetch = require('node-fetch');
const path = require('path');
const { spawn, execSync } = require('child_process');
const PORT = process.env.PORT || 3000;
const CF_APP_ID = process.env.CF_APP_ID || '';
const CF_APP_TOKEN = process.env.CF_APP_TOKEN || '';
const CF_BASE = `https://rtc.live.cloudflare.com/v1/apps/${CF_APP_ID}`;
// Container's PulseAudio sink to write mic audio into
// pacat writes raw PCM to the null sink which virtual_mic reads from
const PACAT_ARGS = [
'--playback',
'--device=mic_sink',
'--format=s16le',
'--rate=48000',
'--channels=1',
'--latency-msec=20',
];
const CONTAINER = process.env.CONTAINER_NAME || 'css-test';
const IDLE_TIMEOUT = 10000; // 10 seconds
if (!CF_APP_ID || !CF_APP_TOKEN) {
console.error('[sig] CF_APP_ID and CF_APP_TOKEN must be set');
process.exit(1);
}
// ── Cloudflare API helpers ────────────────────────────────────────────────────
async function cfPost(path, body) {
const res = await fetch(`${CF_BASE}${path}`, {
method: 'POST',
@ -60,6 +52,7 @@ async function cfPut(path, body) {
return json;
}
// ── Express + WebSocket ───────────────────────────────────────────────────────
const app = express();
const server = http.createServer(app);
const wss = new WebSocket.Server({ server });
@ -67,67 +60,163 @@ const wss = new WebSocket.Server({ server });
app.use(express.json());
app.get('/', (req, res) => res.sendFile(path.join(__dirname, '..', 'client.html')));
// ── Session registry ──────────────────────────────────────────────────────────
const agentSessions = new Map();
const browserToAgent = new Map();
// Per-browser pacat process for mic audio
// browserSessionId -> pacat child process
const pacatProcesses = new Map();
function startPacat(browserSessId) {
// Find the container ID for this browser session
// pacat runs inside the Docker container via docker exec
const agentId = browserToAgent.get(browserSessId);
const entry = agentSessions.get(agentId);
if (!entry) return null;
// ── Controller queue ──────────────────────────────────────────────────────────
let currentController = null;
let lastControllerName = null;
let idleTimer = null;
const controlQueue = [];
let containerRestarting = false;
// Get container name from agent — agent sends it on register
const containerName = entry.containerName || 'css-test';
function broadcastQueueState() {
const queueNames = controlQueue.map(e => e.visitorName);
const controllerName = currentController ? currentController.visitorName : null;
const msg = JSON.stringify({
type: 'queue_state',
controller: controllerName,
queue: queueNames,
restarting: containerRestarting,
});
wss.clients.forEach(ws => {
if (ws.readyState === WebSocket.OPEN) ws.send(msg);
});
}
const pacat = spawn('docker', [
'exec', '-i', containerName,
'pacat', ...PACAT_ARGS,
]);
function resetIdleTimer(entry) {
if (idleTimer) clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
console.log(`[sig] controller ${entry.visitorName} idle timeout`);
releaseControl(entry, 'idle');
}, IDLE_TIMEOUT);
}
pacat.stderr.on('data', d => {
console.error(`[pacat:${browserSessId}]`, d.toString().trim());
async function applyController(entry) {
containerRestarting = true;
broadcastQueueState();
if (entry.visitorName === lastControllerName) {
// Same person returning — no restart needed
containerRestarting = false;
currentController = entry;
lastControllerName = entry.visitorName;
resetIdleTimer(entry);
broadcastQueueState();
if (entry.ws.readyState === WebSocket.OPEN) {
entry.ws.send(JSON.stringify({ type: 'control_granted' }));
}
return;
}
// Different person — update rev.ini and restart
lastControllerName = entry.visitorName;
try {
const safeName = entry.visitorName.replace(/'/g, "'\\''");
execSync(`docker exec ${CONTAINER} sed -i "s/PlayerName=.*/PlayerName=${safeName}/" "/home/ubuntu/Counter-Strike Source/rev.ini"`);
console.log(`[sig] rev.ini updated with name: ${entry.visitorName}`);
} catch (e) {
console.error('[sig] failed to update rev.ini:', e.message);
}
try {
execSync(`docker restart ${CONTAINER}`);
console.log('[sig] container restarted');
} catch (e) {
console.error('[sig] failed to restart container:', e.message);
}
// Wait for agent to re-register (up to 30 seconds)
await new Promise(resolve => {
const start = Date.now();
const check = setInterval(() => {
if (agentSessions.size > 0 || Date.now() - start > 30000) {
clearInterval(check);
resolve();
}
}, 500);
});
containerRestarting = false;
currentController = entry;
resetIdleTimer(entry);
broadcastQueueState();
if (entry.ws.readyState === WebSocket.OPEN) {
entry.ws.send(JSON.stringify({ type: 'control_granted' }));
}
}
function releaseControl(entry, reason) {
if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
if (currentController !== entry) return;
currentController = null;
console.log(`[sig] control released from ${entry.visitorName} (${reason})`);
if (entry.ws.readyState === WebSocket.OPEN) {
entry.ws.send(JSON.stringify({ type: 'control_released', reason }));
}
if (controlQueue.length > 0) {
const next = controlQueue.shift();
applyController(next);
}
broadcastQueueState();
}
function removeFromQueue(ws) {
const idx = controlQueue.findIndex(e => e.ws === ws);
if (idx !== -1) {
console.log(`[sig] removed ${controlQueue[idx].visitorName} from queue`);
controlQueue.splice(idx, 1);
broadcastQueueState();
}
}
// ── pacat for mic audio ───────────────────────────────────────────────────────
const PACAT_ARGS = [
'--playback',
'--device=mic_sink',
'--format=s16le',
'--rate=48000',
'--channels=1',
'--latency-msec=20',
];
function startPacat(browserSessId) {
const pacat = spawn('docker', ['exec', '-i', CONTAINER, 'pacat', ...PACAT_ARGS]);
pacat.stderr.on('data', d => console.error(`[pacat:${browserSessId}]`, d.toString().trim()));
pacat.on('close', code => {
console.log(`[pacat:${browserSessId}] exited with code ${code}`);
pacatProcesses.delete(browserSessId);
});
pacatProcesses.set(browserSessId, pacat);
console.log(`[sig] pacat started for browser session ${browserSessId}`);
return pacat;
}
function stopPacat(browserSessId) {
const pacat = pacatProcesses.get(browserSessId);
if (pacat) {
pacat.kill();
pacatProcesses.delete(browserSessId);
console.log(`[sig] pacat stopped for browser session ${browserSessId}`);
}
if (pacat) { pacat.kill(); pacatProcesses.delete(browserSessId); }
}
// ── WebSocket handler ─────────────────────────────────────────────────────────
wss.on('connection', ws => {
let role = null;
let agentSessionId = null;
let browserSessId = null;
let visitorName = null;
ws.on('message', async (raw, isBinary) => {
// Binary messages are raw PCM audio from browser microphone
if (isBinary) {
if (browserSessId) {
let pacat = pacatProcesses.get(browserSessId);
if (!pacat) {
pacat = startPacat(browserSessId);
}
if (pacat && pacat.stdin.writable) {
pacat.stdin.write(raw);
}
if (!pacat) pacat = startPacat(browserSessId);
if (pacat && pacat.stdin.writable) pacat.stdin.write(raw);
}
return;
}
@ -142,17 +231,45 @@ wss.on('connection', ws => {
agentSessionId: msg.sessionId,
videoTrackName: msg.videoTrackName,
audioTrackName: msg.audioTrackName,
containerName: msg.containerName || 'css-test',
agentWs: ws,
});
console.log(`[sig] agent registered — sessionId=${agentSessionId} container=${msg.containerName || 'css-test'}`);
console.log(`[sig] agent registered — sessionId=${agentSessionId}`);
}
else if (msg.type === 'request_control') {
visitorName = msg.visitorName;
// Reject during restart
if (containerRestarting) return;
// Already controlling — just reset idle timer
if (currentController && currentController.ws === ws) {
resetIdleTimer(currentController);
return;
}
// Already in queue
if (controlQueue.find(e => e.ws === ws)) return;
const entry = { ws, visitorName, browserSessId };
if (!currentController) {
applyController(entry);
} else {
controlQueue.push(entry);
console.log(`[sig] ${visitorName} joined queue position ${controlQueue.length}`);
broadcastQueueState();
}
}
else if (msg.type === 'controller_active') {
if (currentController && currentController.ws === ws) {
resetIdleTimer(currentController);
}
}
else if (msg.type === 'mic_stop') {
// Browser released PTT key — stop pacat
if (browserSessId) {
stopPacat(browserSessId);
}
if (browserSessId) stopPacat(browserSessId);
}
else if (msg.type === 'browser_connect') {
@ -177,6 +294,8 @@ wss.on('connection', ws => {
sessionId: browserSessId,
sdpAnswer: newSession.sessionDescription,
}));
broadcastQueueState();
}
else if (msg.type === 'pull_track') {
@ -216,6 +335,10 @@ wss.on('connection', ws => {
entry.agentWs.send(JSON.stringify(msg.event));
}
}
else if (msg.type === 'get_ip') {
const ip = ws._socket.remoteAddress.replace('::ffff:', '');
ws.send(JSON.stringify({ type: 'your_ip', ip }));
}
});
ws.on('close', () => {
@ -223,7 +346,11 @@ wss.on('connection', ws => {
agentSessions.delete(agentSessionId);
console.log(`[sig] agent disconnected — sessionId=${agentSessionId}`);
}
if (role === 'browser' && browserSessId) {
if (role === 'browser') {
if (currentController && currentController.ws === ws) {
releaseControl(currentController, 'disconnect');
}
removeFromQueue(ws);
stopPacat(browserSessId);
browserToAgent.delete(browserSessId);
}