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);
}