added support for usernames through ip or fingerprint, and also control based on it and restarting sessions
This commit is contained in:
parent
0f98ae862f
commit
4e4bd6a145
326
client.html
326
client.html
@ -44,9 +44,49 @@
|
|||||||
max-width: 100vw;
|
max-width: 100vw;
|
||||||
max-height: 100vh;
|
max-height: 100vh;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
cursor: none;
|
|
||||||
display: block;
|
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 {
|
#faq {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 40px;
|
bottom: 40px;
|
||||||
@ -65,11 +105,24 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="status">Connecting...</div>
|
<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">
|
<div id="faq">
|
||||||
<h2>FAQ</h2>
|
<h2>FAQ</h2>
|
||||||
<div class="faq-item">
|
<div class="faq-item">
|
||||||
<h3>What is this?</h3>
|
<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>
|
<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>
|
</div>
|
||||||
@ -80,11 +133,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="faq-item">
|
<div class="faq-item">
|
||||||
<h3>How can i join the server?</h3>
|
<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>
|
||||||
<div class="faq-item">
|
<div class="faq-item">
|
||||||
<h3>Want to chat with us?</h3>
|
<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>
|
||||||
<div class="faq-item">
|
<div class="faq-item">
|
||||||
<h3>⚠️ Opening the in-game menu</h3>
|
<h3>⚠️ Opening the in-game menu</h3>
|
||||||
@ -98,15 +151,110 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="module">
|
<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 SIG_URL = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}`;
|
||||||
|
|
||||||
const statusEl = document.getElementById('status');
|
const statusEl = document.getElementById('status');
|
||||||
const canvas = document.getElementById('gameCanvas');
|
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 sigWs = null;
|
||||||
let pc = null;
|
let pc = null;
|
||||||
let sessionId = null;
|
let sessionId = null;
|
||||||
let pointerLocked = false;
|
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 ──────────────────────────────────────────────────
|
// ── Microphone / PTT ──────────────────────────────────────────────────
|
||||||
let micStream = null;
|
let micStream = null;
|
||||||
@ -118,17 +266,11 @@
|
|||||||
if (micStream) return true;
|
if (micStream) return true;
|
||||||
try {
|
try {
|
||||||
micStream = await navigator.mediaDevices.getUserMedia({
|
micStream = await navigator.mediaDevices.getUserMedia({
|
||||||
audio: {
|
audio: { sampleRate: 48000, channelCount: 1, echoCancellation: true, noiseSuppression: true },
|
||||||
sampleRate: 48000,
|
|
||||||
channelCount: 1,
|
|
||||||
echoCancellation: true,
|
|
||||||
noiseSuppression: true,
|
|
||||||
},
|
|
||||||
video: false,
|
video: false,
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[mic] getUserMedia failed:', e);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -137,10 +279,8 @@
|
|||||||
if (micActive) return;
|
if (micActive) return;
|
||||||
const ok = await initMic();
|
const ok = await initMic();
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
|
||||||
audioContext = new AudioContext({ sampleRate: 48000 });
|
audioContext = new AudioContext({ sampleRate: 48000 });
|
||||||
const source = audioContext.createMediaStreamSource(micStream);
|
const source = audioContext.createMediaStreamSource(micStream);
|
||||||
|
|
||||||
micProcessor = audioContext.createScriptProcessor(4096, 1, 1);
|
micProcessor = audioContext.createScriptProcessor(4096, 1, 1);
|
||||||
micProcessor.onaudioprocess = e => {
|
micProcessor.onaudioprocess = e => {
|
||||||
if (!micActive) return;
|
if (!micActive) return;
|
||||||
@ -149,11 +289,8 @@
|
|||||||
for (let i = 0; i < float32.length; i++) {
|
for (let i = 0; i < float32.length; i++) {
|
||||||
int16[i] = Math.max(-32768, Math.min(32767, float32[i] * 32768));
|
int16[i] = Math.max(-32768, Math.min(32767, float32[i] * 32768));
|
||||||
}
|
}
|
||||||
if (sigWs && sigWs.readyState === WebSocket.OPEN) {
|
if (sigWs && sigWs.readyState === WebSocket.OPEN) sigWs.send(int16.buffer);
|
||||||
sigWs.send(int16.buffer);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
source.connect(micProcessor);
|
source.connect(micProcessor);
|
||||||
micProcessor.connect(audioContext.destination);
|
micProcessor.connect(audioContext.destination);
|
||||||
micActive = true;
|
micActive = true;
|
||||||
@ -169,12 +306,38 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── WebRTC ────────────────────────────────────────────────────────────
|
// ── Control ───────────────────────────────────────────────────────────
|
||||||
function setStatus(msg) {
|
function requestControl() {
|
||||||
statusEl.textContent = msg;
|
if (!visitorName || !sigWs || sigWs.readyState !== WebSocket.OPEN) return;
|
||||||
console.log('[client]', msg);
|
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() {
|
async function setupPeerConnection() {
|
||||||
pc = new RTCPeerConnection({
|
pc = new RTCPeerConnection({
|
||||||
iceServers: [{ urls: 'stun:stun.cloudflare.com:3478' }],
|
iceServers: [{ urls: 'stun:stun.cloudflare.com:3478' }],
|
||||||
@ -186,10 +349,7 @@
|
|||||||
|
|
||||||
pc.ontrack = event => {
|
pc.ontrack = event => {
|
||||||
remoteStream.addTrack(event.track);
|
remoteStream.addTrack(event.track);
|
||||||
if (event.track.kind === 'video') {
|
if (event.track.kind === 'video') setStatus('Streaming');
|
||||||
setStatus('Streaming — click to play');
|
|
||||||
}
|
|
||||||
console.log(`[client] got ${event.track.kind} track`);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await pc.setLocalDescription(await pc.createOffer({
|
await pc.setLocalDescription(await pc.createOffer({
|
||||||
@ -197,11 +357,7 @@
|
|||||||
offerToReceiveAudio: true,
|
offerToReceiveAudio: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
sigWs.send(JSON.stringify({
|
sigWs.send(JSON.stringify({ type: 'browser_connect', sdp: pc.localDescription.sdp }));
|
||||||
type: 'browser_connect',
|
|
||||||
sdp: pc.localDescription.sdp,
|
|
||||||
}));
|
|
||||||
|
|
||||||
setStatus('Waiting for session...');
|
setStatus('Waiting for session...');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -210,10 +366,12 @@
|
|||||||
setStatus('Connecting...');
|
setStatus('Connecting...');
|
||||||
sigWs = new WebSocket(SIG_URL);
|
sigWs = new WebSocket(SIG_URL);
|
||||||
|
|
||||||
sigWs.onopen = () => {
|
sigWs.onopen = () => {
|
||||||
setStatus('Connected — setting up stream...');
|
setStatus('Connected — setting up stream...');
|
||||||
setupPeerConnection();
|
initVisitorName().then(() => {
|
||||||
};
|
});
|
||||||
|
setupPeerConnection();
|
||||||
|
};
|
||||||
|
|
||||||
sigWs.onmessage = async e => {
|
sigWs.onmessage = async e => {
|
||||||
if (e.data instanceof Blob) return;
|
if (e.data instanceof Blob) return;
|
||||||
@ -231,27 +389,34 @@
|
|||||||
if (result.requiresImmediateRenegotiation) {
|
if (result.requiresImmediateRenegotiation) {
|
||||||
await pc.setRemoteDescription(new RTCSessionDescription(result.sessionDescription));
|
await pc.setRemoteDescription(new RTCSessionDescription(result.sessionDescription));
|
||||||
await pc.setLocalDescription(await pc.createAnswer());
|
await pc.setLocalDescription(await pc.createAnswer());
|
||||||
sigWs.send(JSON.stringify({
|
sigWs.send(JSON.stringify({ type: 'renegotiate', sessionId, sdp: pc.localDescription.sdp }));
|
||||||
type: 'renegotiate',
|
|
||||||
sessionId: 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') {
|
else if (msg.type === 'waiting') {
|
||||||
setStatus('Waiting for game instance...');
|
setStatus('Waiting for game instance...');
|
||||||
}
|
}
|
||||||
else if (msg.type === 'error') {
|
else if (msg.type === 'error') {
|
||||||
setStatus('Error: ' + msg.message);
|
setStatus('Error: ' + msg.message);
|
||||||
console.error('[client] server error:', msg.message);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
sigWs.onerror = () => setStatus('Connection error');
|
sigWs.onerror = () => setStatus('Connection error');
|
||||||
sigWs.onclose = () => {
|
sigWs.onclose = () => { stopMic(); exitControl(); setStatus('Disconnected'); };
|
||||||
stopMic();
|
|
||||||
setStatus('Disconnected');
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function waitForIce() {
|
function waitForIce() {
|
||||||
@ -278,15 +443,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
canvas.addEventListener('click', () => {
|
canvas.addEventListener('click', () => {
|
||||||
canvas.muted = false;
|
if (isControlling) {
|
||||||
canvas.requestPointerLock();
|
// Already controller, just recapture pointer lock
|
||||||
|
canvas.muted = false;
|
||||||
|
canvas.requestPointerLock();
|
||||||
|
} else {
|
||||||
|
requestControl();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener('pointerlockchange', () => {
|
document.addEventListener('pointerlockchange', () => {
|
||||||
pointerLocked = document.pointerLockElement === canvas;
|
pointerLocked = document.pointerLockElement === canvas;
|
||||||
document.getElementById('hint').textContent = pointerLocked
|
if (!pointerLocked && isControlling) {
|
||||||
? 'Mouse captured — ESC to release'
|
hintEl.textContent = 'Mouse released — click to recapture or wait to release control';
|
||||||
: 'Click to capture mouse | ESC to release';
|
} 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();
|
const heldKeys = new Set();
|
||||||
@ -297,28 +472,37 @@
|
|||||||
stopMic();
|
stopMic();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
if (pointerLocked && isControlling && sigWs && sigWs.readyState === WebSocket.OPEN) {
|
||||||
|
sigWs.send(JSON.stringify({ type: 'controller_active' }));
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
let lastMouseMove = 0;
|
let lastMouseMove = 0;
|
||||||
document.addEventListener('mousemove', e => {
|
document.addEventListener('mousemove', e => {
|
||||||
if (!pointerLocked) return;
|
if (!pointerLocked || !isControlling) return;
|
||||||
const now = performance.now();
|
const now = performance.now();
|
||||||
if (now - lastMouseMove < 16) return;
|
if (now - lastMouseMove < 16) return;
|
||||||
lastMouseMove = now;
|
lastMouseMove = now;
|
||||||
sendInput({ type: 'mouse_move', dx: e.movementX, dy: e.movementY });
|
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 => {
|
canvas.addEventListener('mousedown', e => {
|
||||||
if (!pointerLocked) return;
|
if (!pointerLocked || !isControlling) return;
|
||||||
sendInput({ type: 'mouse_down', button: e.button + 1 });
|
sendInput({ type: 'mouse_down', button: e.button + 1 });
|
||||||
});
|
});
|
||||||
|
|
||||||
canvas.addEventListener('mouseup', e => {
|
canvas.addEventListener('mouseup', e => {
|
||||||
if (!pointerLocked) return;
|
if (!pointerLocked || !isControlling) return;
|
||||||
sendInput({ type: 'mouse_up', button: e.button + 1 });
|
sendInput({ type: 'mouse_up', button: e.button + 1 });
|
||||||
});
|
});
|
||||||
|
|
||||||
let lastMouseWheel = 0;
|
let lastMouseWheel = 0;
|
||||||
canvas.addEventListener('wheel', e => {
|
canvas.addEventListener('wheel', e => {
|
||||||
if (!pointerLocked) return;
|
if (!pointerLocked || !isControlling) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const now = performance.now();
|
const now = performance.now();
|
||||||
if (now - lastMouseWheel < 16) return;
|
if (now - lastMouseWheel < 16) return;
|
||||||
@ -356,31 +540,29 @@
|
|||||||
if (e.key === 'q' && e.ctrlKey) { e.preventDefault(); return; }
|
if (e.key === 'q' && e.ctrlKey) { e.preventDefault(); return; }
|
||||||
if (e.key === 'F11') { e.preventDefault(); return; }
|
if (e.key === 'F11') { e.preventDefault(); return; }
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
sendInput({ type: 'key_down', key: 'Escape' });
|
if (isControlling) {
|
||||||
sendInput({ type: 'key_up', key: 'Escape' });
|
sendInput({ type: 'key_down', key: 'Escape' });
|
||||||
|
sendInput({ type: 'key_up', key: 'Escape' });
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!pointerLocked) return;
|
if (!pointerLocked || !isControlling) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const key = KEY_MAP[e.code] || e.code.toLowerCase();
|
const key = KEY_MAP[e.code] || e.code.toLowerCase();
|
||||||
if (e.code === 'KeyK' && !e.repeat) {
|
if (e.code === 'KeyK' && !e.repeat) startMic();
|
||||||
startMic();
|
|
||||||
}
|
|
||||||
heldKeys.add(key);
|
heldKeys.add(key);
|
||||||
sendInput({ type: 'key_down', key });
|
sendInput({ type: 'key_down', key });
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener('keyup', e => {
|
document.addEventListener('keyup', e => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
sendInput({ type: 'key_up', key: 'Escape' });
|
if (isControlling) sendInput({ type: 'key_up', key: 'Escape' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!pointerLocked) return;
|
if (!pointerLocked || !isControlling) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const key = KEY_MAP[e.code] || e.code.toLowerCase();
|
const key = KEY_MAP[e.code] || e.code.toLowerCase();
|
||||||
if (e.code === 'KeyK') {
|
if (e.code === 'KeyK') stopMic();
|
||||||
stopMic();
|
|
||||||
}
|
|
||||||
heldKeys.delete(key);
|
heldKeys.delete(key);
|
||||||
sendInput({ type: 'key_up', key });
|
sendInput({ type: 'key_up', key });
|
||||||
});
|
});
|
||||||
|
|||||||
17
start.sh
17
start.sh
@ -3,7 +3,10 @@
|
|||||||
rm -f /tmp/.X99-lock
|
rm -f /tmp/.X99-lock
|
||||||
rm -f /tmp/.X11-unix/X99
|
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
|
wine reg add "HKCU\\Software\\Wine\\WineDbg" /v ShowCrashDialog /t REG_DWORD /d 0 /f
|
||||||
|
|
||||||
Xvfb :99 -screen 0 800x600x24 &
|
Xvfb :99 -screen 0 800x600x24 &
|
||||||
@ -13,13 +16,19 @@ matchbox-window-manager -use_titlebar no &
|
|||||||
sleep 1
|
sleep 1
|
||||||
|
|
||||||
pulseaudio --start --exit-idle-time=-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-null-sink sink_name=mic_sink
|
||||||
pactl load-module module-virtual-source source_name=virtual_mic master=mic_sink.monitor
|
pactl load-module module-virtual-source source_name=virtual_mic master=mic_sink.monitor
|
||||||
pactl set-default-source virtual_mic
|
pactl set-default-source virtual_mic
|
||||||
|
|
||||||
sleep 1
|
|
||||||
wine ~/Counter-Strike\ Source/revLoader.exe &
|
wine ~/Counter-Strike\ Source/revLoader.exe &
|
||||||
sleep 5
|
sleep 5
|
||||||
python3 ~/streaming-agent/gst_agent.py
|
python3 ~/streaming-agent/gst_agent.py
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
* Signaling Server
|
* Signaling Server
|
||||||
*
|
*
|
||||||
* Handles two tracks: video and game audio.
|
* 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.
|
* All Cloudflare API calls happen server-side.
|
||||||
*
|
*
|
||||||
* Run: CF_APP_ID=xxx CF_APP_TOKEN=xxx node signaling-server.js
|
* Run: CF_APP_ID=xxx CF_APP_TOKEN=xxx node signaling-server.js
|
||||||
@ -10,34 +10,26 @@
|
|||||||
|
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
const WebSocket = require('ws');
|
const WebSocket = require('ws');
|
||||||
const fetch = require('node-fetch');
|
const fetch = require('node-fetch');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { spawn } = require('child_process');
|
const { spawn, execSync } = require('child_process');
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
const CF_APP_ID = process.env.CF_APP_ID || '';
|
const CF_APP_ID = process.env.CF_APP_ID || '';
|
||||||
const CF_APP_TOKEN = process.env.CF_APP_TOKEN || '';
|
const CF_APP_TOKEN = process.env.CF_APP_TOKEN || '';
|
||||||
const CF_BASE = `https://rtc.live.cloudflare.com/v1/apps/${CF_APP_ID}`;
|
const CF_BASE = `https://rtc.live.cloudflare.com/v1/apps/${CF_APP_ID}`;
|
||||||
|
const CONTAINER = process.env.CONTAINER_NAME || 'css-test';
|
||||||
// Container's PulseAudio sink to write mic audio into
|
const IDLE_TIMEOUT = 10000; // 10 seconds
|
||||||
// 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',
|
|
||||||
];
|
|
||||||
|
|
||||||
if (!CF_APP_ID || !CF_APP_TOKEN) {
|
if (!CF_APP_ID || !CF_APP_TOKEN) {
|
||||||
console.error('[sig] CF_APP_ID and CF_APP_TOKEN must be set');
|
console.error('[sig] CF_APP_ID and CF_APP_TOKEN must be set');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Cloudflare API helpers ────────────────────────────────────────────────────
|
||||||
async function cfPost(path, body) {
|
async function cfPost(path, body) {
|
||||||
const res = await fetch(`${CF_BASE}${path}`, {
|
const res = await fetch(`${CF_BASE}${path}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@ -60,6 +52,7 @@ async function cfPut(path, body) {
|
|||||||
return json;
|
return json;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Express + WebSocket ───────────────────────────────────────────────────────
|
||||||
const app = express();
|
const app = express();
|
||||||
const server = http.createServer(app);
|
const server = http.createServer(app);
|
||||||
const wss = new WebSocket.Server({ server });
|
const wss = new WebSocket.Server({ server });
|
||||||
@ -67,67 +60,163 @@ const wss = new WebSocket.Server({ server });
|
|||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.get('/', (req, res) => res.sendFile(path.join(__dirname, '..', 'client.html')));
|
app.get('/', (req, res) => res.sendFile(path.join(__dirname, '..', 'client.html')));
|
||||||
|
|
||||||
|
// ── Session registry ──────────────────────────────────────────────────────────
|
||||||
const agentSessions = new Map();
|
const agentSessions = new Map();
|
||||||
const browserToAgent = new Map();
|
const browserToAgent = new Map();
|
||||||
|
|
||||||
// Per-browser pacat process for mic audio
|
|
||||||
// browserSessionId -> pacat child process
|
|
||||||
const pacatProcesses = new Map();
|
const pacatProcesses = new Map();
|
||||||
|
|
||||||
function startPacat(browserSessId) {
|
// ── Controller queue ──────────────────────────────────────────────────────────
|
||||||
// Find the container ID for this browser session
|
let currentController = null;
|
||||||
// pacat runs inside the Docker container via docker exec
|
let lastControllerName = null;
|
||||||
const agentId = browserToAgent.get(browserSessId);
|
let idleTimer = null;
|
||||||
const entry = agentSessions.get(agentId);
|
const controlQueue = [];
|
||||||
if (!entry) return null;
|
let containerRestarting = false;
|
||||||
|
|
||||||
// Get container name from agent — agent sends it on register
|
function broadcastQueueState() {
|
||||||
const containerName = entry.containerName || 'css-test';
|
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', [
|
function resetIdleTimer(entry) {
|
||||||
'exec', '-i', containerName,
|
if (idleTimer) clearTimeout(idleTimer);
|
||||||
'pacat', ...PACAT_ARGS,
|
idleTimer = setTimeout(() => {
|
||||||
]);
|
console.log(`[sig] controller ${entry.visitorName} idle timeout`);
|
||||||
|
releaseControl(entry, 'idle');
|
||||||
|
}, IDLE_TIMEOUT);
|
||||||
|
}
|
||||||
|
|
||||||
pacat.stderr.on('data', d => {
|
async function applyController(entry) {
|
||||||
console.error(`[pacat:${browserSessId}]`, d.toString().trim());
|
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 => {
|
pacat.on('close', code => {
|
||||||
console.log(`[pacat:${browserSessId}] exited with code ${code}`);
|
console.log(`[pacat:${browserSessId}] exited with code ${code}`);
|
||||||
pacatProcesses.delete(browserSessId);
|
pacatProcesses.delete(browserSessId);
|
||||||
});
|
});
|
||||||
|
|
||||||
pacatProcesses.set(browserSessId, pacat);
|
pacatProcesses.set(browserSessId, pacat);
|
||||||
console.log(`[sig] pacat started for browser session ${browserSessId}`);
|
|
||||||
return pacat;
|
return pacat;
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopPacat(browserSessId) {
|
function stopPacat(browserSessId) {
|
||||||
const pacat = pacatProcesses.get(browserSessId);
|
const pacat = pacatProcesses.get(browserSessId);
|
||||||
if (pacat) {
|
if (pacat) { pacat.kill(); pacatProcesses.delete(browserSessId); }
|
||||||
pacat.kill();
|
|
||||||
pacatProcesses.delete(browserSessId);
|
|
||||||
console.log(`[sig] pacat stopped for browser session ${browserSessId}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── WebSocket handler ─────────────────────────────────────────────────────────
|
||||||
wss.on('connection', ws => {
|
wss.on('connection', ws => {
|
||||||
let role = null;
|
let role = null;
|
||||||
let agentSessionId = null;
|
let agentSessionId = null;
|
||||||
let browserSessId = null;
|
let browserSessId = null;
|
||||||
|
let visitorName = null;
|
||||||
|
|
||||||
ws.on('message', async (raw, isBinary) => {
|
ws.on('message', async (raw, isBinary) => {
|
||||||
// Binary messages are raw PCM audio from browser microphone
|
|
||||||
if (isBinary) {
|
if (isBinary) {
|
||||||
if (browserSessId) {
|
if (browserSessId) {
|
||||||
let pacat = pacatProcesses.get(browserSessId);
|
let pacat = pacatProcesses.get(browserSessId);
|
||||||
if (!pacat) {
|
if (!pacat) pacat = startPacat(browserSessId);
|
||||||
pacat = startPacat(browserSessId);
|
if (pacat && pacat.stdin.writable) pacat.stdin.write(raw);
|
||||||
}
|
|
||||||
if (pacat && pacat.stdin.writable) {
|
|
||||||
pacat.stdin.write(raw);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -142,17 +231,45 @@ wss.on('connection', ws => {
|
|||||||
agentSessionId: msg.sessionId,
|
agentSessionId: msg.sessionId,
|
||||||
videoTrackName: msg.videoTrackName,
|
videoTrackName: msg.videoTrackName,
|
||||||
audioTrackName: msg.audioTrackName,
|
audioTrackName: msg.audioTrackName,
|
||||||
containerName: msg.containerName || 'css-test',
|
|
||||||
agentWs: ws,
|
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') {
|
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') {
|
else if (msg.type === 'browser_connect') {
|
||||||
@ -177,6 +294,8 @@ wss.on('connection', ws => {
|
|||||||
sessionId: browserSessId,
|
sessionId: browserSessId,
|
||||||
sdpAnswer: newSession.sessionDescription,
|
sdpAnswer: newSession.sessionDescription,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
broadcastQueueState();
|
||||||
}
|
}
|
||||||
|
|
||||||
else if (msg.type === 'pull_track') {
|
else if (msg.type === 'pull_track') {
|
||||||
@ -216,6 +335,10 @@ wss.on('connection', ws => {
|
|||||||
entry.agentWs.send(JSON.stringify(msg.event));
|
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', () => {
|
ws.on('close', () => {
|
||||||
@ -223,7 +346,11 @@ wss.on('connection', ws => {
|
|||||||
agentSessions.delete(agentSessionId);
|
agentSessions.delete(agentSessionId);
|
||||||
console.log(`[sig] agent disconnected — sessionId=${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);
|
stopPacat(browserSessId);
|
||||||
browserToAgent.delete(browserSessId);
|
browserToAgent.delete(browserSessId);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user