diff --git a/client.html b/client.html index 283fadf..ed98e1e 100644 --- a/client.html +++ b/client.html @@ -81,6 +81,27 @@ accent-color: #4fc; cursor: pointer; } + + #sensitivity-control { + position: fixed; + bottom: 10px; + left: 10px; + background: rgba(0,0,0,0.7); + padding: 8px 12px; + border-radius: 4px; + font-size: 12px; + z-index: 10; + display: flex; + align-items: center; + gap: 8px; + color: #fff; + font-family: monospace; + } + #sensitivity-control input[type=range] { + width: 80px; + accent-color: #4fc; + cursor: pointer; + } #sound-toggle { position: fixed; top: 10px; @@ -97,6 +118,22 @@ } #sound-toggle:hover { background: rgba(40,40,40,0.85); } #sound-toggle.unmuted { color: #4fc; border-color: #4fc; } + + #restart-game-btn { + position: fixed; + top: 36px; + left: 10px; + background: rgba(120,20,20,0.75); + color: #fff; + border: 1px solid #a44; + padding: 6px 12px; + border-radius: 4px; + font-size: 13px; + font-family: monospace; + cursor: pointer; + z-index: 10; + } + #restart-game-btn:hover { background: rgba(160,30,30,0.9); } #gameCanvas { width: 1280px; height: 720px; @@ -110,7 +147,7 @@ #queue-panel { position: fixed; - top: 40px; + top: 68px; left: 10px; background: rgba(0,0,0,0.75); padding: 10px 14px; @@ -165,9 +202,13 @@
Connecting...
+
☀️ 100%
+
+ 🖱️ 1.0x +
Idle — control will be released soon. Move your mouse to stay in control.
@@ -263,8 +304,12 @@ const loadingEl = document.getElementById('loading-overlay'); const idleWarnEl = document.getElementById('idle-warning'); const soundToggleEl = document.getElementById('sound-toggle'); + const restartGameBtn = document.getElementById('restart-game-btn'); const brightnessSlider = document.getElementById('brightness-slider'); const brightnessValue = document.getElementById('brightness-value'); + const sensitivitySlider = document.getElementById('sensitivity-slider'); + const sensitivityValue = document.getElementById('sensitivity-value'); + let mouseSensitivity = 1; let sigWs = null; let pc = null; @@ -273,6 +318,7 @@ let isControlling = false; let isInQueue = false; let isWaitingForRestart = false; + let waitingRetryTimer = null; let idleWarnTimer = null; function setStatus(msg) { statusEl.textContent = msg; } @@ -311,12 +357,29 @@ setMuted(!canvas.muted); }); + restartGameBtn.addEventListener('click', () => { + if (!isControlling) return; + const confirmed = confirm( + 'Restart the game? This kills and relaunches Counter-Strike Source. ' + + 'Use this only if the game appears stuck/frozen. Takes a few seconds.' + ); + if (!confirmed) return; + if (sigWs && sigWs.readyState === WebSocket.OPEN) { + sigWs.send(JSON.stringify({ type: 'restart_css' })); + } + }); + brightnessSlider.addEventListener('input', () => { const val = brightnessSlider.value; canvas.style.filter = `brightness(${val}%)`; brightnessValue.textContent = `${val}%`; }); + sensitivitySlider.addEventListener('input', () => { + mouseSensitivity = parseFloat(sensitivitySlider.value); + sensitivityValue.textContent = `${mouseSensitivity.toFixed(1)}x`; + }); + function updateQueuePanel(controller, queue, restarting) { let html = ''; if (restarting) { @@ -405,6 +468,7 @@ canvas.requestPointerLock(); hintEl.textContent = 'Mouse captured — ESC to release'; resetIdleWarning(); + restartGameBtn.style.display = 'block'; } function exitControl() { @@ -416,6 +480,7 @@ stopMic(); if (idleWarnTimer) clearTimeout(idleWarnTimer); idleWarnEl.classList.remove('visible'); + restartGameBtn.style.display = 'none'; if (document.pointerLockElement === canvas) document.exitPointerLock(); } @@ -459,6 +524,7 @@ const msg = JSON.parse(e.data); if (msg.type === 'session_created') { + if (waitingRetryTimer) { clearInterval(waitingRetryTimer); waitingRetryTimer = null; } sessionId = msg.sessionId; await pc.setRemoteDescription(new RTCSessionDescription(msg.sdpAnswer)); await waitForIce(); @@ -490,6 +556,21 @@ } else if (msg.type === 'waiting') { setStatus('Waiting for game instance...'); + // Previously this just set the status text and did nothing further — + // if gst_agent had crashed (e.g. ICE failure) and hadn't finished + // restarting/re-registering yet at the exact moment this browser's + // browser_connect request landed, the browser was stuck showing + // this message forever with no way to recover short of a manual + // page refresh, even after gst_agent came back online moments later. + // Now retry browser_connect periodically until session_created + // arrives (cleared there) or the socket closes. + if (!waitingRetryTimer) { + waitingRetryTimer = setInterval(() => { + if (sigWs && sigWs.readyState === WebSocket.OPEN && pc && pc.localDescription) { + sigWs.send(JSON.stringify({ type: 'browser_connect', sdp: pc.localDescription.sdp })); + } + }, 3000); + } } else if (msg.type === 'error') { setStatus('Error: ' + msg.message); @@ -500,6 +581,7 @@ sigWs.onclose = () => { stopMic(); exitControl(); + if (waitingRetryTimer) { clearInterval(waitingRetryTimer); waitingRetryTimer = null; } setStatus('Disconnected'); }; } @@ -566,7 +648,7 @@ const now = performance.now(); if (now - lastMouseMove < 16) return; lastMouseMove = now; - sendInput({ type: 'mouse_move', dx: e.movementX, dy: e.movementY }); + sendInput({ type: 'mouse_move', dx: e.movementX * mouseSensitivity, dy: e.movementY * mouseSensitivity }); pingActive(); }); diff --git a/streaming-agent/signaling-server.js b/streaming-agent/signaling-server.js index 462ae36..046cbbe 100644 --- a/streaming-agent/signaling-server.js +++ b/streaming-agent/signaling-server.js @@ -64,10 +64,11 @@ app.use(express.urlencoded({ extended: false })); app.get('/', (req, res) => res.sendFile(path.join(__dirname, '..', 'client.html'))); // ── Internal-request gate ───────────────────────────────────────────────────── -// Used by both /webclient-name (POST, from the SourceMod plugin) and -// /webclient-current-ip (GET, polled by the SourceMod plugin). Restricted to -// loopback / docker-bridge / private ranges, PLUS this box's own resolved -// public IP. +// Used by /webclient-name (POST), /webclient-current-ip (GET), and +// /webclient-force-restart (POST) — all internal-only endpoints intended for +// the SourceMod plugin (and, for restart, an optional safety-net trigger). +// Restricted to loopback / docker-bridge / private ranges, PLUS this box's +// own resolved public IP. // // The public-IP allowance is required because SteamWorks' embedded HTTP // client (or the Source Engine networking layer beneath it) does NOT route @@ -121,15 +122,25 @@ app.post('/webclient-name', (req, res) => { } const name = (req.body && req.body.name) ? String(req.body.name).trim() : ''; + const ip = (req.body && req.body.ip) ? String(req.body.ip).trim() : ''; if (!name) { return res.status(400).send('missing name'); } + // Only apply this name if it corresponds to the CURRENTLY active + // controller's real IP. Without this check, a delayed/stale POST from a + // previous webclient session (the connect extension's OnClientAuthorized + // can fire with a noticeable delay after the actual in-game reconnect) + // can land AFTER a new person has already taken over control, silently + // overwriting the new controller's correct name with the old one's. + if (!currentController || !ip || currentController.ws._realIp !== ip) { + console.log(`[sig] ignored stale /webclient-name (ip=${ip || 'none'}) — does not match current controller`); + return res.status(200).send('ignored (stale)'); + } + console.log(`[sig] webclient-name received: ${name}`); lastControllerName = name; - if (currentController) { - currentController.visitorName = name; - } + currentController.visitorName = name; broadcastQueueState(); res.status(200).send('ok'); @@ -138,21 +149,9 @@ app.post('/webclient-name', (req, res) => { // SourceMod plugin -> signaling server (polled): returns the current // controller's real IP as plain text, so the plugin can detect when it // changes and force the already-connected webclient player to reconnect -// with "password ; retry" — reusing the exact same connect-extension -// path already proven correct when a password is supplied manually. +// via ClientCommand(client, "retry") — the connect extension backfills the +// real password from this same cached IP via OnClientPreConnectEx. // Returns an empty body when nobody is currently controlling. -// -// This is the ONLY mechanism used to propagate the new controller's IP into -// the game now — there is no more GameMenu.res rewriting, no sed, no -// automated Escape/mouse-move/click sequence. That whole approach was -// removed: GameMenu.res is only parsed once by the engine at menu-build -// time, so rewriting the file on disk while CSS keeps running indefinitely -// (which we do, by design, to avoid restarting the game on every handoff) -// never actually took effect after the very first read — every subsequent -// person's reconnect silently reused whichever password was cached from -// that first parse, which is why everyone ended up with the same generated -// name/SteamID regardless of how many times the file was correctly -// rewritten on disk. app.get('/webclient-current-ip', (req, res) => { if (!isInternalRequest(req)) { console.log(`[sig] rejected /webclient-current-ip from non-internal IP: ${req.socket.remoteAddress}`); @@ -163,6 +162,22 @@ app.get('/webclient-current-ip', (req, res) => { res.status(200).send(ip); }); +// Internal-only safety net: force-kill and relaunch CSS on demand. Intended +// for use by the SourceMod plugin (or any other trusted internal caller) if +// it detects the game client itself has wedged/crashed and needs a hard +// restart, separate from the manual in-browser restart button and the +// scheduled 5-hour restart. +app.post('/webclient-force-restart', (req, res) => { + if (!isInternalRequest(req)) { + console.log(`[sig] rejected /webclient-force-restart from non-internal IP: ${req.socket.remoteAddress}`); + return res.status(403).send('forbidden'); + } + + console.log('[sig] /webclient-force-restart triggered'); + performCssRestart('force-restart endpoint'); + res.status(200).send('ok'); +}); + // ── Session registry ────────────────────────────────────────────────────────── const agentSessions = new Map(); const browserToAgent = new Map(); @@ -209,7 +224,7 @@ function applyController(entry) { // Granting control is now instant — no CSS restart, no GameMenu.res // rewrite, no automated click sequence. The SourceMod plugin picks up the // new controller's IP by polling GET /webclient-current-ip and forces the - // in-game reconnect itself via ClientCommand(client, "password %s; retry"). + // in-game reconnect itself via ClientCommand(client, "retry"). lastControllerName = entry.visitorName; currentController = entry; resetIdleTimer(entry); @@ -222,37 +237,42 @@ function applyController(entry) { } } -// ── Periodic CSS restart ────────────────────────────────────────────────────── -// Controller handoffs no longer restart the game at all, so CSS now stays -// running indefinitely across many sessions. Long Wine sessions have -// historically accumulated issues (audio/keyboard corruption, memory -// growth), so restart the game process on a fixed schedule regardless of -// who's currently in control. -const CSS_RESTART_INTERVAL = 5 * 60 * 60 * 1000; // 5 hours +// ── CSS restart (shared by scheduled timer, manual button, and the internal +// force-restart endpoint) ────────────────────────────────────────────────── +function performCssRestart(reason) { + console.log(`[sig] CSS restart starting (${reason})`); + containerRestarting = true; + broadcastQueueState(); -function scheduledCssRestart() { - console.log('[sig] scheduled 5-hour CSS restart starting'); try { execSync(`docker exec ${CONTAINER} pkill -f "cstrike_win64.exe" || true`); execSync(`docker exec ${CONTAINER} pkill -f "revLoader.exe" || true`); execSync(`docker exec ${CONTAINER} pkill -f "wineserver" || true`); execSync(`docker exec ${CONTAINER} pkill -f "winedevice.exe" || true`); - console.log('[sig] scheduled restart: CSS processes killed'); + console.log('[sig] CSS restart: processes killed'); } catch (e) { - console.error('[sig] scheduled restart: failed to kill CSS:', e.message); + console.error('[sig] CSS restart: failed to kill CSS:', e.message); } setTimeout(() => { try { execSync(`docker exec -d ${CONTAINER} bash -c "DISPLAY=:99 wine '/home/ubuntu/Counter-Strike Source/revLoader.exe'"`); - console.log('[sig] scheduled restart: CSS relaunched'); + console.log('[sig] CSS restart: relaunched'); } catch (e) { - console.error('[sig] scheduled restart: failed to relaunch CSS:', e.message); + console.error('[sig] CSS restart: failed to relaunch CSS:', e.message); } + containerRestarting = false; + broadcastQueueState(); }, 2000); } -setInterval(scheduledCssRestart, CSS_RESTART_INTERVAL); +// Periodic restart: controller handoffs no longer restart the game at all, +// so CSS stays running indefinitely across many sessions. Long Wine +// sessions have historically accumulated issues (audio/keyboard corruption, +// memory growth, wedged connection state), so restart on a fixed schedule +// regardless of who's currently in control. +const CSS_RESTART_INTERVAL = 5 * 60 * 60 * 1000; // 5 hours +setInterval(() => performCssRestart('scheduled 5-hour restart'), CSS_RESTART_INTERVAL); function releaseControl(entry, reason) { if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; } @@ -426,6 +446,17 @@ wss.on('connection', (ws, req) => { } } + else if (msg.type === 'restart_css') { + // Manual restart button — only the CURRENT controller may trigger + // this. Anyone else's message is silently ignored. + if (currentController && currentController.ws === ws) { + console.log(`[sig] manual CSS restart requested by ${currentController.visitorName}`); + performCssRestart(`manual button — ${currentController.visitorName}`); + } else { + console.log('[sig] restart_css ignored — sender is not the current controller'); + } + } + else if (msg.type === 'mic_stop') { if (browserSessId) stopPacat(browserSessId); }