fix(call): корректный mute, статус прямое/TURN и реавторизация WS

This commit is contained in:
AidarKC 2026-04-22 19:25:52 +03:00
parent a905822515
commit 9a3bc9e488
4 changed files with 186 additions and 28 deletions

View File

@ -1,18 +0,0 @@
# MVP notes: Web Push
## Временное поведение (сделано для тестового стенда)
- Клиент отправляет push-подписку на сервер при каждом запуске после авторизации, даже если подписка не изменилась.
- Причина: на тестовом сервере/после переустановки БД запись о токене может пропасть, а клиент этого не узнает.
## Что доработать для production
- Вернуть режим "отправлять только при изменении подписки" как основной.
- Добавить безопасный механизм ресинхронизации:
- Вариант 1: периодическая принудительная отправка (например, 1 раз в N дней).
- Вариант 2: endpoint на сервере "есть ли подписка", и отправка только при отсутствии/рассинхроне.
- В логах разделить обычную отправку и принудительную, чтобы видеть лишний трафик.
- Добавить e2e-тесты сценариев:
- Переустановка сервера (потеря токена в БД).
- Смена браузерной подписки.
- Повторный запуск клиента без изменений.

View File

@ -119,6 +119,7 @@ let connectionRetryBannerEl = null;
let connectionStatusCountdownId = null; let connectionStatusCountdownId = null;
let connectionNextRetryAtMs = 0; let connectionNextRetryAtMs = 0;
let connectionCheckInFlight = false; let connectionCheckInFlight = false;
let wsSessionRestoreInFlight = null;
setClientErrorTransport((payload) => authService.reportClientError(payload)); setClientErrorTransport((payload) => authService.reportClientError(payload));
initPwaInstallPromptHandling(); initPwaInstallPromptHandling();
@ -286,8 +287,15 @@ async function checkConnectionHealth() {
setConnectionStatus('connecting'); setConnectionStatus('connecting');
} }
try { try {
if (!wsIsOpen()) { const wasOpen = wsIsOpen();
if (!wasOpen) {
await authService.ws.open(); await authService.ws.open();
const restored = await ensureSessionAfterWsReconnect();
if (!restored) {
connectionStatusText = '';
setConnectionStatus('disconnected');
return;
}
} }
await authService.ws.request('Ping', { ts: Date.now() }, 7000); await authService.ws.request('Ping', { ts: Date.now() }, 7000);
setConnectionStatus('connected'); setConnectionStatus('connected');
@ -327,6 +335,40 @@ function wsIsOpen() {
return !!(ws && ws.readyState === WebSocket.OPEN); return !!(ws && ws.readyState === WebSocket.OPEN);
} }
async function ensureSessionAfterWsReconnect() {
if (!state.session.isAuthorized) return true;
if (wsSessionRestoreInFlight) return wsSessionRestoreInFlight;
wsSessionRestoreInFlight = (async () => {
try {
const resumed = await authService.resumeSession(state.session.login, state.session.sessionId);
authorizeSession({
login: resumed.login || state.session.login,
sessionId: resumed.sessionId || state.session.sessionId,
storagePwd: resumed.storagePwd || state.session.storagePwdInMemory,
});
addAppLogEntry({
level: 'info',
source: 'ws-reconnect',
message: 'Сессия восстановлена после переподключения WS',
});
return true;
} catch (error) {
if (isSessionInvalidError(error)) {
await terminateCurrentSession({
infoMessage: 'Ваша сессия устарела. Авторизируйтесь заново.',
});
return false;
}
throw error;
}
})().finally(() => {
wsSessionRestoreInFlight = null;
});
return wsSessionRestoreInFlight;
}
function showGlobalErrorAlert(title, details = {}) { function showGlobalErrorAlert(title, details = {}) {
const lines = [title]; const lines = [title];
if (details.message) lines.push(`Сообщение: ${details.message}`); if (details.message) lines.push(`Сообщение: ${details.message}`);
@ -533,6 +575,8 @@ async function ensureSessionRuntimeStarted() {
try { try {
await authService.ws.open(); await authService.ws.open();
const restored = await ensureSessionAfterWsReconnect();
if (!restored) return;
addAppLogEntry({ addAppLogEntry({
level: 'info', level: 'info',
source: 'ws-reconnect', source: 'ws-reconnect',

View File

@ -227,10 +227,21 @@ function setStatus(call, statusText, phase = '') {
notifyCallState(); notifyCallState();
} }
function buildActiveStatusText(call) {
const route = String(call?.connectionRouteLabel || '').trim();
return route ? `Разговор идёт (${route})` : 'Разговор идёт';
}
function setActiveStatus(call) {
setStatus(call, buildActiveStatusText(call), 'active');
}
function cleanupTimers(call) { function cleanupTimers(call) {
if (call.timers?.ack10s) clearTimeout(call.timers.ack10s); if (call.timers?.ack10s) clearTimeout(call.timers.ack10s);
if (call.timers?.total35s) clearTimeout(call.timers.total35s); if (call.timers?.total35s) clearTimeout(call.timers.total35s);
if (call.timers?.incoming20s) clearTimeout(call.timers.incoming20s); if (call.timers?.incoming20s) clearTimeout(call.timers.incoming20s);
if (call.timers?.transportProbe) clearInterval(call.timers.transportProbe);
call.timers.transportProbe = null;
} }
async function closeMedia(call) { async function closeMedia(call) {
@ -238,6 +249,8 @@ async function closeMedia(call) {
try { call.localStream?.getTracks()?.forEach((track) => track.stop()); } catch {} try { call.localStream?.getTracks()?.forEach((track) => track.stop()); } catch {}
call.pc = null; call.pc = null;
call.localStream = null; call.localStream = null;
call.audioSenders = [];
call.connectionRouteLabel = '';
} }
function stopReconnectFlow(call) { function stopReconnectFlow(call) {
@ -254,6 +267,85 @@ function stopReconnectFlow(call) {
call.reconnectAttempts = 0; call.reconnectAttempts = 0;
} }
async function detectConnectionRoute(call) {
const pc = call?.pc;
if (!pc || typeof pc.getStats !== 'function') return { label: '', details: '' };
try {
const stats = await pc.getStats();
let selectedPair = null;
stats.forEach((report) => {
if (selectedPair) return;
if (report.type !== 'transport') return;
if (!report.selectedCandidatePairId) return;
const pair = typeof stats.get === 'function' ? stats.get(report.selectedCandidatePairId) : null;
if (pair) selectedPair = pair;
});
if (!selectedPair) {
stats.forEach((report) => {
if (selectedPair) return;
if (report.type === 'candidate-pair' && report.selected) selectedPair = report;
});
}
if (!selectedPair) {
stats.forEach((report) => {
if (selectedPair) return;
if (report.type === 'candidate-pair' && report.nominated && report.state === 'succeeded') {
selectedPair = report;
}
});
}
if (!selectedPair) return { label: '', details: '' };
const local = selectedPair.localCandidateId && typeof stats.get === 'function'
? stats.get(selectedPair.localCandidateId)
: null;
const remote = selectedPair.remoteCandidateId && typeof stats.get === 'function'
? stats.get(selectedPair.remoteCandidateId)
: null;
const localType = String(local?.candidateType || '').trim().toLowerCase();
const remoteType = String(remote?.candidateType || '').trim().toLowerCase();
const details = `local=${localType || '-'}; remote=${remoteType || '-'}`;
if (localType === 'relay' || remoteType === 'relay') {
return { label: 'через TURN', details };
}
if (localType || remoteType) {
return { label: 'прямое', details };
}
return { label: '', details };
} catch {
return { label: '', details: '' };
}
}
function startTransportProbe(call) {
if (!call?.timers || !call.pc) return;
if (call.timers.transportProbe) {
clearInterval(call.timers.transportProbe);
call.timers.transportProbe = null;
}
const refresh = async () => {
if (!calls.has(call.callId) || call.phase === 'ended') return;
if (!call.pc || call.pc.connectionState !== 'connected') return;
const route = await detectConnectionRoute(call);
if (!route.label || route.label === call.connectionRouteLabel) return;
call.connectionRouteLabel = route.label;
setActiveStatus(call);
await emitDebug(call, 'info', 'peer_connection_route', route.details || route.label);
};
void refresh();
call.timers.transportProbe = window.setInterval(() => {
void refresh();
}, 4000);
}
function startReconnectFlow(call, reason = 'disconnected') { function startReconnectFlow(call, reason = 'disconnected') {
if (!call || call.phase === 'ended') return; if (!call || call.phase === 'ended') return;
if (!call.connectedAtMs) return; if (!call.connectedAtMs) return;
@ -470,7 +562,8 @@ async function ensurePeerConnection(call) {
if (!call.connectedAtMs) { if (!call.connectedAtMs) {
call.connectedAtMs = nowMs(); call.connectedAtMs = nowMs();
} }
setStatus(call, 'Разговор идёт', 'active'); setActiveStatus(call);
startTransportProbe(call);
void emitDebug(call, 'info', 'peer_connection_connected', `callId=${call.callId}`); void emitDebug(call, 'info', 'peer_connection_connected', `callId=${call.callId}`);
return; return;
} }
@ -504,9 +597,14 @@ async function ensurePeerConnection(call) {
try { try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }); const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
call.localStream = stream; call.localStream = stream;
call.audioSenders = [];
call.connectionRouteLabel = '';
stream.getTracks().forEach((track) => { stream.getTracks().forEach((track) => {
track.enabled = !call.muted; track.enabled = track.kind === 'audio' ? !call.muted : true;
pc.addTrack(track, stream); const sender = pc.addTrack(track, stream);
if (track.kind === 'audio') {
call.audioSenders.push(sender);
}
}); });
} catch (e) { } catch (e) {
setStatus(call, `Нет доступа к микрофону: ${e?.message || 'unknown'}`, 'failed'); setStatus(call, `Нет доступа к микрофону: ${e?.message || 'unknown'}`, 'failed');
@ -571,15 +669,41 @@ export function getActiveCallState() {
return getCallStateSnapshot(); return getCallStateSnapshot();
} }
async function applyMicState(call) {
if (!call) return;
const muted = Boolean(call.muted);
const audioTracks = call.localStream?.getAudioTracks?.() || [];
audioTracks.forEach((track) => {
track.enabled = !muted;
});
const senders = Array.isArray(call.audioSenders) && call.audioSenders.length > 0
? call.audioSenders
: (call.pc?.getSenders?.() || []);
const sourceTrack = audioTracks[0] || null;
for (const sender of senders) {
if (!sender || typeof sender.replaceTrack !== 'function') continue;
try {
if (muted) {
if (sender.track) {
await sender.replaceTrack(null);
}
} else if (sourceTrack) {
if (sender.track !== sourceTrack) {
await sender.replaceTrack(sourceTrack);
}
sourceTrack.enabled = true;
}
} catch {}
}
}
export async function setMicMuted(muted) { export async function setMicMuted(muted) {
const call = getActiveCall(); const call = getActiveCall();
if (!call) return; if (!call) return;
call.muted = Boolean(muted); call.muted = Boolean(muted);
try { await applyMicState(call);
call.localStream?.getAudioTracks()?.forEach((track) => {
track.enabled = !call.muted;
});
} catch {}
notifyCallState(); notifyCallState();
} }
@ -609,7 +733,9 @@ export async function startDebugConnectionAsResponder({ runId, callId, peerLogin
connectedAtMs: 0, connectedAtMs: 0,
pc: null, pc: null,
localStream: null, localStream: null,
audioSenders: [],
muted: false, muted: false,
connectionRouteLabel: '',
debugMode: true, debugMode: true,
debugRunId: String(runId || '').trim(), debugRunId: String(runId || '').trim(),
debugRole: 'responder', debugRole: 'responder',
@ -640,7 +766,9 @@ export async function startDebugConnectionAsInitiator({ runId, callId, peerLogin
connectedAtMs: 0, connectedAtMs: 0,
pc: null, pc: null,
localStream: null, localStream: null,
audioSenders: [],
muted: false, muted: false,
connectionRouteLabel: '',
debugMode: true, debugMode: true,
debugRunId: String(runId || '').trim(), debugRunId: String(runId || '').trim(),
debugRole: 'initiator', debugRole: 'initiator',
@ -680,7 +808,9 @@ export async function startOutgoingCall(peerLogin) {
connectedAtMs: 0, connectedAtMs: 0,
pc: null, pc: null,
localStream: null, localStream: null,
audioSenders: [],
muted: false, muted: false,
connectionRouteLabel: '',
reconnectInProgress: false, reconnectInProgress: false,
reconnectAttempts: 0, reconnectAttempts: 0,
debugMode: false, debugMode: false,
@ -747,7 +877,9 @@ export async function handleIncomingCallInvite(evt) {
connectedAtMs: 0, connectedAtMs: 0,
pc: null, pc: null,
localStream: null, localStream: null,
audioSenders: [],
muted: false, muted: false,
connectionRouteLabel: '',
reconnectInProgress: false, reconnectInProgress: false,
reconnectAttempts: 0, reconnectAttempts: 0,
debugMode: false, debugMode: false,

View File

@ -88,7 +88,7 @@ function applyCallState(snapshot) {
const muted = Boolean(snapshot.muted); const muted = Boolean(snapshot.muted);
muteBtn.dataset.muted = muted ? '1' : '0'; muteBtn.dataset.muted = muted ? '1' : '0';
muteBtn.textContent = muted ? 'Микрофон выкл' : 'Микрофон вкл'; muteBtn.textContent = muted ? 'Включить микрофон' : 'Выключить микрофон';
muteBtn.hidden = !snapshot.canMute; muteBtn.hidden = !snapshot.canMute;
muteBtn.disabled = !snapshot.canMute; muteBtn.disabled = !snapshot.canMute;