0.1.4 - voice chat security
- improve voice chat security by implementing join validation - fix XSS in showIncomingCallUI
This commit is contained in:
parent
cb067f3785
commit
cab6314bc7
14 changed files with 229 additions and 63 deletions
|
|
@ -7,8 +7,8 @@ android {
|
|||
applicationId "olcxja.miarven"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 4
|
||||
versionName "0.1.3"
|
||||
versionCode 5
|
||||
versionName "0.1.4"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
|
|
|||
|
|
@ -322,5 +322,7 @@
|
|||
"label.voice.sensitivity": "input sensitivity",
|
||||
"label.voice.default": "default",
|
||||
"error.call.microphone": "microphone error :c",
|
||||
"error.voice.reconnect.failed": "voice connection lost :c"
|
||||
"error.voice.reconnect.failed": "voice connection lost :c",
|
||||
"error.voice.ticket.rejected": "voice auth failed :c check VOICE_KEY/server version",
|
||||
"error.voice.not.configured": "voice server not configured :c missing VOICE_KEY"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -331,5 +331,7 @@
|
|||
"label.voice.sensitivity": "Input sensitivity",
|
||||
"label.voice.default": "Default",
|
||||
"error.call.microphone": "Microphone error",
|
||||
"error.voice.reconnect.failed": "Voice connection lost"
|
||||
"error.voice.reconnect.failed": "Voice connection lost",
|
||||
"error.voice.ticket.rejected": "Voice authentication failed - check VOICE_KEY/server version",
|
||||
"error.voice.not.configured": "Voice server is not configured (missing VOICE_KEY)"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -331,5 +331,7 @@
|
|||
"label.voice.sensitivity": "Czułość wejścia",
|
||||
"label.voice.default": "Domyślne",
|
||||
"error.call.microphone": "Błąd mikrofonu",
|
||||
"error.voice.reconnect.failed": "Utracono połączenie głosowe"
|
||||
"error.voice.reconnect.failed": "Utracono połączenie głosowe",
|
||||
"error.voice.ticket.rejected": "Autoryzacja głosu nieudana - sprawdź VOICE_KEY/wersję serwera",
|
||||
"error.voice.not.configured": "Serwer głosowy nieskonfigurowany (brak VOICE_KEY)"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
const CLIENT_VERSION = "0.1.3";
|
||||
const CLIENT_VERSION = "0.1.4";
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ let deadPeers = new Set();
|
|||
let lastVoiceServerPacketAt = 0;
|
||||
let voicePingTimer = null;
|
||||
let voiceReconnectAttempts = 0;
|
||||
let lastVoiceServerUrl = null;
|
||||
let voiceSocketOpenedAt = 0;
|
||||
|
||||
function cleanupPeerMedia(userId) {
|
||||
for (const aid of [userId, `${userId}_screen`]) {
|
||||
|
|
@ -640,34 +640,90 @@ class TaskQueue {
|
|||
clear() { this.queue = []; }
|
||||
}
|
||||
|
||||
let voiceJoinInFlight = false;
|
||||
|
||||
async function initiateVoiceCall(roomId = null) {
|
||||
if (currentVoiceWs) {
|
||||
if (currentVoiceWs || voiceJoinInFlight) {
|
||||
showBlahNotification("error:already.in.call");
|
||||
return;
|
||||
}
|
||||
currentVoiceCallType = window.currentRoomType === "groups" ? "groups" : "dms";
|
||||
currentVoiceCallId = roomId || window.currentDmId;
|
||||
|
||||
showAction("action.call.init", "callinit");
|
||||
voiceJoinInFlight = true;
|
||||
|
||||
try {
|
||||
showAction("action.call.init", "callinit");
|
||||
const joined = await requestVoiceJoin();
|
||||
clearAction("callinit");
|
||||
|
||||
if (joined) {
|
||||
await startVoiceSession(joined.serverUrl, joined.ticket);
|
||||
}
|
||||
} finally {
|
||||
voiceJoinInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Asks the main server for a voice server IP plus a fresh single-use ticket.
|
||||
// The join payload carries publicId/connKey/proof - the client encrypts the
|
||||
// proof with connKey (AES-GCM) so the wire never contains a usable credential.
|
||||
async function requestVoiceJoin() {
|
||||
let res = await fetchEncrypted("room/voice/join", JSON.stringify({
|
||||
string1: currentVoiceCallType,
|
||||
string2: currentVoiceCallId
|
||||
}));
|
||||
clearAction("callinit");
|
||||
|
||||
if (res.startsWith("success:")) {
|
||||
let bestIp = res.split("success:")[1];
|
||||
if (!bestIp.startsWith("ws://") && !bestIp.startsWith("wss://")) {
|
||||
bestIp = bestIp.includes("localhost") || bestIp.includes("127.0.0.1") ? "ws://" + bestIp : "wss://" + bestIp;
|
||||
}
|
||||
await startVoiceSession(bestIp);
|
||||
} else {
|
||||
if (!res.startsWith("success:")) {
|
||||
showBlahNotification(res);
|
||||
return null;
|
||||
}
|
||||
const payload = res.substring("success:".length);
|
||||
const parts = payload.split("|");
|
||||
if (parts.length !== 4) return null;
|
||||
|
||||
let serverUrl = parts[0];
|
||||
const [publicId, connKey, proof] = [parts[1], parts[2], parts[3]];
|
||||
if (!serverUrl.startsWith("ws://") && !serverUrl.startsWith("wss://")) {
|
||||
serverUrl = serverUrl.includes("localhost") || serverUrl.includes("127.0.0.1") ? "ws://" + serverUrl : "wss://" + serverUrl;
|
||||
}
|
||||
|
||||
try {
|
||||
const keyBytes = b64uToBytes(connKey);
|
||||
const key = await crypto.subtle.importKey("raw", keyBytes, "AES-GCM", false, ["encrypt"]);
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const ct = new Uint8Array(await crypto.subtle.encrypt({name: "AES-GCM", iv}, key, b64uToBytes(proof)));
|
||||
const ticket = `${publicId}.${bytesToB64u(iv)}.${bytesToB64u(ct)}`;
|
||||
return {serverUrl, ticket};
|
||||
} catch (e) {
|
||||
console.warn("Voice: failed to build ticket frame", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function startVoiceSession(serverUrl) {
|
||||
lastVoiceServerUrl = serverUrl;
|
||||
function b64uToBytes(s) {
|
||||
const bin = atob(s.replace(/-/g, "+").replace(/_/g, "/"));
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
function bytesToB64u(a) {
|
||||
let bin = "";
|
||||
for (const byte of a) bin += String.fromCharCode(byte);
|
||||
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
async function startVoiceSession(serverUrl, ticket) {
|
||||
if (!ticket) throw new Error("missing voice ticket");
|
||||
// Single-session invariant: a stale socket (e.g. a double-fired join)
|
||||
// must be replaced BEFORE its onopen sends the same ticket twice -
|
||||
// otherwise the duplicate validation burns the single-use ticket.
|
||||
if (currentVoiceWs) {
|
||||
try { currentVoiceWs.onclose = null; currentVoiceWs.close(); } catch (e) {}
|
||||
currentVoiceWs = null;
|
||||
}
|
||||
const myTicket = ticket; // captured PER INVOCATION - the global may be
|
||||
// overwritten by a racing second join, and reading it inside onopen made
|
||||
// both sockets send the same (already consumed) ticket.
|
||||
showVoiceUI();
|
||||
updateVoiceUIUsers();
|
||||
setVoiceStatus("connecting");
|
||||
|
|
@ -680,12 +736,17 @@ async function startVoiceSession(serverUrl) {
|
|||
}
|
||||
wsUrl.searchParams.set("room", currentVoiceCallId);
|
||||
wsUrl.searchParams.set("userId", window.id);
|
||||
wsUrl.searchParams.set("secret", "none");
|
||||
|
||||
|
||||
currentVoiceWs = new WebSocket(wsUrl.toString());
|
||||
currentVoiceWs.binaryType = 'arraybuffer';
|
||||
|
||||
|
||||
currentVoiceWs.onopen = async () => {
|
||||
// Ticket must be the very first frame - the voice server
|
||||
// authenticates the connection before joining it to the room.
|
||||
voiceSocketOpenedAt = Date.now();
|
||||
console.debug("Voice: sending ticket", String(myTicket).split(".")[0]);
|
||||
currentVoiceWs.send(myTicket);
|
||||
|
||||
setVoiceStatus("connected");
|
||||
let btn = document.getElementById("voice-btn-mic");
|
||||
if (btn) {
|
||||
|
|
@ -760,7 +821,7 @@ async function startVoiceSession(serverUrl) {
|
|||
|
||||
let vadHangover = 0;
|
||||
senderNode.port.onmessage = async (e) => {
|
||||
if (currentVoiceWs.readyState !== WebSocket.OPEN) return;
|
||||
if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN) return;
|
||||
// Backpressure valve: when the socket is congested, drop mic
|
||||
// frames instead of queueing them - queued audio would arrive
|
||||
// minutes late and drown the connection for every stream.
|
||||
|
|
@ -933,6 +994,7 @@ async function startVoiceSession(serverUrl) {
|
|||
};
|
||||
|
||||
currentVoiceWs.onclose = (ev) => {
|
||||
console.debug("Voice: socket closed", JSON.stringify({ reason: ev && ev.reason, code: ev && ev.code, aliveForMs: Date.now() - voiceSocketOpenedAt }));
|
||||
// Server closes with reason "Kicked" when another connection took
|
||||
// our userId slot (second device/tab). Reconnecting would start an
|
||||
// infinite kick-war - give up cleanly instead. Any other close
|
||||
|
|
@ -941,6 +1003,14 @@ async function startVoiceSession(serverUrl) {
|
|||
endVoiceSession();
|
||||
return;
|
||||
}
|
||||
// Rejected right after opening = ticket/server-key problem.
|
||||
// Retrying cannot succeed - surface it instead of looping.
|
||||
if (ev && ev.reason && Date.now() - voiceSocketOpenedAt < 6000) {
|
||||
console.error("Voice: connection rejected by server:", ev.reason);
|
||||
showBlahNotification("error:voice.ticket.rejected");
|
||||
endVoiceSession();
|
||||
return;
|
||||
}
|
||||
scheduleVoiceReconnect();
|
||||
};
|
||||
|
||||
|
|
@ -1058,10 +1128,12 @@ function showIncomingCallUI(roomId) {
|
|||
|
||||
let incomingUi = document.createElement("div");
|
||||
incomingUi.id = "incoming-call-popup";
|
||||
// Remote-controlled values (username/avatar) are NEVER interpolated into
|
||||
// HTML - they are assigned via textContent/src after the skeleton.
|
||||
incomingUi.innerHTML = `
|
||||
<img class="incoming-call-avatar" src="${cachedPfp || "assets/default_avatar.png"}" alt="">
|
||||
<img class="incoming-call-avatar" alt="">
|
||||
<div class="incoming-call-info">
|
||||
<span class="incoming-call-name">${cachedName || "..."}</span>
|
||||
<span class="incoming-call-name"></span>
|
||||
<span class="incoming-call-subtitle">${processBlah("title.incoming.call")} · ${processBlah(`:desc.call.in.chat:${voiceActiveUsers.length}`)}</span>
|
||||
</div>
|
||||
<div class="incoming-call-actions">
|
||||
|
|
@ -1073,6 +1145,8 @@ function showIncomingCallUI(roomId) {
|
|||
</button>
|
||||
</div>
|
||||
`;
|
||||
incomingUi.querySelector(".incoming-call-name").textContent = cachedName || "...";
|
||||
incomingUi.querySelector(".incoming-call-avatar").src = cachedPfp || "assets/default_avatar.png";
|
||||
document.body.appendChild(incomingUi);
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => incomingUi.classList.add("show"));
|
||||
|
|
@ -2113,7 +2187,7 @@ function teardownVoiceTransport() {
|
|||
}
|
||||
|
||||
function scheduleVoiceReconnect() {
|
||||
if (!currentVoiceCallId || !lastVoiceServerUrl) {
|
||||
if (!currentVoiceCallId || !currentVoiceCallType) {
|
||||
// Real hangup already ran its cleanup.
|
||||
return;
|
||||
}
|
||||
|
|
@ -2126,9 +2200,12 @@ function scheduleVoiceReconnect() {
|
|||
setVoiceStatus("connecting");
|
||||
teardownVoiceTransport();
|
||||
setTimeout(async () => {
|
||||
if (!currentVoiceCallId || !lastVoiceServerUrl) return;
|
||||
if (!currentVoiceCallId || !currentVoiceCallType) return;
|
||||
try {
|
||||
await startVoiceSession(lastVoiceServerUrl);
|
||||
// Tickets are single-use - every reconnect needs a fresh one.
|
||||
const joined = await requestVoiceJoin();
|
||||
if (!joined) { endVoiceSession(); return; }
|
||||
await startVoiceSession(joined.serverUrl, joined.ticket);
|
||||
} catch (err) {
|
||||
console.warn("Voice reconnect failed", err);
|
||||
scheduleVoiceReconnect();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "Miarven",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.4",
|
||||
"description": "An Amazing Capacitor App",
|
||||
"author": {
|
||||
"name": "",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "olcxja.miarven",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.4",
|
||||
"description": "Larpix client",
|
||||
"license": "AGPL-3.0",
|
||||
"author": "olcxja's softworks",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"versionCode": 4,
|
||||
"versionName": "0.1.3",
|
||||
"npmVersion": "0.1.3"
|
||||
"versionCode": 5,
|
||||
"versionName": "0.1.4",
|
||||
"npmVersion": "0.1.4"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -322,5 +322,7 @@
|
|||
"label.voice.sensitivity": "input sensitivity",
|
||||
"label.voice.default": "default",
|
||||
"error.call.microphone": "microphone error :c",
|
||||
"error.voice.reconnect.failed": "voice connection lost :c"
|
||||
"error.voice.reconnect.failed": "voice connection lost :c",
|
||||
"error.voice.ticket.rejected": "voice auth failed :c check VOICE_KEY/server version",
|
||||
"error.voice.not.configured": "voice server not configured :c missing VOICE_KEY"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -331,5 +331,7 @@
|
|||
"label.voice.sensitivity": "Input sensitivity",
|
||||
"label.voice.default": "Default",
|
||||
"error.call.microphone": "Microphone error",
|
||||
"error.voice.reconnect.failed": "Voice connection lost"
|
||||
"error.voice.reconnect.failed": "Voice connection lost",
|
||||
"error.voice.ticket.rejected": "Voice authentication failed - check VOICE_KEY/server version",
|
||||
"error.voice.not.configured": "Voice server is not configured (missing VOICE_KEY)"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -331,5 +331,7 @@
|
|||
"label.voice.sensitivity": "Czułość wejścia",
|
||||
"label.voice.default": "Domyślne",
|
||||
"error.call.microphone": "Błąd mikrofonu",
|
||||
"error.voice.reconnect.failed": "Utracono połączenie głosowe"
|
||||
"error.voice.reconnect.failed": "Utracono połączenie głosowe",
|
||||
"error.voice.ticket.rejected": "Autoryzacja głosu nieudana - sprawdź VOICE_KEY/wersję serwera",
|
||||
"error.voice.not.configured": "Serwer głosowy nieskonfigurowany (brak VOICE_KEY)"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
const CLIENT_VERSION = "0.1.3";
|
||||
const CLIENT_VERSION = "0.1.4";
|
||||
|
|
|
|||
125
webroot/voice.js
125
webroot/voice.js
|
|
@ -40,7 +40,7 @@ let deadPeers = new Set();
|
|||
let lastVoiceServerPacketAt = 0;
|
||||
let voicePingTimer = null;
|
||||
let voiceReconnectAttempts = 0;
|
||||
let lastVoiceServerUrl = null;
|
||||
let voiceSocketOpenedAt = 0;
|
||||
|
||||
function cleanupPeerMedia(userId) {
|
||||
for (const aid of [userId, `${userId}_screen`]) {
|
||||
|
|
@ -640,34 +640,90 @@ class TaskQueue {
|
|||
clear() { this.queue = []; }
|
||||
}
|
||||
|
||||
let voiceJoinInFlight = false;
|
||||
|
||||
async function initiateVoiceCall(roomId = null) {
|
||||
if (currentVoiceWs) {
|
||||
if (currentVoiceWs || voiceJoinInFlight) {
|
||||
showBlahNotification("error:already.in.call");
|
||||
return;
|
||||
}
|
||||
currentVoiceCallType = window.currentRoomType === "groups" ? "groups" : "dms";
|
||||
currentVoiceCallId = roomId || window.currentDmId;
|
||||
|
||||
showAction("action.call.init", "callinit");
|
||||
voiceJoinInFlight = true;
|
||||
|
||||
try {
|
||||
showAction("action.call.init", "callinit");
|
||||
const joined = await requestVoiceJoin();
|
||||
clearAction("callinit");
|
||||
|
||||
if (joined) {
|
||||
await startVoiceSession(joined.serverUrl, joined.ticket);
|
||||
}
|
||||
} finally {
|
||||
voiceJoinInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Asks the main server for a voice server IP plus a fresh single-use ticket.
|
||||
// The join payload carries publicId/connKey/proof - the client encrypts the
|
||||
// proof with connKey (AES-GCM) so the wire never contains a usable credential.
|
||||
async function requestVoiceJoin() {
|
||||
let res = await fetchEncrypted("room/voice/join", JSON.stringify({
|
||||
string1: currentVoiceCallType,
|
||||
string2: currentVoiceCallId
|
||||
}));
|
||||
clearAction("callinit");
|
||||
|
||||
if (res.startsWith("success:")) {
|
||||
let bestIp = res.split("success:")[1];
|
||||
if (!bestIp.startsWith("ws://") && !bestIp.startsWith("wss://")) {
|
||||
bestIp = bestIp.includes("localhost") || bestIp.includes("127.0.0.1") ? "ws://" + bestIp : "wss://" + bestIp;
|
||||
}
|
||||
await startVoiceSession(bestIp);
|
||||
} else {
|
||||
if (!res.startsWith("success:")) {
|
||||
showBlahNotification(res);
|
||||
return null;
|
||||
}
|
||||
const payload = res.substring("success:".length);
|
||||
const parts = payload.split("|");
|
||||
if (parts.length !== 4) return null;
|
||||
|
||||
let serverUrl = parts[0];
|
||||
const [publicId, connKey, proof] = [parts[1], parts[2], parts[3]];
|
||||
if (!serverUrl.startsWith("ws://") && !serverUrl.startsWith("wss://")) {
|
||||
serverUrl = serverUrl.includes("localhost") || serverUrl.includes("127.0.0.1") ? "ws://" + serverUrl : "wss://" + serverUrl;
|
||||
}
|
||||
|
||||
try {
|
||||
const keyBytes = b64uToBytes(connKey);
|
||||
const key = await crypto.subtle.importKey("raw", keyBytes, "AES-GCM", false, ["encrypt"]);
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const ct = new Uint8Array(await crypto.subtle.encrypt({name: "AES-GCM", iv}, key, b64uToBytes(proof)));
|
||||
const ticket = `${publicId}.${bytesToB64u(iv)}.${bytesToB64u(ct)}`;
|
||||
return {serverUrl, ticket};
|
||||
} catch (e) {
|
||||
console.warn("Voice: failed to build ticket frame", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function startVoiceSession(serverUrl) {
|
||||
lastVoiceServerUrl = serverUrl;
|
||||
function b64uToBytes(s) {
|
||||
const bin = atob(s.replace(/-/g, "+").replace(/_/g, "/"));
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
function bytesToB64u(a) {
|
||||
let bin = "";
|
||||
for (const byte of a) bin += String.fromCharCode(byte);
|
||||
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
async function startVoiceSession(serverUrl, ticket) {
|
||||
if (!ticket) throw new Error("missing voice ticket");
|
||||
// Single-session invariant: a stale socket (e.g. a double-fired join)
|
||||
// must be replaced BEFORE its onopen sends the same ticket twice -
|
||||
// otherwise the duplicate validation burns the single-use ticket.
|
||||
if (currentVoiceWs) {
|
||||
try { currentVoiceWs.onclose = null; currentVoiceWs.close(); } catch (e) {}
|
||||
currentVoiceWs = null;
|
||||
}
|
||||
const myTicket = ticket; // captured PER INVOCATION - the global may be
|
||||
// overwritten by a racing second join, and reading it inside onopen made
|
||||
// both sockets send the same (already consumed) ticket.
|
||||
showVoiceUI();
|
||||
updateVoiceUIUsers();
|
||||
setVoiceStatus("connecting");
|
||||
|
|
@ -680,12 +736,17 @@ async function startVoiceSession(serverUrl) {
|
|||
}
|
||||
wsUrl.searchParams.set("room", currentVoiceCallId);
|
||||
wsUrl.searchParams.set("userId", window.id);
|
||||
wsUrl.searchParams.set("secret", "none");
|
||||
|
||||
|
||||
currentVoiceWs = new WebSocket(wsUrl.toString());
|
||||
currentVoiceWs.binaryType = 'arraybuffer';
|
||||
|
||||
|
||||
currentVoiceWs.onopen = async () => {
|
||||
// Ticket must be the very first frame - the voice server
|
||||
// authenticates the connection before joining it to the room.
|
||||
voiceSocketOpenedAt = Date.now();
|
||||
console.debug("Voice: sending ticket", String(myTicket).split(".")[0]);
|
||||
currentVoiceWs.send(myTicket);
|
||||
|
||||
setVoiceStatus("connected");
|
||||
let btn = document.getElementById("voice-btn-mic");
|
||||
if (btn) {
|
||||
|
|
@ -760,7 +821,7 @@ async function startVoiceSession(serverUrl) {
|
|||
|
||||
let vadHangover = 0;
|
||||
senderNode.port.onmessage = async (e) => {
|
||||
if (currentVoiceWs.readyState !== WebSocket.OPEN) return;
|
||||
if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN) return;
|
||||
// Backpressure valve: when the socket is congested, drop mic
|
||||
// frames instead of queueing them - queued audio would arrive
|
||||
// minutes late and drown the connection for every stream.
|
||||
|
|
@ -933,6 +994,7 @@ async function startVoiceSession(serverUrl) {
|
|||
};
|
||||
|
||||
currentVoiceWs.onclose = (ev) => {
|
||||
console.debug("Voice: socket closed", JSON.stringify({ reason: ev && ev.reason, code: ev && ev.code, aliveForMs: Date.now() - voiceSocketOpenedAt }));
|
||||
// Server closes with reason "Kicked" when another connection took
|
||||
// our userId slot (second device/tab). Reconnecting would start an
|
||||
// infinite kick-war - give up cleanly instead. Any other close
|
||||
|
|
@ -941,6 +1003,14 @@ async function startVoiceSession(serverUrl) {
|
|||
endVoiceSession();
|
||||
return;
|
||||
}
|
||||
// Rejected right after opening = ticket/server-key problem.
|
||||
// Retrying cannot succeed - surface it instead of looping.
|
||||
if (ev && ev.reason && Date.now() - voiceSocketOpenedAt < 6000) {
|
||||
console.error("Voice: connection rejected by server:", ev.reason);
|
||||
showBlahNotification("error:voice.ticket.rejected");
|
||||
endVoiceSession();
|
||||
return;
|
||||
}
|
||||
scheduleVoiceReconnect();
|
||||
};
|
||||
|
||||
|
|
@ -1058,10 +1128,12 @@ function showIncomingCallUI(roomId) {
|
|||
|
||||
let incomingUi = document.createElement("div");
|
||||
incomingUi.id = "incoming-call-popup";
|
||||
// Remote-controlled values (username/avatar) are NEVER interpolated into
|
||||
// HTML - they are assigned via textContent/src after the skeleton.
|
||||
incomingUi.innerHTML = `
|
||||
<img class="incoming-call-avatar" src="${cachedPfp || "assets/default_avatar.png"}" alt="">
|
||||
<img class="incoming-call-avatar" alt="">
|
||||
<div class="incoming-call-info">
|
||||
<span class="incoming-call-name">${cachedName || "..."}</span>
|
||||
<span class="incoming-call-name"></span>
|
||||
<span class="incoming-call-subtitle">${processBlah("title.incoming.call")} · ${processBlah(`:desc.call.in.chat:${voiceActiveUsers.length}`)}</span>
|
||||
</div>
|
||||
<div class="incoming-call-actions">
|
||||
|
|
@ -1073,6 +1145,8 @@ function showIncomingCallUI(roomId) {
|
|||
</button>
|
||||
</div>
|
||||
`;
|
||||
incomingUi.querySelector(".incoming-call-name").textContent = cachedName || "...";
|
||||
incomingUi.querySelector(".incoming-call-avatar").src = cachedPfp || "assets/default_avatar.png";
|
||||
document.body.appendChild(incomingUi);
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => incomingUi.classList.add("show"));
|
||||
|
|
@ -2113,7 +2187,7 @@ function teardownVoiceTransport() {
|
|||
}
|
||||
|
||||
function scheduleVoiceReconnect() {
|
||||
if (!currentVoiceCallId || !lastVoiceServerUrl) {
|
||||
if (!currentVoiceCallId || !currentVoiceCallType) {
|
||||
// Real hangup already ran its cleanup.
|
||||
return;
|
||||
}
|
||||
|
|
@ -2126,9 +2200,12 @@ function scheduleVoiceReconnect() {
|
|||
setVoiceStatus("connecting");
|
||||
teardownVoiceTransport();
|
||||
setTimeout(async () => {
|
||||
if (!currentVoiceCallId || !lastVoiceServerUrl) return;
|
||||
if (!currentVoiceCallId || !currentVoiceCallType) return;
|
||||
try {
|
||||
await startVoiceSession(lastVoiceServerUrl);
|
||||
// Tickets are single-use - every reconnect needs a fresh one.
|
||||
const joined = await requestVoiceJoin();
|
||||
if (!joined) { endVoiceSession(); return; }
|
||||
await startVoiceSession(joined.serverUrl, joined.ticket);
|
||||
} catch (err) {
|
||||
console.warn("Voice reconnect failed", err);
|
||||
scheduleVoiceReconnect();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue