diff --git a/VERSION.properties b/VERSION.properties index 46cc002..04a29d4 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.25 -server.version=1.2.25 +client.version=1.2.28 +server.version=1.2.26 diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index e6ed53a..c61958b 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -108,6 +108,7 @@ const toolbarEl = document.getElementById('toolbar-slot'); const appShellEl = document.querySelector('.app-shell'); const CONNECTION_CHECK_INTERVAL_MS = 20 * 1000; +const UI_VERSION_PERIODIC_CHECK_MS = 5 * 60 * 1000; const CURRENT_BUILD_HASH = String(window.__SHINE_BUILD_HASH__ || '').trim(); const UI_BUILD_HASH_PATTERN = /window\.__SHINE_BUILD_HASH__\s*=\s*'([^']+)'/; @@ -125,6 +126,7 @@ let wsSessionRestoreInFlight = null; let uiUpdateReloadScheduled = false; let pwaUpdateCheckAttempted = false; let uiVersionCheckInFlight = false; +let uiVersionPeriodicIntervalId = null; setClientErrorTransport((payload) => authService.reportClientError(payload)); initPwaInstallPromptHandling(); @@ -335,6 +337,32 @@ async function tryUpdatePwaOnFirstConnectedPing() { await refreshServiceWorkers({ activateWaitingWorker: false }); } +async function runPeriodicUiVersionCheck() { + if (uiUpdateReloadScheduled) return; + if (uiVersionCheckInFlight) return; + try { + const latestHostHash = await fetchCurrentHostUiBuildHash(); + if (!latestHostHash || !CURRENT_BUILD_HASH || latestHostHash === CURRENT_BUILD_HASH) return; + scheduleUiReload({ + source: 'ui-periodic-version-check', + message: `Найдена новая версия UI: ${CURRENT_BUILD_HASH} -> ${latestHostHash}`, + delayMs: 600, + activateWaitingWorker: true, + }); + } catch { + // ignore periodic check errors + } +} + +function startPeriodicUiVersionCheck() { + if (uiVersionPeriodicIntervalId) return; + // ВРЕМЕННО: частая проверка обновления UI (каждые 5 минут) для диагностики проблем с обновлением клиента. + // Позже интервал нужно увеличить или вернуть проверку только по ручному действию. + uiVersionPeriodicIntervalId = window.setInterval(() => { + void runPeriodicUiVersionCheck(); + }, UI_VERSION_PERIODIC_CHECK_MS); +} + async function checkConnectionHealth() { if (connectionCheckInFlight) return; connectionCheckInFlight = true; @@ -887,6 +915,7 @@ async function init() { await tryAutoLogin(); await hydrateMessagesFromStore(); startConnectionMonitor(); + startPeriodicUiVersionCheck(); await ensureSessionRuntimeStarted(); if (!window.location.hash) { diff --git a/shine-UI/js/pages/settings-view.js b/shine-UI/js/pages/settings-view.js index 8a50d52..6e90c06 100644 --- a/shine-UI/js/pages/settings-view.js +++ b/shine-UI/js/pages/settings-view.js @@ -43,14 +43,12 @@ export function render({ navigate }) { - `; card.querySelector('#settings-device').addEventListener('click', () => navigate('device-view')); card.querySelector('#settings-servers').addEventListener('click', () => navigate('server-settings-view')); card.querySelector('#settings-language').addEventListener('click', () => navigate('language-view')); - card.querySelector('#settings-developer').addEventListener('click', () => navigate('developer-settings-view')); const signOutBtn = card.querySelector('#settings-signout'); signOutBtn.addEventListener('click', async () => { @@ -95,6 +93,13 @@ export function render({ navigate }) { versionCard.append(title, clientVersion, uiBuild, serverVersion); + const developerCard = document.createElement('div'); + developerCard.className = 'card stack'; + developerCard.innerHTML = ` + + `; + developerCard.querySelector('#settings-developer').addEventListener('click', () => navigate('developer-settings-view')); + void (async () => { try { let value = ''; @@ -122,5 +127,6 @@ export function render({ navigate }) { }; screen.append(card); screen.append(versionCard); + screen.append(developerCard); return screen; } diff --git a/shine-UI/js/services/call-service.js b/shine-UI/js/services/call-service.js index 767cbc6..46dcf54 100644 --- a/shine-UI/js/services/call-service.js +++ b/shine-UI/js/services/call-service.js @@ -86,6 +86,23 @@ function uniqueUrls(urls = []) { return out; } +function parseTurnHostFromUrl(rawUrl) { + const value = String(rawUrl || '').trim(); + if (!value) return ''; + const noProto = value.replace(/^turns?:/i, ''); + const noQuery = noProto.split('?')[0]; + const noPath = noQuery.split('/')[0]; + const hostPort = noPath.replace(/^\/\//, '').trim(); + if (!hostPort) return ''; + if (hostPort.startsWith('[')) { + const end = hostPort.indexOf(']'); + if (end > 1) return hostPort.slice(1, end); + return ''; + } + const idx = hostPort.indexOf(':'); + return (idx >= 0 ? hostPort.slice(0, idx) : hostPort).trim(); +} + async function resolveIceServers(call) { try { const payload = await authService.getCallIceConfig(); @@ -95,6 +112,7 @@ async function resolveIceServers(call) { const turnPassword = String(payload?.turnPassword || '').trim(); const turnServers = Array.isArray(payload?.turnServers) ? payload.turnServers : []; + const turnHostSet = new Set(); const iceServers = []; if (stunUrls.length > 0) { iceServers.push({ urls: stunUrls.length === 1 ? stunUrls[0] : stunUrls }); @@ -105,6 +123,10 @@ async function resolveIceServers(call) { const username = String(item?.username || '').trim(); const password = String(item?.password || '').trim(); if (urls.length === 0 || !username || !password) return; + urls.forEach((url) => { + const host = parseTurnHostFromUrl(url); + if (host) turnHostSet.add(host); + }); iceServers.push({ urls: urls.length === 1 ? urls[0] : urls, username, @@ -112,6 +134,10 @@ async function resolveIceServers(call) { }); }); } else if (turnUrls.length > 0 && turnUsername && turnPassword) { + turnUrls.forEach((url) => { + const host = parseTurnHostFromUrl(url); + if (host) turnHostSet.add(host); + }); iceServers.push({ urls: turnUrls.length === 1 ? turnUrls[0] : turnUrls, username: turnUsername, @@ -123,6 +149,9 @@ async function resolveIceServers(call) { await emitDebug(call, 'warn', 'call_ice_empty_from_server', 'using_default_stun'); return cloneDefaultIceServers(); } + if (call) { + call.turnHostsConfigured = Array.from(turnHostSet); + } await emitDebug(call, 'info', 'call_ice_loaded_from_server', `stun=${stunUrls.length}; turnEntries=${Math.max(0, iceServers.length - (stunUrls.length > 0 ? 1 : 0))}`); return iceServers; } catch (error) { @@ -131,6 +160,97 @@ async function resolveIceServers(call) { } } +async function collectIceCandidateAnalytics(call) { + const pc = call?.pc; + if (!pc || typeof pc.getStats !== 'function') return {}; + try { + const stats = await pc.getStats(); + const localCounts = { host: 0, srflx: 0, relay: 0, prflx: 0, other: 0 }; + const remoteCounts = { host: 0, srflx: 0, relay: 0, prflx: 0, other: 0 }; + const relayLocalAddresses = new Set(); + const relayRemoteAddresses = new Set(); + const configuredHosts = new Set((call?.turnHostsConfigured || []).map((v) => String(v || '').trim()).filter(Boolean)); + const matchedConfiguredHosts = new Set(); + let succeededPairsCount = 0; + let succeededPairsWithRelayCount = 0; + + const bump = (bucket, rawType) => { + const type = String(rawType || '').trim().toLowerCase(); + if (!type) bucket.other += 1; + else if (Object.prototype.hasOwnProperty.call(bucket, type)) bucket[type] += 1; + else bucket.other += 1; + }; + const hostOf = (candidate) => { + const raw = String(candidate?.ip || candidate?.address || '').trim(); + if (!raw) return ''; + if (raw.startsWith('[')) { + const end = raw.indexOf(']'); + if (end > 1) return raw.slice(1, end); + } + return raw; + }; + + stats.forEach((report) => { + if (!report || report.type !== 'local-candidate') return; + bump(localCounts, report.candidateType); + if (String(report.candidateType || '').toLowerCase() === 'relay') { + const addr = String(report.ip || report.address || ''); + const port = String(report.port || ''); + relayLocalAddresses.add(port ? `${addr}:${port}` : addr); + const host = hostOf(report); + if (host && configuredHosts.has(host)) matchedConfiguredHosts.add(host); + } + }); + stats.forEach((report) => { + if (!report || report.type !== 'remote-candidate') return; + bump(remoteCounts, report.candidateType); + if (String(report.candidateType || '').toLowerCase() === 'relay') { + const addr = String(report.ip || report.address || ''); + const port = String(report.port || ''); + relayRemoteAddresses.add(port ? `${addr}:${port}` : addr); + } + }); + stats.forEach((report) => { + if (!report || report.type !== 'candidate-pair') return; + const state = String(report.state || '').toLowerCase(); + const ok = report.selected === true || (report.nominated === true && state === 'succeeded') || state === 'succeeded'; + if (!ok) return; + succeededPairsCount += 1; + const local = report.localCandidateId && typeof stats.get === 'function' ? stats.get(report.localCandidateId) : null; + const remote = report.remoteCandidateId && typeof stats.get === 'function' ? stats.get(report.remoteCandidateId) : null; + const lt = String(local?.candidateType || '').toLowerCase(); + const rt = String(remote?.candidateType || '').toLowerCase(); + if (lt === 'relay' || rt === 'relay') succeededPairsWithRelayCount += 1; + }); + + return { + localCandidatesHost: localCounts.host, + localCandidatesSrflx: localCounts.srflx, + localCandidatesRelay: localCounts.relay, + localCandidatesPrflx: localCounts.prflx, + localCandidatesOther: localCounts.other, + remoteCandidatesHost: remoteCounts.host, + remoteCandidatesSrflx: remoteCounts.srflx, + remoteCandidatesRelay: remoteCounts.relay, + remoteCandidatesPrflx: remoteCounts.prflx, + remoteCandidatesOther: remoteCounts.other, + relayLocalCandidatesFound: relayLocalAddresses.size, + relayRemoteCandidatesFound: relayRemoteAddresses.size, + relayLocalCandidatesAddresses: Array.from(relayLocalAddresses).join('|'), + relayRemoteCandidatesAddresses: Array.from(relayRemoteAddresses).join('|'), + configuredTurnHosts: Array.from(configuredHosts).join('|'), + configuredTurnHostsCount: configuredHosts.size, + reachableTurnHostsCount: matchedConfiguredHosts.size, + reachableTurnHosts: Array.from(matchedConfiguredHosts).join('|'), + turnConfiguredButNotReachedHosts: Array.from(configuredHosts).filter((host) => !matchedConfiguredHosts.has(host)).join('|'), + succeededCandidatePairsCount: succeededPairsCount, + succeededCandidatePairsWithRelayCount: succeededPairsWithRelayCount, + }; + } catch { + return {}; + } +} + function ensureAudioContext() { if (audioContext) return audioContext; const Ctx = window.AudioContext || window.webkitAudioContext; @@ -633,14 +753,23 @@ async function finalizeCall(call, { stopReconnectFlow(call); stopTone(); - if (notifyRemoteHangup && call.remoteSessionId) { + const shouldNotifyRemoteFailure = + !notifyRemoteHangup + && Boolean(call.remoteSessionId) + && String(localReasonCode || '') !== 'completed' + && String(debugReason || '') !== 'remote_hangup'; + + if ((notifyRemoteHangup || shouldNotifyRemoteFailure) && call.remoteSessionId) { try { + const dataValue = notifyRemoteHangup + ? '' + : `setup_failed:${String(localReasonCode || 'error')}:${String(debugReason || '').slice(0, 80)}`; await authService.callSignalToSession({ toLogin: call.peerLogin, targetSessionId: call.remoteSessionId, callId: call.callId, type: TYPES.HANGUP, - data: '', + data: dataValue, }); } catch {} } @@ -769,6 +898,7 @@ async function ensurePeerConnection(call) { call.connectionSuccessReported = true; void (async () => { const route = await detectConnectionRoute(call); + const candidateAnalytics = await collectIceCandidateAnalytics(call); if (route?.label) { call.connectionRouteLabel = route.label; } @@ -785,6 +915,7 @@ async function ensurePeerConnection(call) { localIp: route?.localIp || '', remoteIp: route?.remoteIp || '', turnCandidateAddress: route?.turnCandidateAddress || '', + ...candidateAnalytics, }, ); })(); diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/ActiveConnectionsRegistry.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/ActiveConnectionsRegistry.java index 82bafe8..1bf3cfb 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/ActiveConnectionsRegistry.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/ActiveConnectionsRegistry.java @@ -81,10 +81,12 @@ public final class ActiveConnectionsRegistry { String login = ctx.getLogin(); if (sessionId != null && !sessionId.isBlank()) { - ConnectionContext removed = bySessionId.remove(sessionId); + // Удаляем только если под ключом всё ещё лежит именно этот ctx. + // Иначе это старое соединение после re-register, и удалять новый ctx нельзя. + boolean removedCurrent = bySessionId.remove(sessionId, ctx); - // Если в мапе лежал другой ctx под тем же sessionId — не трогаем его byLogin - if (removed != null && removed != ctx) { + // Если в мапе уже другой ctx под тем же sessionId — не трогаем byLogin. + if (!removedCurrent) { log.debug("remove(ctx): sessionId mapped to another ctx, skip byLogin cleanup (sessionId={})", sessionId); return; } diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_CallSignalToSession_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_CallSignalToSession_Handler.java index ce31026..d06b836 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_CallSignalToSession_Handler.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_CallSignalToSession_Handler.java @@ -16,6 +16,8 @@ import server.logic.ws_protocol.WireCodes; import shine.db.dao.SolanaUsersDAO; import shine.db.entities.SolanaUserEntry; +import java.util.Set; + public class Net_CallSignalToSession_Handler implements JsonMessageHandler { private static final ObjectMapper MAPPER = new ObjectMapper(); @@ -44,7 +46,14 @@ public class Net_CallSignalToSession_Handler implements JsonMessageHandler { ConnectionContext targetCtx = ActiveConnectionsRegistry.getInstance().getBySessionId(targetSessionId); if (targetCtx == null || !to.equalsIgnoreCase(targetCtx.getLogin())) { - return NetExceptionResponseFactory.error(req, 404, "SESSION_NOT_FOUND", "Целевая сессия не найдена"); + // Fallback: если точной сессии уже нет (переподключение), но у пользователя ровно 1 активная сессия, + // отправляем в неё, чтобы не ронять звонок из-за устаревшего targetSessionId. + Set activeForLogin = ActiveConnectionsRegistry.getInstance().getByLogin(to); + if (activeForLogin.size() == 1) { + targetCtx = activeForLogin.iterator().next(); + } else { + return NetExceptionResponseFactory.error(req, 404, "SESSION_NOT_FOUND", "Целевая сессия не найдена"); + } } String eventId = NetIdGenerator.eventId("evt");