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");