From 9a3bc9e48877ff9581b145bcdc4756107493ac0645c91b13607fef3f1b9e5dfe Mon Sep 17 00:00:00 2001 From: AidarKC Date: Wed, 22 Apr 2026 19:25:52 +0300 Subject: [PATCH] =?UTF-8?q?fix(call):=20=D0=BA=D0=BE=D1=80=D1=80=D0=B5?= =?UTF-8?q?=D0=BA=D1=82=D0=BD=D1=8B=D0=B9=20mute,=20=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D1=82=D1=83=D1=81=20=D0=BF=D1=80=D1=8F=D0=BC=D0=BE=D0=B5/TURN?= =?UTF-8?q?=20=D0=B8=20=D1=80=D0=B5=D0=B0=D0=B2=D1=82=D0=BE=D1=80=D0=B8?= =?UTF-8?q?=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20WS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- doc/mvp-web-push-notes/README.md | 18 --- shine-UI/js/app.js | 46 +++++++- shine-UI/js/services/call-service.js | 148 ++++++++++++++++++++++-- shine-UI/js/services/call-ui-service.js | 2 +- 4 files changed, 186 insertions(+), 28 deletions(-) delete mode 100644 doc/mvp-web-push-notes/README.md diff --git a/doc/mvp-web-push-notes/README.md b/doc/mvp-web-push-notes/README.md deleted file mode 100644 index d6c1680..0000000 --- a/doc/mvp-web-push-notes/README.md +++ /dev/null @@ -1,18 +0,0 @@ -# MVP notes: Web Push - -## Временное поведение (сделано для тестового стенда) - -- Клиент отправляет push-подписку на сервер при каждом запуске после авторизации, даже если подписка не изменилась. -- Причина: на тестовом сервере/после переустановки БД запись о токене может пропасть, а клиент этого не узнает. - -## Что доработать для production - -- Вернуть режим "отправлять только при изменении подписки" как основной. -- Добавить безопасный механизм ресинхронизации: -- Вариант 1: периодическая принудительная отправка (например, 1 раз в N дней). -- Вариант 2: endpoint на сервере "есть ли подписка", и отправка только при отсутствии/рассинхроне. -- В логах разделить обычную отправку и принудительную, чтобы видеть лишний трафик. -- Добавить e2e-тесты сценариев: -- Переустановка сервера (потеря токена в БД). -- Смена браузерной подписки. -- Повторный запуск клиента без изменений. diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index 80d6f01..bd84128 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -119,6 +119,7 @@ let connectionRetryBannerEl = null; let connectionStatusCountdownId = null; let connectionNextRetryAtMs = 0; let connectionCheckInFlight = false; +let wsSessionRestoreInFlight = null; setClientErrorTransport((payload) => authService.reportClientError(payload)); initPwaInstallPromptHandling(); @@ -286,8 +287,15 @@ async function checkConnectionHealth() { setConnectionStatus('connecting'); } try { - if (!wsIsOpen()) { + const wasOpen = wsIsOpen(); + if (!wasOpen) { await authService.ws.open(); + const restored = await ensureSessionAfterWsReconnect(); + if (!restored) { + connectionStatusText = ''; + setConnectionStatus('disconnected'); + return; + } } await authService.ws.request('Ping', { ts: Date.now() }, 7000); setConnectionStatus('connected'); @@ -327,6 +335,40 @@ function wsIsOpen() { 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 = {}) { const lines = [title]; if (details.message) lines.push(`Сообщение: ${details.message}`); @@ -533,6 +575,8 @@ async function ensureSessionRuntimeStarted() { try { await authService.ws.open(); + const restored = await ensureSessionAfterWsReconnect(); + if (!restored) return; addAppLogEntry({ level: 'info', source: 'ws-reconnect', diff --git a/shine-UI/js/services/call-service.js b/shine-UI/js/services/call-service.js index 3e2b9e4..6691517 100644 --- a/shine-UI/js/services/call-service.js +++ b/shine-UI/js/services/call-service.js @@ -227,10 +227,21 @@ function setStatus(call, statusText, phase = '') { notifyCallState(); } +function buildActiveStatusText(call) { + const route = String(call?.connectionRouteLabel || '').trim(); + return route ? `Разговор идёт (${route})` : 'Разговор идёт'; +} + +function setActiveStatus(call) { + setStatus(call, buildActiveStatusText(call), 'active'); +} + function cleanupTimers(call) { if (call.timers?.ack10s) clearTimeout(call.timers.ack10s); if (call.timers?.total35s) clearTimeout(call.timers.total35s); if (call.timers?.incoming20s) clearTimeout(call.timers.incoming20s); + if (call.timers?.transportProbe) clearInterval(call.timers.transportProbe); + call.timers.transportProbe = null; } async function closeMedia(call) { @@ -238,6 +249,8 @@ async function closeMedia(call) { try { call.localStream?.getTracks()?.forEach((track) => track.stop()); } catch {} call.pc = null; call.localStream = null; + call.audioSenders = []; + call.connectionRouteLabel = ''; } function stopReconnectFlow(call) { @@ -254,6 +267,85 @@ function stopReconnectFlow(call) { 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') { if (!call || call.phase === 'ended') return; if (!call.connectedAtMs) return; @@ -470,7 +562,8 @@ async function ensurePeerConnection(call) { if (!call.connectedAtMs) { call.connectedAtMs = nowMs(); } - setStatus(call, 'Разговор идёт', 'active'); + setActiveStatus(call); + startTransportProbe(call); void emitDebug(call, 'info', 'peer_connection_connected', `callId=${call.callId}`); return; } @@ -504,9 +597,14 @@ async function ensurePeerConnection(call) { try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }); call.localStream = stream; + call.audioSenders = []; + call.connectionRouteLabel = ''; stream.getTracks().forEach((track) => { - track.enabled = !call.muted; - pc.addTrack(track, stream); + track.enabled = track.kind === 'audio' ? !call.muted : true; + const sender = pc.addTrack(track, stream); + if (track.kind === 'audio') { + call.audioSenders.push(sender); + } }); } catch (e) { setStatus(call, `Нет доступа к микрофону: ${e?.message || 'unknown'}`, 'failed'); @@ -571,15 +669,41 @@ export function getActiveCallState() { 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) { const call = getActiveCall(); if (!call) return; call.muted = Boolean(muted); - try { - call.localStream?.getAudioTracks()?.forEach((track) => { - track.enabled = !call.muted; - }); - } catch {} + await applyMicState(call); notifyCallState(); } @@ -609,7 +733,9 @@ export async function startDebugConnectionAsResponder({ runId, callId, peerLogin connectedAtMs: 0, pc: null, localStream: null, + audioSenders: [], muted: false, + connectionRouteLabel: '', debugMode: true, debugRunId: String(runId || '').trim(), debugRole: 'responder', @@ -640,7 +766,9 @@ export async function startDebugConnectionAsInitiator({ runId, callId, peerLogin connectedAtMs: 0, pc: null, localStream: null, + audioSenders: [], muted: false, + connectionRouteLabel: '', debugMode: true, debugRunId: String(runId || '').trim(), debugRole: 'initiator', @@ -680,7 +808,9 @@ export async function startOutgoingCall(peerLogin) { connectedAtMs: 0, pc: null, localStream: null, + audioSenders: [], muted: false, + connectionRouteLabel: '', reconnectInProgress: false, reconnectAttempts: 0, debugMode: false, @@ -747,7 +877,9 @@ export async function handleIncomingCallInvite(evt) { connectedAtMs: 0, pc: null, localStream: null, + audioSenders: [], muted: false, + connectionRouteLabel: '', reconnectInProgress: false, reconnectAttempts: 0, debugMode: false, diff --git a/shine-UI/js/services/call-ui-service.js b/shine-UI/js/services/call-ui-service.js index e4e0fd6..12dde59 100644 --- a/shine-UI/js/services/call-ui-service.js +++ b/shine-UI/js/services/call-ui-service.js @@ -88,7 +88,7 @@ function applyCallState(snapshot) { const muted = Boolean(snapshot.muted); muteBtn.dataset.muted = muted ? '1' : '0'; - muteBtn.textContent = muted ? 'Микрофон выкл' : 'Микрофон вкл'; + muteBtn.textContent = muted ? 'Включить микрофон' : 'Выключить микрофон'; muteBtn.hidden = !snapshot.canMute; muteBtn.disabled = !snapshot.canMute;