fix(call): корректный mute, статус прямое/TURN и реавторизация WS
This commit is contained in:
parent
a905822515
commit
9a3bc9e488
@ -1,18 +0,0 @@
|
|||||||
# MVP notes: Web Push
|
|
||||||
|
|
||||||
## Временное поведение (сделано для тестового стенда)
|
|
||||||
|
|
||||||
- Клиент отправляет push-подписку на сервер при каждом запуске после авторизации, даже если подписка не изменилась.
|
|
||||||
- Причина: на тестовом сервере/после переустановки БД запись о токене может пропасть, а клиент этого не узнает.
|
|
||||||
|
|
||||||
## Что доработать для production
|
|
||||||
|
|
||||||
- Вернуть режим "отправлять только при изменении подписки" как основной.
|
|
||||||
- Добавить безопасный механизм ресинхронизации:
|
|
||||||
- Вариант 1: периодическая принудительная отправка (например, 1 раз в N дней).
|
|
||||||
- Вариант 2: endpoint на сервере "есть ли подписка", и отправка только при отсутствии/рассинхроне.
|
|
||||||
- В логах разделить обычную отправку и принудительную, чтобы видеть лишний трафик.
|
|
||||||
- Добавить e2e-тесты сценариев:
|
|
||||||
- Переустановка сервера (потеря токена в БД).
|
|
||||||
- Смена браузерной подписки.
|
|
||||||
- Повторный запуск клиента без изменений.
|
|
||||||
@ -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',
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user