security
This commit is contained in:
parent
b12a75cfc4
commit
b2a6c19bc0
4 changed files with 554 additions and 116 deletions
|
|
@ -1149,12 +1149,20 @@ function createAvatarSvg(name, size = 512) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getAvatarUrl(id, username) {
|
async function getAvatarUrl(id, username) {
|
||||||
|
//S22: helper robi fetch istnienia pliku - bez cache każde odświeżenie listy
|
||||||
|
//generowało 1-2 żądania NA wiersz (główny składnik N+1).
|
||||||
|
//Cache'owane TYLKO pozytywne potwierdzenia - nieudane lookupy (chwilowy
|
||||||
|
//network itp.) nie mogą przyklejać fallback-SVG na minutę
|
||||||
|
if (!window.pfpExistenceCache) window.pfpExistenceCache = {};
|
||||||
|
const cached = window.pfpExistenceCache[id];
|
||||||
|
if (cached && Date.now() - cached.ts < 60000) return cached.url;
|
||||||
try {
|
try {
|
||||||
let pfpUrl = `${url}/user/storage/public/getentry?id=${id}&e=larp.profile.pfp`;
|
let pfpUrl = `${url}/user/storage/public/getentry?id=${id}&e=larp.profile.pfp`;
|
||||||
if (id === window.id && window.profileUpdateTimestamp) pfpUrl += `&t=${window.profileUpdateTimestamp}`;
|
if (id === window.id && window.profileUpdateTimestamp) pfpUrl += `&t=${window.profileUpdateTimestamp}`;
|
||||||
if ((await fetchAsync(pfpUrl)) == "") {
|
if ((await fetchAsync(pfpUrl)) == "") {
|
||||||
throw Error();
|
throw Error();
|
||||||
}
|
}
|
||||||
|
window.pfpExistenceCache[id] = { url: pfpUrl, ts: Date.now() };
|
||||||
return pfpUrl;
|
return pfpUrl;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return createAvatarSvg(username);
|
return createAvatarSvg(username);
|
||||||
|
|
@ -1162,11 +1170,15 @@ async function getAvatarUrl(id, username) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getGroupAvatarUrl(groupId, groupName) {
|
async function getGroupAvatarUrl(groupId, groupName) {
|
||||||
|
if (!window.groupPfpExistenceCache) window.groupPfpExistenceCache = {};
|
||||||
|
const cached = window.groupPfpExistenceCache[groupId];
|
||||||
|
if (cached && Date.now() - cached.ts < 60000) return cached.url;
|
||||||
try {
|
try {
|
||||||
let pfpUrl = `${url}/group/storage/public/getentry?id=${groupId}&e=larp.profile.pfp`;
|
let pfpUrl = `${url}/group/storage/public/getentry?id=${groupId}&e=larp.profile.pfp`;
|
||||||
if ((await fetchAsync(pfpUrl)) == "") {
|
if ((await fetchAsync(pfpUrl)) == "") {
|
||||||
throw Error();
|
throw Error();
|
||||||
}
|
}
|
||||||
|
window.groupPfpExistenceCache[groupId] = { url: pfpUrl, ts: Date.now() };
|
||||||
return pfpUrl;
|
return pfpUrl;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return createAvatarSvg(groupName);
|
return createAvatarSvg(groupName);
|
||||||
|
|
@ -1557,7 +1569,7 @@ async function switchRoomsBar(title, content) {
|
||||||
|
|
||||||
await delay(200);
|
await delay(200);
|
||||||
|
|
||||||
roomsTopBarTransition.innerHTML = processBlah(title);
|
roomsTopBarTransition.innerHTML = escapeHtmlText(processBlah(title));
|
||||||
|
|
||||||
|
|
||||||
let parser = new DOMParser();
|
let parser = new DOMParser();
|
||||||
|
|
@ -1636,10 +1648,10 @@ async function switchRoomContent(title, content, showRoomBar, icon = "", skipMob
|
||||||
|
|
||||||
let clickableContent = showRoomBar ?
|
let clickableContent = showRoomBar ?
|
||||||
`<button id="room-topbar-clickable" class="inherit" onclick="mobileNavDetails();" style="cursor: pointer; display: flex; align-items: center; border-radius: 0.65rem; background: transparent; outline: none; height: 100%; text-align: left; min-width: 0; width: auto; max-width: 100%;">
|
`<button id="room-topbar-clickable" class="inherit" onclick="mobileNavDetails();" style="cursor: pointer; display: flex; align-items: center; border-radius: 0.65rem; background: transparent; outline: none; height: 100%; text-align: left; min-width: 0; width: auto; max-width: 100%;">
|
||||||
${icon ? icon + '<space></space>' : ''}<inherit style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; min-width: 0; width: auto;">${processBlah(title)}</inherit>
|
${icon ? icon + '<space></space>' : ''}<inherit style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; min-width: 0; width: auto;">${escapeHtmlText(processBlah(title))}</inherit>
|
||||||
</button>` :
|
</button>` :
|
||||||
`<inherit style="display: flex; align-items: center; height: 100%; min-width: 0; width: auto; max-width: 100%;">
|
`<inherit style="display: flex; align-items: center; height: 100%; min-width: 0; width: auto; max-width: 100%;">
|
||||||
${icon ? icon + '<space></space>' : ''}<inherit style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; min-width: 0; width: auto;">${processBlah(title)}</inherit>
|
${icon ? icon + '<space></space>' : ''}<inherit style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; min-width: 0; width: auto;">${escapeHtmlText(processBlah(title))}</inherit>
|
||||||
</inherit>`;
|
</inherit>`;
|
||||||
roomsTopBarTransition.innerHTML = `${backBtnHtml}${clickableContent}<div class="flex-spacer"></div>${pinCallBtns}`;
|
roomsTopBarTransition.innerHTML = `${backBtnHtml}${clickableContent}<div class="flex-spacer"></div>${pinCallBtns}`;
|
||||||
|
|
||||||
|
|
@ -1710,7 +1722,7 @@ async function switchDetailsContent(title, content, showBack = false, backAction
|
||||||
pinCallBtns = `${groupMenuBtnHtml}${pinCallBtns}`;
|
pinCallBtns = `${groupMenuBtnHtml}${pinCallBtns}`;
|
||||||
}
|
}
|
||||||
let backBtn = showBack ? `<button class="topbar-action-btn" onclick="${backAction}" style="margin-right: 0.5rem">${ICONS.arrowBack({ width: "1.5rem", height: "1.5rem" })}</button>` : "";
|
let backBtn = showBack ? `<button class="topbar-action-btn" onclick="${backAction}" style="margin-right: 0.5rem">${ICONS.arrowBack({ width: "1.5rem", height: "1.5rem" })}</button>` : "";
|
||||||
roomsTopBarTransition.innerHTML = `<inherit style="display: flex; align-items: center;">${backBtn}<inherit>${processBlah(title)}</inherit></inherit><div class="flex-spacer"></div>${pinCallBtns}`;
|
roomsTopBarTransition.innerHTML = `<inherit style="display: flex; align-items: center;">${backBtn}<inherit>${escapeHtmlText(processBlah(title))}</inherit></inherit><div class="flex-spacer"></div>${pinCallBtns}`;
|
||||||
|
|
||||||
let parser = new DOMParser();
|
let parser = new DOMParser();
|
||||||
let doc = parser.parseFromString(content, "text/html");
|
let doc = parser.parseFromString(content, "text/html");
|
||||||
|
|
@ -2033,7 +2045,9 @@ async function openProfile(accountId) {
|
||||||
});
|
});
|
||||||
let roleRes = await fetchEncrypted("room/role", payload);
|
let roleRes = await fetchEncrypted("room/role", payload);
|
||||||
if (roleRes && !roleRes.startsWith("error:")) {
|
if (roleRes && !roleRes.startsWith("error:")) {
|
||||||
let mappedRole = "role." + roleRes.replace(" ", "_");
|
//roleRes trafia do innerHTML - whitelist znaków zamiast surowej
|
||||||
|
//podstawienia (audyt W6: serwer-controlled string w szablonie)
|
||||||
|
let mappedRole = "role." + String(roleRes).replace(/[^A-Za-z0-9_.]/g, "_");
|
||||||
roleHtml = `<div class="profile-role" style="opacity:0.7; font-size:0.9rem; margin-top:0.3rem;"><blah>${mappedRole}</blah></div>`;
|
roleHtml = `<div class="profile-role" style="opacity:0.7; font-size:0.9rem; margin-top:0.3rem;"><blah>${mappedRole}</blah></div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2275,7 +2289,7 @@ async function renderInvites(res, type) {
|
||||||
}
|
}
|
||||||
|
|
||||||
let entry = invitesEntry
|
let entry = invitesEntry
|
||||||
.replaceAll("{username}", groupName)
|
.replaceAll("{username}", escapeHtmlText(groupName))
|
||||||
.replaceAll("{pfp}", pfp)
|
.replaceAll("{pfp}", pfp)
|
||||||
.replaceAll("{desc}", `<blah>${desc}</blah>`)
|
.replaceAll("{desc}", `<blah>${desc}</blah>`)
|
||||||
.replaceAll("{actions}", actions)
|
.replaceAll("{actions}", actions)
|
||||||
|
|
@ -2644,7 +2658,13 @@ function handleMobileSwipe() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//S22: generacje renderów - przy nakładających się refreshDms/refreshGroups
|
||||||
|
//(ws events, fallbacki) starszy wynik nie może nadpisać nowszego innerHTML-em
|
||||||
|
let dmListRenderGen = 0;
|
||||||
|
let groupListRenderGen = 0;
|
||||||
|
|
||||||
async function renderDms(res) {
|
async function renderDms(res) {
|
||||||
|
const gen = ++dmListRenderGen;
|
||||||
let container = document.getElementById("dms-list");
|
let container = document.getElementById("dms-list");
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
|
|
@ -2665,8 +2685,13 @@ async function renderDms(res) {
|
||||||
let myId = `${id};${host}`;
|
let myId = `${id};${host}`;
|
||||||
let targetId = sanitizeId(parts[0] === myId ? parts[1] : parts[0]);
|
let targetId = sanitizeId(parts[0] === myId ? parts[1] : parts[0]);
|
||||||
|
|
||||||
let targetUsername = await fetchAsync(`${url}/idtoname?id=${targetId}`);
|
//S22: cache nazw - idtoname było wołane od nowa przy każdym renderze
|
||||||
if (!targetUsername || targetUsername.trim() === "" || targetUsername.startsWith("error:")) continue;
|
let targetUsername = window.dmUsernameCache ? window.dmUsernameCache[targetId] : null;
|
||||||
|
if (!targetUsername) {
|
||||||
|
targetUsername = await fetchAsync(`${url}/idtoname?id=${targetId}`);
|
||||||
|
if (!targetUsername || targetUsername.trim() === "" || targetUsername.startsWith("error:")) continue;
|
||||||
|
if (window.dmUsernameCache) window.dmUsernameCache[targetId] = targetUsername;
|
||||||
|
}
|
||||||
|
|
||||||
let pfp = await getAvatarUrl(targetId, targetUsername);
|
let pfp = await getAvatarUrl(targetId, targetUsername);
|
||||||
let displayName = escapeHtml(targetUsername.split(':')[0]);
|
let displayName = escapeHtml(targetUsername.split(':')[0]);
|
||||||
|
|
@ -2694,11 +2719,13 @@ async function renderDms(res) {
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (gen !== dmListRenderGen) return; //nowszy render przejął listę
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
container.classList.remove("empty");
|
container.classList.remove("empty");
|
||||||
updateVoicePresenceUI();
|
updateVoicePresenceUI();
|
||||||
} else {
|
} else {
|
||||||
|
if (gen !== dmListRenderGen) return;
|
||||||
container.innerHTML = `<p style="opacity: 0.5;"><blah>desc.no.dms</blah></p>`;
|
container.innerHTML = `<p style="opacity: 0.5;"><blah>desc.no.dms</blah></p>`;
|
||||||
container.classList.add("empty");
|
container.classList.add("empty");
|
||||||
let blahTags = container.getElementsByTagName("blah");
|
let blahTags = container.getElementsByTagName("blah");
|
||||||
|
|
@ -2716,7 +2743,17 @@ async function renderDms(res) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//S22: TTL-cache nazw grup (60 s) - nazwy rzadko się zmieniają,
|
||||||
|
//a group/info było najdroższym elementem renderu listy grup
|
||||||
|
function getGroupNameFromCache(groupId) {
|
||||||
|
if (!window.groupNameCache) return null;
|
||||||
|
let entry = window.groupNameCache[sanitizeId(groupId)];
|
||||||
|
if (!entry || Date.now() - entry.ts > 60000) return null;
|
||||||
|
return entry.name;
|
||||||
|
}
|
||||||
|
|
||||||
async function renderGroups(res) {
|
async function renderGroups(res) {
|
||||||
|
const gen = ++groupListRenderGen;
|
||||||
let container = document.getElementById("groups-list");
|
let container = document.getElementById("groups-list");
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
|
|
@ -2737,11 +2774,24 @@ async function renderGroups(res) {
|
||||||
|
|
||||||
seedCallStateFromList(groupId, groupData.call === true);
|
seedCallStateFromList(groupId, groupData.call === true);
|
||||||
|
|
||||||
let infoRes = await fetchEncrypted("group/info", groupId);
|
//S22: TTL-cache nazw grup - group/info było wołane per grupa per render
|
||||||
if (infoRes && infoRes.startsWith("{")) {
|
let groupName = getGroupNameFromCache(groupId);
|
||||||
let info = JSON.parse(infoRes);
|
if (groupName === null) {
|
||||||
let groupName = info.string2;
|
let infoRes = await fetchEncrypted("group/info", groupId);
|
||||||
|
if (infoRes && infoRes.startsWith("{")) {
|
||||||
|
groupName = JSON.parse(infoRes).string2;
|
||||||
|
if (!window.groupNameCache) window.groupNameCache = {};
|
||||||
|
window.groupNameCache[sanitizeId(groupId)] = { name: groupName, ts: Date.now() };
|
||||||
|
} else {
|
||||||
|
if (infoRes === "error:group.not.found") {
|
||||||
|
//grupa została usunięta - wyczyść nieaktualny wpis z listy
|
||||||
|
removeRoomFromCachedList(groupId, 'groups');
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
let safeGroupName = encodeURIComponent(groupName).replace(/'/g, "%27");
|
let safeGroupName = encodeURIComponent(groupName).replace(/'/g, "%27");
|
||||||
let pfp = await getGroupAvatarUrl(groupId, groupName);
|
let pfp = await getGroupAvatarUrl(groupId, groupName);
|
||||||
|
|
||||||
|
|
@ -2760,17 +2810,16 @@ async function renderGroups(res) {
|
||||||
</button>
|
</button>
|
||||||
`;
|
`;
|
||||||
count++;
|
count++;
|
||||||
} else if (infoRes === "error:group.not.found") {
|
|
||||||
//grupa została usunięta - wyczyść nieaktualny wpis z listy
|
|
||||||
removeRoomFromCachedList(groupId, 'groups');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (gen !== groupListRenderGen) return; //nowszy render przejął listę
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
container.classList.remove("empty");
|
container.classList.remove("empty");
|
||||||
updateVoicePresenceUI();
|
updateVoicePresenceUI();
|
||||||
} else {
|
} else {
|
||||||
|
if (gen !== groupListRenderGen) return;
|
||||||
container.innerHTML = `<p style="opacity: 0.5;"><blah>desc.no.groups</blah></p>`;
|
container.innerHTML = `<p style="opacity: 0.5;"><blah>desc.no.groups</blah></p>`;
|
||||||
container.classList.add("empty");
|
container.classList.add("empty");
|
||||||
let blahTags = container.getElementsByTagName("blah");
|
let blahTags = container.getElementsByTagName("blah");
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,9 @@ const VOICE_MAX_RECONNECTS = 2;
|
||||||
let peerLastSeen = {};
|
let peerLastSeen = {};
|
||||||
let deadPeers = new Set();
|
let deadPeers = new Set();
|
||||||
let lastVoiceServerPacketAt = 0;
|
let lastVoiceServerPacketAt = 0;
|
||||||
|
//Czy w bieżącym okresie socketu byli jacyś inni uczestnicy - baza dla
|
||||||
|
//resetu baseline'u ciszy w watchdogu (patrz komentarz przy użyciu)
|
||||||
|
let voiceHadRemotePeers = false;
|
||||||
let voicePingTimer = null;
|
let voicePingTimer = null;
|
||||||
let voiceRosterRetryTimer = null;
|
let voiceRosterRetryTimer = null;
|
||||||
let lastVoiceKeyRequestAt = 0;
|
let lastVoiceKeyRequestAt = 0;
|
||||||
|
|
@ -150,6 +153,10 @@ function cleanupPeerMedia(userId) {
|
||||||
delete remoteAudio[aid];
|
delete remoteAudio[aid];
|
||||||
delete audioDecryptChains[aid];
|
delete audioDecryptChains[aid];
|
||||||
}
|
}
|
||||||
|
if (remoteOpusDecoders[aid]) {
|
||||||
|
try { remoteOpusDecoders[aid].close(); } catch (e) {}
|
||||||
|
delete remoteOpusDecoders[aid];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for (const decoderId of Object.keys(remoteVideos)) {
|
for (const decoderId of Object.keys(remoteVideos)) {
|
||||||
if (decoderId.split('-')[0] === userId) {
|
if (decoderId.split('-')[0] === userId) {
|
||||||
|
|
@ -544,8 +551,10 @@ function setVoiceStatus(state) {
|
||||||
updateVoicePing();
|
updateVoicePing();
|
||||||
} else {
|
} else {
|
||||||
if (voiceTimerInterval) { clearInterval(voiceTimerInterval); voiceTimerInterval = null; }
|
if (voiceTimerInterval) { clearInterval(voiceTimerInterval); voiceTimerInterval = null; }
|
||||||
voiceConnectedAt = null;
|
//Anchor czasu NIE jest kasowany przy "connecting" - reconnect w trakcie
|
||||||
if (state === "disconnected") { voicePingMs = null; updateVoicePing(); }
|
//rozmowy nie może resetować licznika na 00:00; czyszczone tylko przy
|
||||||
|
//realnym rozłączeniu (disconnected z endVoiceSession)
|
||||||
|
if (state === "disconnected") { voiceConnectedAt = null; voicePingMs = null; updateVoicePing(); }
|
||||||
if (textEl) textEl.innerText = processBlah(state === "disconnected" ? "status.voice.disconnected" : "status.voice.connecting");
|
if (textEl) textEl.innerText = processBlah(state === "disconnected" ? "status.voice.disconnected" : "status.voice.connecting");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -605,7 +614,11 @@ setInterval(() => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Ghost peers: still on the room roster but totally silent (pings count as
|
// Ghost peers: still on the room roster but totally silent (pings count as
|
||||||
// life) - drop them locally until the server or the peer proves otherwise.
|
// life). Audyt S15: UKRYWANIE ich z UI tworzyło niewidzialnego
|
||||||
|
// podsłuchiwacza - voice-server dalej relayaudio do ich socketu, a u innych
|
||||||
|
// znikał z listy. Teraz peer zostaje WIDOCZNY (roster serwera jest źródłem
|
||||||
|
// prawdy); czyścimy jedynie jego kanały mediów - wrócą z pierwszym pakietem.
|
||||||
|
// deadPeers pełni tu rolę znacznika "media już wycięte", nie "ukryty".
|
||||||
let ghostRemoved = false;
|
let ghostRemoved = false;
|
||||||
for (const u of voiceActiveUsers) {
|
for (const u of voiceActiveUsers) {
|
||||||
if (u === String(window.id)) continue;
|
if (u === String(window.id)) continue;
|
||||||
|
|
@ -617,12 +630,18 @@ setInterval(() => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (ghostRemoved) {
|
if (ghostRemoved) {
|
||||||
voiceActiveUsers = voiceActiveUsers.filter(u => !deadPeers.has(u));
|
|
||||||
updateVoiceUIUsers();
|
updateVoiceUIUsers();
|
||||||
}
|
}
|
||||||
// Half-open socket watchdog + backgrounded-tab audio recovery.
|
// Half-open socket watchdog + backgrounded-tab audio recovery.
|
||||||
if (currentVoiceWs && currentVoiceWs.readyState === WebSocket.OPEN) {
|
if (currentVoiceWs && currentVoiceWs.readyState === WebSocket.OPEN) {
|
||||||
const others = voiceActiveUsers.filter(u => u !== String(window.id)).length;
|
const others = voiceActiveUsers.filter(u => u !== String(window.id)).length;
|
||||||
|
// Baseline ciszy odświeżany przy przejściu 0->N uczestników: solo w
|
||||||
|
// rozmowie NIE MA żadnego ruchu przychodzącego, więc po pojawieniu się
|
||||||
|
// nowej osoby lastVoiceServerPacketAt bywa nieświeży z okresu solo.
|
||||||
|
// Bez tego resetu join drugiej osoby natychmiast spełniał warunek
|
||||||
|
// ">10s bez inbound" i rozłączał zdrowy socket (fałszywy alarm).
|
||||||
|
if (others > 0 && !voiceHadRemotePeers) lastVoiceServerPacketAt = now;
|
||||||
|
voiceHadRemotePeers = others > 0;
|
||||||
// Alive peers ping every 2s regardless of mute, so total inbound
|
// Alive peers ping every 2s regardless of mute, so total inbound
|
||||||
// silence while others are present means the socket is dead.
|
// silence while others are present means the socket is dead.
|
||||||
if (others > 0 && now - lastVoiceServerPacketAt > VOICE_SERVER_TIMEOUT_MS) {
|
if (others > 0 && now - lastVoiceServerPacketAt > VOICE_SERVER_TIMEOUT_MS) {
|
||||||
|
|
@ -753,6 +772,96 @@ class TaskQueue {
|
||||||
|
|
||||||
let voiceJoinInFlight = false;
|
let voiceJoinInFlight = false;
|
||||||
|
|
||||||
|
// === S20: Opus (WebCodecs) dla mikrofonu z pełnym fallbackiem na legacy PCM ===
|
||||||
|
// UWAGA: tymczasowo WYŁĄCZONE (raport użytkownika: cisza w VC po wdrożeniu -
|
||||||
|
// wymaga testów na realnych urządzeniach przed ponownym włączeniem).
|
||||||
|
// Flagą włącza się całość; ścieżka odbiorcza typu 6 jest bezpieczna i zostaje.
|
||||||
|
const ENABLE_VOICE_OPUS = false;
|
||||||
|
// Typ ramki 6 = opus (typ 0 pozostaje PCM). Layout wewnętrzny identyczny jak
|
||||||
|
// typ 0: [0]=typ, [1]=keyframe, [2..14)=IV, [14..)=ciphertext - serwer i tak
|
||||||
|
// dokleja clientId przed relayem. Brak AudioEncoder/AudioDecoder lub jakikolwiek
|
||||||
|
// błąd = powrót na surowe PCM (mieszane wersje klientów się nie wysypują).
|
||||||
|
let voiceOpusEncoder = null;
|
||||||
|
let voiceOpusEncoderFailed = false;
|
||||||
|
let voiceOpusInputTimestampUs = 0;
|
||||||
|
const remoteOpusDecoders = {};
|
||||||
|
|
||||||
|
function getVoiceOpusEncoder() {
|
||||||
|
if (!ENABLE_VOICE_OPUS || voiceOpusEncoderFailed) return null;
|
||||||
|
if (typeof window.AudioEncoder !== 'function') { voiceOpusEncoderFailed = true; return null; }
|
||||||
|
if (voiceOpusEncoder) return voiceOpusEncoder;
|
||||||
|
try {
|
||||||
|
voiceOpusEncoder = new AudioEncoder({
|
||||||
|
output: (chunk) => {
|
||||||
|
try {
|
||||||
|
if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return;
|
||||||
|
const bytes = new Uint8Array(chunk.byteLength);
|
||||||
|
chunk.copyTo(bytes);
|
||||||
|
sendVoiceEncodedFrame(6, bytes);
|
||||||
|
} catch (e) {}
|
||||||
|
},
|
||||||
|
error: () => { voiceOpusEncoderFailed = true; }
|
||||||
|
});
|
||||||
|
voiceOpusEncoder.configure({
|
||||||
|
codec: 'opus',
|
||||||
|
sampleRate: 48000,
|
||||||
|
numberOfChannels: 1,
|
||||||
|
bitrate: 32000
|
||||||
|
});
|
||||||
|
return voiceOpusEncoder;
|
||||||
|
} catch (e) {
|
||||||
|
voiceOpusEncoderFailed = true;
|
||||||
|
voiceOpusEncoder = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendVoiceEncodedFrame(type, plainBytes) {
|
||||||
|
//licznik IV inkrementowany synchronicznie PRZED awaitem - niezmiennik
|
||||||
|
//unikalności IV bez zmian
|
||||||
|
const iv = nextVoiceIv();
|
||||||
|
const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, plainBytes);
|
||||||
|
if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN) return;
|
||||||
|
const payload = new Uint8Array(14 + cipher.byteLength);
|
||||||
|
payload[0] = type; payload[1] = 1; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14);
|
||||||
|
currentVoiceWs.send(payload.buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeVoiceOpus(audioId, targetNode, opusBuffer) {
|
||||||
|
try {
|
||||||
|
if (typeof window.AudioDecoder !== 'function') return;
|
||||||
|
let dec = remoteOpusDecoders[audioId];
|
||||||
|
if (!dec) {
|
||||||
|
dec = new AudioDecoder({
|
||||||
|
output: (audioData) => {
|
||||||
|
try {
|
||||||
|
const frames = audioData.numberOfFrames;
|
||||||
|
const f32 = new Float32Array(frames);
|
||||||
|
audioData.copyTo(f32, { planeIndex: 0, format: 'f32-planar' });
|
||||||
|
const pcm = new Int16Array(frames);
|
||||||
|
for (let i = 0; i < frames; i++) {
|
||||||
|
const s = Math.max(-1, Math.min(1, f32[i]));
|
||||||
|
pcm[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
|
||||||
|
}
|
||||||
|
audioData.close();
|
||||||
|
if (targetNode) targetNode.port.postMessage({ clientId: audioId, audioData: pcm.buffer }, [pcm.buffer]);
|
||||||
|
} catch (e) { try { audioData.close(); } catch (e2) {} }
|
||||||
|
},
|
||||||
|
error: () => { delete remoteOpusDecoders[audioId]; }
|
||||||
|
});
|
||||||
|
dec.configure({ codec: 'opus', sampleRate: 48000, numberOfChannels: 1 });
|
||||||
|
remoteOpusDecoders[audioId] = dec;
|
||||||
|
}
|
||||||
|
dec.decode(new EncodedAudioChunk({
|
||||||
|
type: 'key',
|
||||||
|
timestamp: Math.trunc(performance.now() * 1000),
|
||||||
|
data: opusBuffer
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
delete remoteOpusDecoders[audioId];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function initiateVoiceCall(roomId = null) {
|
async function initiateVoiceCall(roomId = null) {
|
||||||
if (currentVoiceWs || voiceJoinInFlight) {
|
if (currentVoiceWs || voiceJoinInFlight) {
|
||||||
showBlahNotification("error:already.in.call");
|
showBlahNotification("error:already.in.call");
|
||||||
|
|
@ -775,6 +884,12 @@ async function initiateVoiceCall(roomId = null) {
|
||||||
|
|
||||||
if (joined) {
|
if (joined) {
|
||||||
await startVoiceSession(joined.serverUrl, joined.ticket);
|
await startVoiceSession(joined.serverUrl, joined.ticket);
|
||||||
|
} else {
|
||||||
|
//S14: nieudany join nie może zostawiać ustawionego currentVoiceCallId/
|
||||||
|
//Type - uzbrojony gate sygnalizacji pozwalał obcemu na wstrzyknięcie
|
||||||
|
//"klucza rozmowy", a zatruty klucz przechodziłby do następnej rozmowy
|
||||||
|
currentVoiceCallId = null;
|
||||||
|
currentVoiceCallType = null;
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
voiceJoinInFlight = false;
|
voiceJoinInFlight = false;
|
||||||
|
|
@ -884,12 +999,12 @@ async function startVoiceSession(serverUrl, ticket) {
|
||||||
voicePingMs = Math.round(performance.now() - t0);
|
voicePingMs = Math.round(performance.now() - t0);
|
||||||
updateVoicePing();
|
updateVoicePing();
|
||||||
const rawUsers = usersRaw.map(u => String(u));
|
const rawUsers = usersRaw.map(u => String(u));
|
||||||
// Peers we locally declared dead stay hidden until either
|
//Serwer jest źródłem prawdy o składzie - nie filtrujemy po
|
||||||
// they send anything again or the server drops them.
|
//deadPeers (audyt S15: ukryty peer nadal dostaje audio)
|
||||||
for (const gone of [...deadPeers]) {
|
for (const gone of [...deadPeers]) {
|
||||||
if (!rawUsers.includes(gone)) deadPeers.delete(gone);
|
if (!rawUsers.includes(gone)) deadPeers.delete(gone);
|
||||||
}
|
}
|
||||||
const users = rawUsers.filter(u => !deadPeers.has(u));
|
const users = rawUsers;
|
||||||
let changed = (users.length !== voiceActiveUsers.length);
|
let changed = (users.length !== voiceActiveUsers.length);
|
||||||
for(let u of users) if(!voiceActiveUsers.includes(u)) changed = true;
|
for(let u of users) if(!voiceActiveUsers.includes(u)) changed = true;
|
||||||
if(changed) {
|
if(changed) {
|
||||||
|
|
@ -920,37 +1035,48 @@ async function startVoiceSession(serverUrl, ticket) {
|
||||||
if (voicePingTimer) clearInterval(voicePingTimer);
|
if (voicePingTimer) clearInterval(voicePingTimer);
|
||||||
voicePingTimer = setInterval(sendVoicePing, VOICE_PING_INTERVAL_MS);
|
voicePingTimer = setInterval(sendVoicePing, VOICE_PING_INTERVAL_MS);
|
||||||
|
|
||||||
// Reconnects reuse the live mic stream - only capture a new one
|
//S16: getUserMedia/worklet/rezump AudioContext potrafią rzucić
|
||||||
// when the previous is gone (full teardown or track ended).
|
//(brak uprawnień, zniknięte urządzenie) - bez obsługi zostawał
|
||||||
if (!micStream || micStream.getTracks().some(t => t.readyState !== 'live')) {
|
//"connected" zombie-call ze spalonym single-use ticketem i bez
|
||||||
micStream = await navigator.mediaDevices.getUserMedia(buildVoiceMicConstraints(voiceSettings.micId));
|
//mikrofonu, bez powiadomienia i bez recovery
|
||||||
if (micSourceNode) { try { micSourceNode.disconnect(); } catch (e) {} micSourceNode = null; }
|
try {
|
||||||
}
|
// Reconnects reuse the live mic stream - only capture a new one
|
||||||
|
// when the previous is gone (full teardown or track ended).
|
||||||
|
if (!micStream || micStream.getTracks().some(t => t.readyState !== 'live')) {
|
||||||
|
micStream = await navigator.mediaDevices.getUserMedia(buildVoiceMicConstraints(voiceSettings.micId));
|
||||||
|
if (micSourceNode) { try { micSourceNode.disconnect(); } catch (e) {} micSourceNode = null; }
|
||||||
|
}
|
||||||
|
|
||||||
const nativeRouting = getNativeVoiceRouting();
|
const nativeRouting = getNativeVoiceRouting();
|
||||||
if (nativeRouting && nativeRouting.startCall) {
|
if (nativeRouting && nativeRouting.startCall) {
|
||||||
try { await nativeRouting.startCall(); } catch (e) {}
|
try { await nativeRouting.startCall(); } catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!audioContext) audioContext = new (window.AudioContext || window.webkitAudioContext)({sampleRate: 48000});
|
if (!audioContext) audioContext = new (window.AudioContext || window.webkitAudioContext)({sampleRate: 48000});
|
||||||
if (!isAudioWorkletLoaded) {
|
if (!isAudioWorkletLoaded) {
|
||||||
await audioContext.audioWorklet.addModule(URL.createObjectURL(new Blob([voiceWorkletCode], {type: 'application/javascript'})));
|
await audioContext.audioWorklet.addModule(URL.createObjectURL(new Blob([voiceWorkletCode], {type: 'application/javascript'})));
|
||||||
senderNode = new AudioWorkletNode(audioContext, 'audio-sender');
|
senderNode = new AudioWorkletNode(audioContext, 'audio-sender');
|
||||||
const forwardPcm = !!(getNativeVoiceRouting() && isMobileVoiceDevice());
|
const forwardPcm = !!(getNativeVoiceRouting() && isMobileVoiceDevice());
|
||||||
receiverNode = new AudioWorkletNode(audioContext, 'audio-receiver', {processorOptions: {forward: forwardPcm}});
|
receiverNode = new AudioWorkletNode(audioContext, 'audio-receiver', {processorOptions: {forward: forwardPcm}});
|
||||||
screenReceiverNode = new AudioWorkletNode(audioContext, 'audio-receiver');
|
screenReceiverNode = new AudioWorkletNode(audioContext, 'audio-receiver');
|
||||||
isAudioWorkletLoaded = true;
|
isAudioWorkletLoaded = true;
|
||||||
}
|
}
|
||||||
if (audioContext.state === 'suspended') await audioContext.resume();
|
if (audioContext.state === 'suspended') await audioContext.resume();
|
||||||
applyVoiceSpeakerSink();
|
applyVoiceSpeakerSink();
|
||||||
setupVoiceOutput();
|
setupVoiceOutput();
|
||||||
|
|
||||||
if (micSourceNode) {
|
if (micSourceNode) {
|
||||||
try { micSourceNode.disconnect(); } catch (e) {}
|
try { micSourceNode.disconnect(); } catch (e) {}
|
||||||
micSourceNode = null;
|
micSourceNode = null;
|
||||||
|
}
|
||||||
|
micSourceNode = audioContext.createMediaStreamSource(micStream);
|
||||||
|
micSourceNode.connect(senderNode);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Voice: call setup failed", err);
|
||||||
|
showBlahNotification("error:call.connect.failed");
|
||||||
|
endVoiceSession();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
micSourceNode = audioContext.createMediaStreamSource(micStream);
|
|
||||||
micSourceNode.connect(senderNode);
|
|
||||||
|
|
||||||
let vadHangover = 0;
|
let vadHangover = 0;
|
||||||
senderNode.port.onmessage = async (e) => {
|
senderNode.port.onmessage = async (e) => {
|
||||||
|
|
@ -981,6 +1107,30 @@ async function startVoiceSession(serverUrl, ticket) {
|
||||||
if (vdsLevelFill) vdsLevelFill.style.backgroundColor = 'var(--big-green)';
|
if (vdsLevelFill) vdsLevelFill.style.backgroundColor = 'var(--big-green)';
|
||||||
|
|
||||||
if (!voiceMySenderCryptoKey) return;
|
if (!voiceMySenderCryptoKey) return;
|
||||||
|
|
||||||
|
//S20: Opus gdy WebCodecs dostępne (~30x mniej bajtów niż PCM);
|
||||||
|
//każda awaria = spójny fallback na legacy PCM poniżej
|
||||||
|
const opusEnc = getVoiceOpusEncoder();
|
||||||
|
if (opusEnc && opusEnc.state === 'configured') {
|
||||||
|
try {
|
||||||
|
const i16 = new Int16Array(audioData);
|
||||||
|
const f32 = new Float32Array(i16.length);
|
||||||
|
for (let i = 0; i < i16.length; i++) f32[i] = i16[i] / 32768;
|
||||||
|
const ad = new AudioData({
|
||||||
|
format: 'f32-planar',
|
||||||
|
sampleRate: 48000,
|
||||||
|
numberOfFrames: i16.length,
|
||||||
|
numberOfChannels: 1,
|
||||||
|
timestamp: voiceOpusInputTimestampUs,
|
||||||
|
data: f32
|
||||||
|
});
|
||||||
|
voiceOpusInputTimestampUs += Math.round(i16.length * 1e6 / 48000);
|
||||||
|
opusEnc.encode(ad);
|
||||||
|
ad.close();
|
||||||
|
return;
|
||||||
|
} catch (e) { /* fallback na PCM */ }
|
||||||
|
}
|
||||||
|
|
||||||
const iv = nextVoiceIv();
|
const iv = nextVoiceIv();
|
||||||
const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, audioData);
|
const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, audioData);
|
||||||
const payload = new Uint8Array(14 + cipher.byteLength);
|
const payload = new Uint8Array(14 + cipher.byteLength);
|
||||||
|
|
@ -1010,7 +1160,8 @@ async function startVoiceSession(serverUrl, ticket) {
|
||||||
const baseUrl = currentVoiceWs.url.split('/ws')[0];
|
const baseUrl = currentVoiceWs.url.split('/ws')[0];
|
||||||
const httpUrl = baseUrl.replace('ws:', 'http:').replace('wss:', 'https:');
|
const httpUrl = baseUrl.replace('ws:', 'http:').replace('wss:', 'https:');
|
||||||
fetch(`${httpUrl}/room/users?room=${currentVoiceCallId}`).then(res => res.json()).then(usersRaw => {
|
fetch(`${httpUrl}/room/users?room=${currentVoiceCallId}`).then(res => res.json()).then(usersRaw => {
|
||||||
voiceActiveUsers = usersRaw.map(u => String(u)).filter(u => !deadPeers.has(u));
|
//bez filtra deadPeers - serwer jest źródłem prawdy (audyt S15)
|
||||||
|
voiceActiveUsers = usersRaw.map(u => String(u));
|
||||||
updateVoiceUIUsers();
|
updateVoiceUIUsers();
|
||||||
}).catch(()=>{});
|
}).catch(()=>{});
|
||||||
}
|
}
|
||||||
|
|
@ -1024,8 +1175,9 @@ async function startVoiceSession(serverUrl, ticket) {
|
||||||
//podklucz nadawcy - deterministyczny z (root, clientId)
|
//podklucz nadawcy - deterministyczny z (root, clientId)
|
||||||
const senderKeyPromise = getVoicePeerSenderKey(clientId);
|
const senderKeyPromise = getVoicePeerSenderKey(clientId);
|
||||||
|
|
||||||
if (type === 0 || type === 4) {
|
if (type === 0 || type === 4 || type === 6) {
|
||||||
const isScreenAudio = (type === 4);
|
const isScreenAudio = (type === 4);
|
||||||
|
const isOpus = (type === 6);
|
||||||
const audioId = isScreenAudio ? `${clientId}_screen` : clientId;
|
const audioId = isScreenAudio ? `${clientId}_screen` : clientId;
|
||||||
if (!remoteAudio[audioId]) remoteAudio[audioId] = { lastUpdate: Date.now() };
|
if (!remoteAudio[audioId]) remoteAudio[audioId] = { lastUpdate: Date.now() };
|
||||||
remoteAudio[audioId].lastUpdate = Date.now();
|
remoteAudio[audioId].lastUpdate = Date.now();
|
||||||
|
|
@ -1038,7 +1190,12 @@ async function startVoiceSession(serverUrl, ticket) {
|
||||||
const senderKey = await senderKeyPromise;
|
const senderKey = await senderKeyPromise;
|
||||||
if (!senderKey) return;
|
if (!senderKey) return;
|
||||||
const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, senderKey, ciphertext);
|
const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, senderKey, ciphertext);
|
||||||
if(targetReceiver) targetReceiver.port.postMessage({ clientId: audioId, audioData: decryptedBuffer }, [decryptedBuffer]);
|
if (isOpus) {
|
||||||
|
//S20: opus -> Int16 PCM -> ten sam jitter-buffer workletu
|
||||||
|
decodeVoiceOpus(audioId, targetReceiver, decryptedBuffer);
|
||||||
|
} else {
|
||||||
|
if(targetReceiver) targetReceiver.port.postMessage({ clientId: audioId, audioData: decryptedBuffer }, [decryptedBuffer]);
|
||||||
|
}
|
||||||
|
|
||||||
setTileSpeaking(clientId, true, isScreenAudio ? `${clientId}-2` : null);
|
setTileSpeaking(clientId, true, isScreenAudio ? `${clientId}-2` : null);
|
||||||
} catch (err) {}
|
} catch (err) {}
|
||||||
|
|
@ -1210,10 +1367,11 @@ async function handleCallUpdate(data) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//Ignore updates for rooms we have nothing to do with - they would otherwise
|
//Roster rozmowy podmieniamy TYLKO dla pokoju, w którym faktycznie trwa
|
||||||
//overwrite the participant list of an ongoing call and wipe its tiles.
|
//nasza rozmowa (audyt S13): call_update otwartego czatu nie może wyzerować
|
||||||
if (roomId !== currentVoiceCallId && roomId !== window.currentDmId) return;
|
//kafelków ani rozdzielić kluczy trwającej rozmowy w innym pokoju
|
||||||
let state = incomingState.filter(u => !deadPeers.has(u));
|
if (roomId !== currentVoiceCallId) return;
|
||||||
|
let state = incomingState;
|
||||||
voiceActiveUsers = state;
|
voiceActiveUsers = state;
|
||||||
|
|
||||||
if (currentVoiceCallId === roomId) {
|
if (currentVoiceCallId === roomId) {
|
||||||
|
|
@ -2157,7 +2315,10 @@ function updateVoiceUIUsers() {
|
||||||
let avatarUrl = avatarRes ? avatarRes : "assets/default_avatar.png";
|
let avatarUrl = avatarRes ? avatarRes : "assets/default_avatar.png";
|
||||||
|
|
||||||
if (!avatarRes && window.getAvatarUrl) {
|
if (!avatarRes && window.getAvatarUrl) {
|
||||||
getAvatarUrl(u, authorName || "User").then(url => {
|
//Nie podawaj numerycznego ID jako "nazwy" - fallback SVG z cyframi
|
||||||
|
//(id) wygląda jak bug; gdy nie znamy nazwy, neutralny "User"
|
||||||
|
const nameForAvatar = (authorName && authorName !== u) ? authorName : "User";
|
||||||
|
getAvatarUrl(u, nameForAvatar).then(url => {
|
||||||
if(url) {
|
if(url) {
|
||||||
window.dmPfpCache[u] = url;
|
window.dmPfpCache[u] = url;
|
||||||
let el = document.getElementById(`voice-tile-${u}`);
|
let el = document.getElementById(`voice-tile-${u}`);
|
||||||
|
|
@ -2492,6 +2653,15 @@ function endVoiceSession() {
|
||||||
deadPeers.clear();
|
deadPeers.clear();
|
||||||
lastVoiceKeyRequestAt = 0;
|
lastVoiceKeyRequestAt = 0;
|
||||||
voiceActiveUsers = [];
|
voiceActiveUsers = [];
|
||||||
|
voiceHadRemotePeers = false;
|
||||||
|
|
||||||
|
//S20: teardown enkodera/dekoderów opus
|
||||||
|
if (voiceOpusEncoder) { try { voiceOpusEncoder.close(); } catch (e) {} voiceOpusEncoder = null; }
|
||||||
|
voiceOpusInputTimestampUs = 0;
|
||||||
|
for (const aid of Object.keys(remoteOpusDecoders)) {
|
||||||
|
try { remoteOpusDecoders[aid].close(); } catch (e) {}
|
||||||
|
delete remoteOpusDecoders[aid];
|
||||||
|
}
|
||||||
|
|
||||||
for (let id in audioDecoders) {
|
for (let id in audioDecoders) {
|
||||||
try { audioDecoders[id].close(); } catch(e){}
|
try { audioDecoders[id].close(); } catch(e){}
|
||||||
|
|
|
||||||
|
|
@ -1149,12 +1149,20 @@ function createAvatarSvg(name, size = 512) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getAvatarUrl(id, username) {
|
async function getAvatarUrl(id, username) {
|
||||||
|
//S22: helper robi fetch istnienia pliku - bez cache każde odświeżenie listy
|
||||||
|
//generowało 1-2 żądania NA wiersz (główny składnik N+1).
|
||||||
|
//Cache'owane TYLKO pozytywne potwierdzenia - nieudane lookupy (chwilowy
|
||||||
|
//network itp.) nie mogą przyklejać fallback-SVG na minutę
|
||||||
|
if (!window.pfpExistenceCache) window.pfpExistenceCache = {};
|
||||||
|
const cached = window.pfpExistenceCache[id];
|
||||||
|
if (cached && Date.now() - cached.ts < 60000) return cached.url;
|
||||||
try {
|
try {
|
||||||
let pfpUrl = `${url}/user/storage/public/getentry?id=${id}&e=larp.profile.pfp`;
|
let pfpUrl = `${url}/user/storage/public/getentry?id=${id}&e=larp.profile.pfp`;
|
||||||
if (id === window.id && window.profileUpdateTimestamp) pfpUrl += `&t=${window.profileUpdateTimestamp}`;
|
if (id === window.id && window.profileUpdateTimestamp) pfpUrl += `&t=${window.profileUpdateTimestamp}`;
|
||||||
if ((await fetchAsync(pfpUrl)) == "") {
|
if ((await fetchAsync(pfpUrl)) == "") {
|
||||||
throw Error();
|
throw Error();
|
||||||
}
|
}
|
||||||
|
window.pfpExistenceCache[id] = { url: pfpUrl, ts: Date.now() };
|
||||||
return pfpUrl;
|
return pfpUrl;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return createAvatarSvg(username);
|
return createAvatarSvg(username);
|
||||||
|
|
@ -1162,11 +1170,15 @@ async function getAvatarUrl(id, username) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getGroupAvatarUrl(groupId, groupName) {
|
async function getGroupAvatarUrl(groupId, groupName) {
|
||||||
|
if (!window.groupPfpExistenceCache) window.groupPfpExistenceCache = {};
|
||||||
|
const cached = window.groupPfpExistenceCache[groupId];
|
||||||
|
if (cached && Date.now() - cached.ts < 60000) return cached.url;
|
||||||
try {
|
try {
|
||||||
let pfpUrl = `${url}/group/storage/public/getentry?id=${groupId}&e=larp.profile.pfp`;
|
let pfpUrl = `${url}/group/storage/public/getentry?id=${groupId}&e=larp.profile.pfp`;
|
||||||
if ((await fetchAsync(pfpUrl)) == "") {
|
if ((await fetchAsync(pfpUrl)) == "") {
|
||||||
throw Error();
|
throw Error();
|
||||||
}
|
}
|
||||||
|
window.groupPfpExistenceCache[groupId] = { url: pfpUrl, ts: Date.now() };
|
||||||
return pfpUrl;
|
return pfpUrl;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return createAvatarSvg(groupName);
|
return createAvatarSvg(groupName);
|
||||||
|
|
@ -1557,7 +1569,7 @@ async function switchRoomsBar(title, content) {
|
||||||
|
|
||||||
await delay(200);
|
await delay(200);
|
||||||
|
|
||||||
roomsTopBarTransition.innerHTML = processBlah(title);
|
roomsTopBarTransition.innerHTML = escapeHtmlText(processBlah(title));
|
||||||
|
|
||||||
|
|
||||||
let parser = new DOMParser();
|
let parser = new DOMParser();
|
||||||
|
|
@ -1636,10 +1648,10 @@ async function switchRoomContent(title, content, showRoomBar, icon = "", skipMob
|
||||||
|
|
||||||
let clickableContent = showRoomBar ?
|
let clickableContent = showRoomBar ?
|
||||||
`<button id="room-topbar-clickable" class="inherit" onclick="mobileNavDetails();" style="cursor: pointer; display: flex; align-items: center; border-radius: 0.65rem; background: transparent; outline: none; height: 100%; text-align: left; min-width: 0; width: auto; max-width: 100%;">
|
`<button id="room-topbar-clickable" class="inherit" onclick="mobileNavDetails();" style="cursor: pointer; display: flex; align-items: center; border-radius: 0.65rem; background: transparent; outline: none; height: 100%; text-align: left; min-width: 0; width: auto; max-width: 100%;">
|
||||||
${icon ? icon + '<space></space>' : ''}<inherit style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; min-width: 0; width: auto;">${processBlah(title)}</inherit>
|
${icon ? icon + '<space></space>' : ''}<inherit style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; min-width: 0; width: auto;">${escapeHtmlText(processBlah(title))}</inherit>
|
||||||
</button>` :
|
</button>` :
|
||||||
`<inherit style="display: flex; align-items: center; height: 100%; min-width: 0; width: auto; max-width: 100%;">
|
`<inherit style="display: flex; align-items: center; height: 100%; min-width: 0; width: auto; max-width: 100%;">
|
||||||
${icon ? icon + '<space></space>' : ''}<inherit style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; min-width: 0; width: auto;">${processBlah(title)}</inherit>
|
${icon ? icon + '<space></space>' : ''}<inherit style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; min-width: 0; width: auto;">${escapeHtmlText(processBlah(title))}</inherit>
|
||||||
</inherit>`;
|
</inherit>`;
|
||||||
roomsTopBarTransition.innerHTML = `${backBtnHtml}${clickableContent}<div class="flex-spacer"></div>${pinCallBtns}`;
|
roomsTopBarTransition.innerHTML = `${backBtnHtml}${clickableContent}<div class="flex-spacer"></div>${pinCallBtns}`;
|
||||||
|
|
||||||
|
|
@ -1710,7 +1722,7 @@ async function switchDetailsContent(title, content, showBack = false, backAction
|
||||||
pinCallBtns = `${groupMenuBtnHtml}${pinCallBtns}`;
|
pinCallBtns = `${groupMenuBtnHtml}${pinCallBtns}`;
|
||||||
}
|
}
|
||||||
let backBtn = showBack ? `<button class="topbar-action-btn" onclick="${backAction}" style="margin-right: 0.5rem">${ICONS.arrowBack({ width: "1.5rem", height: "1.5rem" })}</button>` : "";
|
let backBtn = showBack ? `<button class="topbar-action-btn" onclick="${backAction}" style="margin-right: 0.5rem">${ICONS.arrowBack({ width: "1.5rem", height: "1.5rem" })}</button>` : "";
|
||||||
roomsTopBarTransition.innerHTML = `<inherit style="display: flex; align-items: center;">${backBtn}<inherit>${processBlah(title)}</inherit></inherit><div class="flex-spacer"></div>${pinCallBtns}`;
|
roomsTopBarTransition.innerHTML = `<inherit style="display: flex; align-items: center;">${backBtn}<inherit>${escapeHtmlText(processBlah(title))}</inherit></inherit><div class="flex-spacer"></div>${pinCallBtns}`;
|
||||||
|
|
||||||
let parser = new DOMParser();
|
let parser = new DOMParser();
|
||||||
let doc = parser.parseFromString(content, "text/html");
|
let doc = parser.parseFromString(content, "text/html");
|
||||||
|
|
@ -2033,7 +2045,9 @@ async function openProfile(accountId) {
|
||||||
});
|
});
|
||||||
let roleRes = await fetchEncrypted("room/role", payload);
|
let roleRes = await fetchEncrypted("room/role", payload);
|
||||||
if (roleRes && !roleRes.startsWith("error:")) {
|
if (roleRes && !roleRes.startsWith("error:")) {
|
||||||
let mappedRole = "role." + roleRes.replace(" ", "_");
|
//roleRes trafia do innerHTML - whitelist znaków zamiast surowej
|
||||||
|
//podstawienia (audyt W6: serwer-controlled string w szablonie)
|
||||||
|
let mappedRole = "role." + String(roleRes).replace(/[^A-Za-z0-9_.]/g, "_");
|
||||||
roleHtml = `<div class="profile-role" style="opacity:0.7; font-size:0.9rem; margin-top:0.3rem;"><blah>${mappedRole}</blah></div>`;
|
roleHtml = `<div class="profile-role" style="opacity:0.7; font-size:0.9rem; margin-top:0.3rem;"><blah>${mappedRole}</blah></div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2275,7 +2289,7 @@ async function renderInvites(res, type) {
|
||||||
}
|
}
|
||||||
|
|
||||||
let entry = invitesEntry
|
let entry = invitesEntry
|
||||||
.replaceAll("{username}", groupName)
|
.replaceAll("{username}", escapeHtmlText(groupName))
|
||||||
.replaceAll("{pfp}", pfp)
|
.replaceAll("{pfp}", pfp)
|
||||||
.replaceAll("{desc}", `<blah>${desc}</blah>`)
|
.replaceAll("{desc}", `<blah>${desc}</blah>`)
|
||||||
.replaceAll("{actions}", actions)
|
.replaceAll("{actions}", actions)
|
||||||
|
|
@ -2644,7 +2658,13 @@ function handleMobileSwipe() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//S22: generacje renderów - przy nakładających się refreshDms/refreshGroups
|
||||||
|
//(ws events, fallbacki) starszy wynik nie może nadpisać nowszego innerHTML-em
|
||||||
|
let dmListRenderGen = 0;
|
||||||
|
let groupListRenderGen = 0;
|
||||||
|
|
||||||
async function renderDms(res) {
|
async function renderDms(res) {
|
||||||
|
const gen = ++dmListRenderGen;
|
||||||
let container = document.getElementById("dms-list");
|
let container = document.getElementById("dms-list");
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
|
|
@ -2665,8 +2685,13 @@ async function renderDms(res) {
|
||||||
let myId = `${id};${host}`;
|
let myId = `${id};${host}`;
|
||||||
let targetId = sanitizeId(parts[0] === myId ? parts[1] : parts[0]);
|
let targetId = sanitizeId(parts[0] === myId ? parts[1] : parts[0]);
|
||||||
|
|
||||||
let targetUsername = await fetchAsync(`${url}/idtoname?id=${targetId}`);
|
//S22: cache nazw - idtoname było wołane od nowa przy każdym renderze
|
||||||
if (!targetUsername || targetUsername.trim() === "" || targetUsername.startsWith("error:")) continue;
|
let targetUsername = window.dmUsernameCache ? window.dmUsernameCache[targetId] : null;
|
||||||
|
if (!targetUsername) {
|
||||||
|
targetUsername = await fetchAsync(`${url}/idtoname?id=${targetId}`);
|
||||||
|
if (!targetUsername || targetUsername.trim() === "" || targetUsername.startsWith("error:")) continue;
|
||||||
|
if (window.dmUsernameCache) window.dmUsernameCache[targetId] = targetUsername;
|
||||||
|
}
|
||||||
|
|
||||||
let pfp = await getAvatarUrl(targetId, targetUsername);
|
let pfp = await getAvatarUrl(targetId, targetUsername);
|
||||||
let displayName = escapeHtml(targetUsername.split(':')[0]);
|
let displayName = escapeHtml(targetUsername.split(':')[0]);
|
||||||
|
|
@ -2694,11 +2719,13 @@ async function renderDms(res) {
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (gen !== dmListRenderGen) return; //nowszy render przejął listę
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
container.classList.remove("empty");
|
container.classList.remove("empty");
|
||||||
updateVoicePresenceUI();
|
updateVoicePresenceUI();
|
||||||
} else {
|
} else {
|
||||||
|
if (gen !== dmListRenderGen) return;
|
||||||
container.innerHTML = `<p style="opacity: 0.5;"><blah>desc.no.dms</blah></p>`;
|
container.innerHTML = `<p style="opacity: 0.5;"><blah>desc.no.dms</blah></p>`;
|
||||||
container.classList.add("empty");
|
container.classList.add("empty");
|
||||||
let blahTags = container.getElementsByTagName("blah");
|
let blahTags = container.getElementsByTagName("blah");
|
||||||
|
|
@ -2716,7 +2743,17 @@ async function renderDms(res) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//S22: TTL-cache nazw grup (60 s) - nazwy rzadko się zmieniają,
|
||||||
|
//a group/info było najdroższym elementem renderu listy grup
|
||||||
|
function getGroupNameFromCache(groupId) {
|
||||||
|
if (!window.groupNameCache) return null;
|
||||||
|
let entry = window.groupNameCache[sanitizeId(groupId)];
|
||||||
|
if (!entry || Date.now() - entry.ts > 60000) return null;
|
||||||
|
return entry.name;
|
||||||
|
}
|
||||||
|
|
||||||
async function renderGroups(res) {
|
async function renderGroups(res) {
|
||||||
|
const gen = ++groupListRenderGen;
|
||||||
let container = document.getElementById("groups-list");
|
let container = document.getElementById("groups-list");
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
|
|
@ -2737,11 +2774,24 @@ async function renderGroups(res) {
|
||||||
|
|
||||||
seedCallStateFromList(groupId, groupData.call === true);
|
seedCallStateFromList(groupId, groupData.call === true);
|
||||||
|
|
||||||
let infoRes = await fetchEncrypted("group/info", groupId);
|
//S22: TTL-cache nazw grup - group/info było wołane per grupa per render
|
||||||
if (infoRes && infoRes.startsWith("{")) {
|
let groupName = getGroupNameFromCache(groupId);
|
||||||
let info = JSON.parse(infoRes);
|
if (groupName === null) {
|
||||||
let groupName = info.string2;
|
let infoRes = await fetchEncrypted("group/info", groupId);
|
||||||
|
if (infoRes && infoRes.startsWith("{")) {
|
||||||
|
groupName = JSON.parse(infoRes).string2;
|
||||||
|
if (!window.groupNameCache) window.groupNameCache = {};
|
||||||
|
window.groupNameCache[sanitizeId(groupId)] = { name: groupName, ts: Date.now() };
|
||||||
|
} else {
|
||||||
|
if (infoRes === "error:group.not.found") {
|
||||||
|
//grupa została usunięta - wyczyść nieaktualny wpis z listy
|
||||||
|
removeRoomFromCachedList(groupId, 'groups');
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
let safeGroupName = encodeURIComponent(groupName).replace(/'/g, "%27");
|
let safeGroupName = encodeURIComponent(groupName).replace(/'/g, "%27");
|
||||||
let pfp = await getGroupAvatarUrl(groupId, groupName);
|
let pfp = await getGroupAvatarUrl(groupId, groupName);
|
||||||
|
|
||||||
|
|
@ -2760,17 +2810,16 @@ async function renderGroups(res) {
|
||||||
</button>
|
</button>
|
||||||
`;
|
`;
|
||||||
count++;
|
count++;
|
||||||
} else if (infoRes === "error:group.not.found") {
|
|
||||||
//grupa została usunięta - wyczyść nieaktualny wpis z listy
|
|
||||||
removeRoomFromCachedList(groupId, 'groups');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (gen !== groupListRenderGen) return; //nowszy render przejął listę
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
container.classList.remove("empty");
|
container.classList.remove("empty");
|
||||||
updateVoicePresenceUI();
|
updateVoicePresenceUI();
|
||||||
} else {
|
} else {
|
||||||
|
if (gen !== groupListRenderGen) return;
|
||||||
container.innerHTML = `<p style="opacity: 0.5;"><blah>desc.no.groups</blah></p>`;
|
container.innerHTML = `<p style="opacity: 0.5;"><blah>desc.no.groups</blah></p>`;
|
||||||
container.classList.add("empty");
|
container.classList.add("empty");
|
||||||
let blahTags = container.getElementsByTagName("blah");
|
let blahTags = container.getElementsByTagName("blah");
|
||||||
|
|
|
||||||
254
webroot/voice.js
254
webroot/voice.js
|
|
@ -137,6 +137,9 @@ const VOICE_MAX_RECONNECTS = 2;
|
||||||
let peerLastSeen = {};
|
let peerLastSeen = {};
|
||||||
let deadPeers = new Set();
|
let deadPeers = new Set();
|
||||||
let lastVoiceServerPacketAt = 0;
|
let lastVoiceServerPacketAt = 0;
|
||||||
|
//Czy w bieżącym okresie socketu byli jacyś inni uczestnicy - baza dla
|
||||||
|
//resetu baseline'u ciszy w watchdogu (patrz komentarz przy użyciu)
|
||||||
|
let voiceHadRemotePeers = false;
|
||||||
let voicePingTimer = null;
|
let voicePingTimer = null;
|
||||||
let voiceRosterRetryTimer = null;
|
let voiceRosterRetryTimer = null;
|
||||||
let lastVoiceKeyRequestAt = 0;
|
let lastVoiceKeyRequestAt = 0;
|
||||||
|
|
@ -150,6 +153,10 @@ function cleanupPeerMedia(userId) {
|
||||||
delete remoteAudio[aid];
|
delete remoteAudio[aid];
|
||||||
delete audioDecryptChains[aid];
|
delete audioDecryptChains[aid];
|
||||||
}
|
}
|
||||||
|
if (remoteOpusDecoders[aid]) {
|
||||||
|
try { remoteOpusDecoders[aid].close(); } catch (e) {}
|
||||||
|
delete remoteOpusDecoders[aid];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for (const decoderId of Object.keys(remoteVideos)) {
|
for (const decoderId of Object.keys(remoteVideos)) {
|
||||||
if (decoderId.split('-')[0] === userId) {
|
if (decoderId.split('-')[0] === userId) {
|
||||||
|
|
@ -544,8 +551,10 @@ function setVoiceStatus(state) {
|
||||||
updateVoicePing();
|
updateVoicePing();
|
||||||
} else {
|
} else {
|
||||||
if (voiceTimerInterval) { clearInterval(voiceTimerInterval); voiceTimerInterval = null; }
|
if (voiceTimerInterval) { clearInterval(voiceTimerInterval); voiceTimerInterval = null; }
|
||||||
voiceConnectedAt = null;
|
//Anchor czasu NIE jest kasowany przy "connecting" - reconnect w trakcie
|
||||||
if (state === "disconnected") { voicePingMs = null; updateVoicePing(); }
|
//rozmowy nie może resetować licznika na 00:00; czyszczone tylko przy
|
||||||
|
//realnym rozłączeniu (disconnected z endVoiceSession)
|
||||||
|
if (state === "disconnected") { voiceConnectedAt = null; voicePingMs = null; updateVoicePing(); }
|
||||||
if (textEl) textEl.innerText = processBlah(state === "disconnected" ? "status.voice.disconnected" : "status.voice.connecting");
|
if (textEl) textEl.innerText = processBlah(state === "disconnected" ? "status.voice.disconnected" : "status.voice.connecting");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -605,7 +614,11 @@ setInterval(() => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Ghost peers: still on the room roster but totally silent (pings count as
|
// Ghost peers: still on the room roster but totally silent (pings count as
|
||||||
// life) - drop them locally until the server or the peer proves otherwise.
|
// life). Audyt S15: UKRYWANIE ich z UI tworzyło niewidzialnego
|
||||||
|
// podsłuchiwacza - voice-server dalej relayaudio do ich socketu, a u innych
|
||||||
|
// znikał z listy. Teraz peer zostaje WIDOCZNY (roster serwera jest źródłem
|
||||||
|
// prawdy); czyścimy jedynie jego kanały mediów - wrócą z pierwszym pakietem.
|
||||||
|
// deadPeers pełni tu rolę znacznika "media już wycięte", nie "ukryty".
|
||||||
let ghostRemoved = false;
|
let ghostRemoved = false;
|
||||||
for (const u of voiceActiveUsers) {
|
for (const u of voiceActiveUsers) {
|
||||||
if (u === String(window.id)) continue;
|
if (u === String(window.id)) continue;
|
||||||
|
|
@ -617,12 +630,18 @@ setInterval(() => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (ghostRemoved) {
|
if (ghostRemoved) {
|
||||||
voiceActiveUsers = voiceActiveUsers.filter(u => !deadPeers.has(u));
|
|
||||||
updateVoiceUIUsers();
|
updateVoiceUIUsers();
|
||||||
}
|
}
|
||||||
// Half-open socket watchdog + backgrounded-tab audio recovery.
|
// Half-open socket watchdog + backgrounded-tab audio recovery.
|
||||||
if (currentVoiceWs && currentVoiceWs.readyState === WebSocket.OPEN) {
|
if (currentVoiceWs && currentVoiceWs.readyState === WebSocket.OPEN) {
|
||||||
const others = voiceActiveUsers.filter(u => u !== String(window.id)).length;
|
const others = voiceActiveUsers.filter(u => u !== String(window.id)).length;
|
||||||
|
// Baseline ciszy odświeżany przy przejściu 0->N uczestników: solo w
|
||||||
|
// rozmowie NIE MA żadnego ruchu przychodzącego, więc po pojawieniu się
|
||||||
|
// nowej osoby lastVoiceServerPacketAt bywa nieświeży z okresu solo.
|
||||||
|
// Bez tego resetu join drugiej osoby natychmiast spełniał warunek
|
||||||
|
// ">10s bez inbound" i rozłączał zdrowy socket (fałszywy alarm).
|
||||||
|
if (others > 0 && !voiceHadRemotePeers) lastVoiceServerPacketAt = now;
|
||||||
|
voiceHadRemotePeers = others > 0;
|
||||||
// Alive peers ping every 2s regardless of mute, so total inbound
|
// Alive peers ping every 2s regardless of mute, so total inbound
|
||||||
// silence while others are present means the socket is dead.
|
// silence while others are present means the socket is dead.
|
||||||
if (others > 0 && now - lastVoiceServerPacketAt > VOICE_SERVER_TIMEOUT_MS) {
|
if (others > 0 && now - lastVoiceServerPacketAt > VOICE_SERVER_TIMEOUT_MS) {
|
||||||
|
|
@ -753,6 +772,96 @@ class TaskQueue {
|
||||||
|
|
||||||
let voiceJoinInFlight = false;
|
let voiceJoinInFlight = false;
|
||||||
|
|
||||||
|
// === S20: Opus (WebCodecs) dla mikrofonu z pełnym fallbackiem na legacy PCM ===
|
||||||
|
// UWAGA: tymczasowo WYŁĄCZONE (raport użytkownika: cisza w VC po wdrożeniu -
|
||||||
|
// wymaga testów na realnych urządzeniach przed ponownym włączeniem).
|
||||||
|
// Flagą włącza się całość; ścieżka odbiorcza typu 6 jest bezpieczna i zostaje.
|
||||||
|
const ENABLE_VOICE_OPUS = false;
|
||||||
|
// Typ ramki 6 = opus (typ 0 pozostaje PCM). Layout wewnętrzny identyczny jak
|
||||||
|
// typ 0: [0]=typ, [1]=keyframe, [2..14)=IV, [14..)=ciphertext - serwer i tak
|
||||||
|
// dokleja clientId przed relayem. Brak AudioEncoder/AudioDecoder lub jakikolwiek
|
||||||
|
// błąd = powrót na surowe PCM (mieszane wersje klientów się nie wysypują).
|
||||||
|
let voiceOpusEncoder = null;
|
||||||
|
let voiceOpusEncoderFailed = false;
|
||||||
|
let voiceOpusInputTimestampUs = 0;
|
||||||
|
const remoteOpusDecoders = {};
|
||||||
|
|
||||||
|
function getVoiceOpusEncoder() {
|
||||||
|
if (!ENABLE_VOICE_OPUS || voiceOpusEncoderFailed) return null;
|
||||||
|
if (typeof window.AudioEncoder !== 'function') { voiceOpusEncoderFailed = true; return null; }
|
||||||
|
if (voiceOpusEncoder) return voiceOpusEncoder;
|
||||||
|
try {
|
||||||
|
voiceOpusEncoder = new AudioEncoder({
|
||||||
|
output: (chunk) => {
|
||||||
|
try {
|
||||||
|
if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return;
|
||||||
|
const bytes = new Uint8Array(chunk.byteLength);
|
||||||
|
chunk.copyTo(bytes);
|
||||||
|
sendVoiceEncodedFrame(6, bytes);
|
||||||
|
} catch (e) {}
|
||||||
|
},
|
||||||
|
error: () => { voiceOpusEncoderFailed = true; }
|
||||||
|
});
|
||||||
|
voiceOpusEncoder.configure({
|
||||||
|
codec: 'opus',
|
||||||
|
sampleRate: 48000,
|
||||||
|
numberOfChannels: 1,
|
||||||
|
bitrate: 32000
|
||||||
|
});
|
||||||
|
return voiceOpusEncoder;
|
||||||
|
} catch (e) {
|
||||||
|
voiceOpusEncoderFailed = true;
|
||||||
|
voiceOpusEncoder = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendVoiceEncodedFrame(type, plainBytes) {
|
||||||
|
//licznik IV inkrementowany synchronicznie PRZED awaitem - niezmiennik
|
||||||
|
//unikalności IV bez zmian
|
||||||
|
const iv = nextVoiceIv();
|
||||||
|
const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, plainBytes);
|
||||||
|
if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN) return;
|
||||||
|
const payload = new Uint8Array(14 + cipher.byteLength);
|
||||||
|
payload[0] = type; payload[1] = 1; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14);
|
||||||
|
currentVoiceWs.send(payload.buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeVoiceOpus(audioId, targetNode, opusBuffer) {
|
||||||
|
try {
|
||||||
|
if (typeof window.AudioDecoder !== 'function') return;
|
||||||
|
let dec = remoteOpusDecoders[audioId];
|
||||||
|
if (!dec) {
|
||||||
|
dec = new AudioDecoder({
|
||||||
|
output: (audioData) => {
|
||||||
|
try {
|
||||||
|
const frames = audioData.numberOfFrames;
|
||||||
|
const f32 = new Float32Array(frames);
|
||||||
|
audioData.copyTo(f32, { planeIndex: 0, format: 'f32-planar' });
|
||||||
|
const pcm = new Int16Array(frames);
|
||||||
|
for (let i = 0; i < frames; i++) {
|
||||||
|
const s = Math.max(-1, Math.min(1, f32[i]));
|
||||||
|
pcm[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
|
||||||
|
}
|
||||||
|
audioData.close();
|
||||||
|
if (targetNode) targetNode.port.postMessage({ clientId: audioId, audioData: pcm.buffer }, [pcm.buffer]);
|
||||||
|
} catch (e) { try { audioData.close(); } catch (e2) {} }
|
||||||
|
},
|
||||||
|
error: () => { delete remoteOpusDecoders[audioId]; }
|
||||||
|
});
|
||||||
|
dec.configure({ codec: 'opus', sampleRate: 48000, numberOfChannels: 1 });
|
||||||
|
remoteOpusDecoders[audioId] = dec;
|
||||||
|
}
|
||||||
|
dec.decode(new EncodedAudioChunk({
|
||||||
|
type: 'key',
|
||||||
|
timestamp: Math.trunc(performance.now() * 1000),
|
||||||
|
data: opusBuffer
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
delete remoteOpusDecoders[audioId];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function initiateVoiceCall(roomId = null) {
|
async function initiateVoiceCall(roomId = null) {
|
||||||
if (currentVoiceWs || voiceJoinInFlight) {
|
if (currentVoiceWs || voiceJoinInFlight) {
|
||||||
showBlahNotification("error:already.in.call");
|
showBlahNotification("error:already.in.call");
|
||||||
|
|
@ -775,6 +884,12 @@ async function initiateVoiceCall(roomId = null) {
|
||||||
|
|
||||||
if (joined) {
|
if (joined) {
|
||||||
await startVoiceSession(joined.serverUrl, joined.ticket);
|
await startVoiceSession(joined.serverUrl, joined.ticket);
|
||||||
|
} else {
|
||||||
|
//S14: nieudany join nie może zostawiać ustawionego currentVoiceCallId/
|
||||||
|
//Type - uzbrojony gate sygnalizacji pozwalał obcemu na wstrzyknięcie
|
||||||
|
//"klucza rozmowy", a zatruty klucz przechodziłby do następnej rozmowy
|
||||||
|
currentVoiceCallId = null;
|
||||||
|
currentVoiceCallType = null;
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
voiceJoinInFlight = false;
|
voiceJoinInFlight = false;
|
||||||
|
|
@ -884,12 +999,12 @@ async function startVoiceSession(serverUrl, ticket) {
|
||||||
voicePingMs = Math.round(performance.now() - t0);
|
voicePingMs = Math.round(performance.now() - t0);
|
||||||
updateVoicePing();
|
updateVoicePing();
|
||||||
const rawUsers = usersRaw.map(u => String(u));
|
const rawUsers = usersRaw.map(u => String(u));
|
||||||
// Peers we locally declared dead stay hidden until either
|
//Serwer jest źródłem prawdy o składzie - nie filtrujemy po
|
||||||
// they send anything again or the server drops them.
|
//deadPeers (audyt S15: ukryty peer nadal dostaje audio)
|
||||||
for (const gone of [...deadPeers]) {
|
for (const gone of [...deadPeers]) {
|
||||||
if (!rawUsers.includes(gone)) deadPeers.delete(gone);
|
if (!rawUsers.includes(gone)) deadPeers.delete(gone);
|
||||||
}
|
}
|
||||||
const users = rawUsers.filter(u => !deadPeers.has(u));
|
const users = rawUsers;
|
||||||
let changed = (users.length !== voiceActiveUsers.length);
|
let changed = (users.length !== voiceActiveUsers.length);
|
||||||
for(let u of users) if(!voiceActiveUsers.includes(u)) changed = true;
|
for(let u of users) if(!voiceActiveUsers.includes(u)) changed = true;
|
||||||
if(changed) {
|
if(changed) {
|
||||||
|
|
@ -920,37 +1035,48 @@ async function startVoiceSession(serverUrl, ticket) {
|
||||||
if (voicePingTimer) clearInterval(voicePingTimer);
|
if (voicePingTimer) clearInterval(voicePingTimer);
|
||||||
voicePingTimer = setInterval(sendVoicePing, VOICE_PING_INTERVAL_MS);
|
voicePingTimer = setInterval(sendVoicePing, VOICE_PING_INTERVAL_MS);
|
||||||
|
|
||||||
// Reconnects reuse the live mic stream - only capture a new one
|
//S16: getUserMedia/worklet/rezump AudioContext potrafią rzucić
|
||||||
// when the previous is gone (full teardown or track ended).
|
//(brak uprawnień, zniknięte urządzenie) - bez obsługi zostawał
|
||||||
if (!micStream || micStream.getTracks().some(t => t.readyState !== 'live')) {
|
//"connected" zombie-call ze spalonym single-use ticketem i bez
|
||||||
micStream = await navigator.mediaDevices.getUserMedia(buildVoiceMicConstraints(voiceSettings.micId));
|
//mikrofonu, bez powiadomienia i bez recovery
|
||||||
if (micSourceNode) { try { micSourceNode.disconnect(); } catch (e) {} micSourceNode = null; }
|
try {
|
||||||
}
|
// Reconnects reuse the live mic stream - only capture a new one
|
||||||
|
// when the previous is gone (full teardown or track ended).
|
||||||
|
if (!micStream || micStream.getTracks().some(t => t.readyState !== 'live')) {
|
||||||
|
micStream = await navigator.mediaDevices.getUserMedia(buildVoiceMicConstraints(voiceSettings.micId));
|
||||||
|
if (micSourceNode) { try { micSourceNode.disconnect(); } catch (e) {} micSourceNode = null; }
|
||||||
|
}
|
||||||
|
|
||||||
const nativeRouting = getNativeVoiceRouting();
|
const nativeRouting = getNativeVoiceRouting();
|
||||||
if (nativeRouting && nativeRouting.startCall) {
|
if (nativeRouting && nativeRouting.startCall) {
|
||||||
try { await nativeRouting.startCall(); } catch (e) {}
|
try { await nativeRouting.startCall(); } catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!audioContext) audioContext = new (window.AudioContext || window.webkitAudioContext)({sampleRate: 48000});
|
if (!audioContext) audioContext = new (window.AudioContext || window.webkitAudioContext)({sampleRate: 48000});
|
||||||
if (!isAudioWorkletLoaded) {
|
if (!isAudioWorkletLoaded) {
|
||||||
await audioContext.audioWorklet.addModule(URL.createObjectURL(new Blob([voiceWorkletCode], {type: 'application/javascript'})));
|
await audioContext.audioWorklet.addModule(URL.createObjectURL(new Blob([voiceWorkletCode], {type: 'application/javascript'})));
|
||||||
senderNode = new AudioWorkletNode(audioContext, 'audio-sender');
|
senderNode = new AudioWorkletNode(audioContext, 'audio-sender');
|
||||||
const forwardPcm = !!(getNativeVoiceRouting() && isMobileVoiceDevice());
|
const forwardPcm = !!(getNativeVoiceRouting() && isMobileVoiceDevice());
|
||||||
receiverNode = new AudioWorkletNode(audioContext, 'audio-receiver', {processorOptions: {forward: forwardPcm}});
|
receiverNode = new AudioWorkletNode(audioContext, 'audio-receiver', {processorOptions: {forward: forwardPcm}});
|
||||||
screenReceiverNode = new AudioWorkletNode(audioContext, 'audio-receiver');
|
screenReceiverNode = new AudioWorkletNode(audioContext, 'audio-receiver');
|
||||||
isAudioWorkletLoaded = true;
|
isAudioWorkletLoaded = true;
|
||||||
}
|
}
|
||||||
if (audioContext.state === 'suspended') await audioContext.resume();
|
if (audioContext.state === 'suspended') await audioContext.resume();
|
||||||
applyVoiceSpeakerSink();
|
applyVoiceSpeakerSink();
|
||||||
setupVoiceOutput();
|
setupVoiceOutput();
|
||||||
|
|
||||||
if (micSourceNode) {
|
if (micSourceNode) {
|
||||||
try { micSourceNode.disconnect(); } catch (e) {}
|
try { micSourceNode.disconnect(); } catch (e) {}
|
||||||
micSourceNode = null;
|
micSourceNode = null;
|
||||||
|
}
|
||||||
|
micSourceNode = audioContext.createMediaStreamSource(micStream);
|
||||||
|
micSourceNode.connect(senderNode);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Voice: call setup failed", err);
|
||||||
|
showBlahNotification("error:call.connect.failed");
|
||||||
|
endVoiceSession();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
micSourceNode = audioContext.createMediaStreamSource(micStream);
|
|
||||||
micSourceNode.connect(senderNode);
|
|
||||||
|
|
||||||
let vadHangover = 0;
|
let vadHangover = 0;
|
||||||
senderNode.port.onmessage = async (e) => {
|
senderNode.port.onmessage = async (e) => {
|
||||||
|
|
@ -981,6 +1107,30 @@ async function startVoiceSession(serverUrl, ticket) {
|
||||||
if (vdsLevelFill) vdsLevelFill.style.backgroundColor = 'var(--big-green)';
|
if (vdsLevelFill) vdsLevelFill.style.backgroundColor = 'var(--big-green)';
|
||||||
|
|
||||||
if (!voiceMySenderCryptoKey) return;
|
if (!voiceMySenderCryptoKey) return;
|
||||||
|
|
||||||
|
//S20: Opus gdy WebCodecs dostępne (~30x mniej bajtów niż PCM);
|
||||||
|
//każda awaria = spójny fallback na legacy PCM poniżej
|
||||||
|
const opusEnc = getVoiceOpusEncoder();
|
||||||
|
if (opusEnc && opusEnc.state === 'configured') {
|
||||||
|
try {
|
||||||
|
const i16 = new Int16Array(audioData);
|
||||||
|
const f32 = new Float32Array(i16.length);
|
||||||
|
for (let i = 0; i < i16.length; i++) f32[i] = i16[i] / 32768;
|
||||||
|
const ad = new AudioData({
|
||||||
|
format: 'f32-planar',
|
||||||
|
sampleRate: 48000,
|
||||||
|
numberOfFrames: i16.length,
|
||||||
|
numberOfChannels: 1,
|
||||||
|
timestamp: voiceOpusInputTimestampUs,
|
||||||
|
data: f32
|
||||||
|
});
|
||||||
|
voiceOpusInputTimestampUs += Math.round(i16.length * 1e6 / 48000);
|
||||||
|
opusEnc.encode(ad);
|
||||||
|
ad.close();
|
||||||
|
return;
|
||||||
|
} catch (e) { /* fallback na PCM */ }
|
||||||
|
}
|
||||||
|
|
||||||
const iv = nextVoiceIv();
|
const iv = nextVoiceIv();
|
||||||
const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, audioData);
|
const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, audioData);
|
||||||
const payload = new Uint8Array(14 + cipher.byteLength);
|
const payload = new Uint8Array(14 + cipher.byteLength);
|
||||||
|
|
@ -1010,7 +1160,8 @@ async function startVoiceSession(serverUrl, ticket) {
|
||||||
const baseUrl = currentVoiceWs.url.split('/ws')[0];
|
const baseUrl = currentVoiceWs.url.split('/ws')[0];
|
||||||
const httpUrl = baseUrl.replace('ws:', 'http:').replace('wss:', 'https:');
|
const httpUrl = baseUrl.replace('ws:', 'http:').replace('wss:', 'https:');
|
||||||
fetch(`${httpUrl}/room/users?room=${currentVoiceCallId}`).then(res => res.json()).then(usersRaw => {
|
fetch(`${httpUrl}/room/users?room=${currentVoiceCallId}`).then(res => res.json()).then(usersRaw => {
|
||||||
voiceActiveUsers = usersRaw.map(u => String(u)).filter(u => !deadPeers.has(u));
|
//bez filtra deadPeers - serwer jest źródłem prawdy (audyt S15)
|
||||||
|
voiceActiveUsers = usersRaw.map(u => String(u));
|
||||||
updateVoiceUIUsers();
|
updateVoiceUIUsers();
|
||||||
}).catch(()=>{});
|
}).catch(()=>{});
|
||||||
}
|
}
|
||||||
|
|
@ -1024,8 +1175,9 @@ async function startVoiceSession(serverUrl, ticket) {
|
||||||
//podklucz nadawcy - deterministyczny z (root, clientId)
|
//podklucz nadawcy - deterministyczny z (root, clientId)
|
||||||
const senderKeyPromise = getVoicePeerSenderKey(clientId);
|
const senderKeyPromise = getVoicePeerSenderKey(clientId);
|
||||||
|
|
||||||
if (type === 0 || type === 4) {
|
if (type === 0 || type === 4 || type === 6) {
|
||||||
const isScreenAudio = (type === 4);
|
const isScreenAudio = (type === 4);
|
||||||
|
const isOpus = (type === 6);
|
||||||
const audioId = isScreenAudio ? `${clientId}_screen` : clientId;
|
const audioId = isScreenAudio ? `${clientId}_screen` : clientId;
|
||||||
if (!remoteAudio[audioId]) remoteAudio[audioId] = { lastUpdate: Date.now() };
|
if (!remoteAudio[audioId]) remoteAudio[audioId] = { lastUpdate: Date.now() };
|
||||||
remoteAudio[audioId].lastUpdate = Date.now();
|
remoteAudio[audioId].lastUpdate = Date.now();
|
||||||
|
|
@ -1038,7 +1190,12 @@ async function startVoiceSession(serverUrl, ticket) {
|
||||||
const senderKey = await senderKeyPromise;
|
const senderKey = await senderKeyPromise;
|
||||||
if (!senderKey) return;
|
if (!senderKey) return;
|
||||||
const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, senderKey, ciphertext);
|
const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, senderKey, ciphertext);
|
||||||
if(targetReceiver) targetReceiver.port.postMessage({ clientId: audioId, audioData: decryptedBuffer }, [decryptedBuffer]);
|
if (isOpus) {
|
||||||
|
//S20: opus -> Int16 PCM -> ten sam jitter-buffer workletu
|
||||||
|
decodeVoiceOpus(audioId, targetReceiver, decryptedBuffer);
|
||||||
|
} else {
|
||||||
|
if(targetReceiver) targetReceiver.port.postMessage({ clientId: audioId, audioData: decryptedBuffer }, [decryptedBuffer]);
|
||||||
|
}
|
||||||
|
|
||||||
setTileSpeaking(clientId, true, isScreenAudio ? `${clientId}-2` : null);
|
setTileSpeaking(clientId, true, isScreenAudio ? `${clientId}-2` : null);
|
||||||
} catch (err) {}
|
} catch (err) {}
|
||||||
|
|
@ -1210,10 +1367,11 @@ async function handleCallUpdate(data) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//Ignore updates for rooms we have nothing to do with - they would otherwise
|
//Roster rozmowy podmieniamy TYLKO dla pokoju, w którym faktycznie trwa
|
||||||
//overwrite the participant list of an ongoing call and wipe its tiles.
|
//nasza rozmowa (audyt S13): call_update otwartego czatu nie może wyzerować
|
||||||
if (roomId !== currentVoiceCallId && roomId !== window.currentDmId) return;
|
//kafelków ani rozdzielić kluczy trwającej rozmowy w innym pokoju
|
||||||
let state = incomingState.filter(u => !deadPeers.has(u));
|
if (roomId !== currentVoiceCallId) return;
|
||||||
|
let state = incomingState;
|
||||||
voiceActiveUsers = state;
|
voiceActiveUsers = state;
|
||||||
|
|
||||||
if (currentVoiceCallId === roomId) {
|
if (currentVoiceCallId === roomId) {
|
||||||
|
|
@ -2157,7 +2315,10 @@ function updateVoiceUIUsers() {
|
||||||
let avatarUrl = avatarRes ? avatarRes : "assets/default_avatar.png";
|
let avatarUrl = avatarRes ? avatarRes : "assets/default_avatar.png";
|
||||||
|
|
||||||
if (!avatarRes && window.getAvatarUrl) {
|
if (!avatarRes && window.getAvatarUrl) {
|
||||||
getAvatarUrl(u, authorName || "User").then(url => {
|
//Nie podawaj numerycznego ID jako "nazwy" - fallback SVG z cyframi
|
||||||
|
//(id) wygląda jak bug; gdy nie znamy nazwy, neutralny "User"
|
||||||
|
const nameForAvatar = (authorName && authorName !== u) ? authorName : "User";
|
||||||
|
getAvatarUrl(u, nameForAvatar).then(url => {
|
||||||
if(url) {
|
if(url) {
|
||||||
window.dmPfpCache[u] = url;
|
window.dmPfpCache[u] = url;
|
||||||
let el = document.getElementById(`voice-tile-${u}`);
|
let el = document.getElementById(`voice-tile-${u}`);
|
||||||
|
|
@ -2492,6 +2653,15 @@ function endVoiceSession() {
|
||||||
deadPeers.clear();
|
deadPeers.clear();
|
||||||
lastVoiceKeyRequestAt = 0;
|
lastVoiceKeyRequestAt = 0;
|
||||||
voiceActiveUsers = [];
|
voiceActiveUsers = [];
|
||||||
|
voiceHadRemotePeers = false;
|
||||||
|
|
||||||
|
//S20: teardown enkodera/dekoderów opus
|
||||||
|
if (voiceOpusEncoder) { try { voiceOpusEncoder.close(); } catch (e) {} voiceOpusEncoder = null; }
|
||||||
|
voiceOpusInputTimestampUs = 0;
|
||||||
|
for (const aid of Object.keys(remoteOpusDecoders)) {
|
||||||
|
try { remoteOpusDecoders[aid].close(); } catch (e) {}
|
||||||
|
delete remoteOpusDecoders[aid];
|
||||||
|
}
|
||||||
|
|
||||||
for (let id in audioDecoders) {
|
for (let id in audioDecoders) {
|
||||||
try { audioDecoders[id].close(); } catch(e){}
|
try { audioDecoders[id].close(); } catch(e){}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue