diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_CallInviteBroadcast_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_CallInviteBroadcast_Handler.java index 359529e0..c21602af 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_CallInviteBroadcast_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_CallInviteBroadcast_Handler.java @@ -26,6 +26,7 @@ import java.util.Set; public class Net_CallInviteBroadcast_Handler implements JsonMessageHandler { private static final ObjectMapper MAPPER = new ObjectMapper(); private static final int TYPE_INVITE = 100; + private static final int TYPE_CONNECT_START = 170; private static final long PUSH_CALL_TTL_MS = 10_000L; @Override @@ -38,8 +39,11 @@ public class Net_CallInviteBroadcast_Handler implements JsonMessageHandler { String toRequest = req.getToLogin() == null ? "" : req.getToLogin().trim(); String callId = req.getCallId() == null ? "" : req.getCallId().trim(); int type = req.getType() == null ? TYPE_INVITE : req.getType(); - if (toRequest.isBlank() || callId.isBlank() || type != TYPE_INVITE) { - return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "toLogin/callId/type=100 обязательны"); + String data = req.getData() == null ? "" : req.getData().trim(); + boolean isInvite = type == TYPE_INVITE; + boolean isConnectStart = type == TYPE_CONNECT_START; + if (toRequest.isBlank() || callId.isBlank() || (!isInvite && !isConnectStart)) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "toLogin/callId/type=100|170 обязательны"); } CurrentUserEntry targetUser = CurrentUsersDAO.getInstance().getByLogin(toRequest); @@ -69,13 +73,28 @@ public class Net_CallInviteBroadcast_Handler implements JsonMessageHandler { payload.put("fromSessionId", ctx.getSessionId()); payload.put("toLogin", to); payload.put("callId", callId); - payload.put("type", TYPE_INVITE); + payload.put("type", type); payload.put("timeMs", timeMs); + if (!data.isBlank()) { + payload.put("data", data); + } boolean sent = WsEventSender.sendEvent(targetCtx, "IncomingCallInvite", eventId, payload); if (sent) wsDelivered++; } + if (isConnectStart) { + Net_CallInviteBroadcast_Response resp = new Net_CallInviteBroadcast_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setCallId(callId); + resp.setDeliveredWsSessions(wsDelivered); + resp.setDeliveredFcmSessions(0); + resp.setDeliveredWebPushSessions(0); + return resp; + } + for (ActiveSessionEntry session : allTargetSessions) { String sessionId = String.valueOf(session.getSessionId() == null ? "" : session.getSessionId()).trim(); if (!sessionId.isBlank() && activeSessionIds.contains(sessionId)) { diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendSignal_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendSignal_Handler.java index 7c24e117..7cf84e7e 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendSignal_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendSignal_Handler.java @@ -12,10 +12,12 @@ import server.logic.ws_protocol.JSON.entyties.Net_Response; import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; import server.logic.ws_protocol.JSON.messages.entyties.Net_SendSignal_Request; import server.logic.ws_protocol.JSON.messages.entyties.Net_SendSignal_Response; +import server.logic.ws_protocol.JSON.push.WebPushSender; import server.logic.ws_protocol.JSON.push.WsEventSender; import server.logic.ws_protocol.JSON.utils.AuthKeyUtils; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; +import shine.db.dao.ActiveSessionsDAO; import shine.db.dao.CurrentUsersDAO; import shine.db.entities.ActiveSessionEntry; import shine.db.entities.CurrentUserEntry; @@ -34,6 +36,13 @@ public class Net_SendSignal_Handler implements JsonMessageHandler { private static final String TARGET_MODE_SINGLE = "single_session"; private static final String TARGET_MODE_ALL = "all_sessions"; private static final long ALLOWED_SKEW_MS = 30_000L; + private static final long PUSH_CALL_TTL_MS = 10_000L; + private static final String SIGNAL_CALL_INVITE = "call_invite"; + private static final String SIGNAL_CALL_ACCEPT = "call_accept"; + private static final String SIGNAL_CALL_DECLINE_BUSY = "call_decline_busy"; + private static final String SIGNAL_CALL_TIMEOUT = "call_timeout"; + private static final String SIGNAL_CALL_HANGUP = "call_hangup"; + private static final String SIGNAL_CALL_CONNECT_START = "call_connect_start"; @Override public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception { @@ -99,6 +108,10 @@ public class Net_SendSignal_Handler implements JsonMessageHandler { return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_SESSION_SIGNATURE", "Некорректная подпись session key"); } + if (isCallSignalType(signalType) && clientSignatureB64.isBlank()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "CLIENT_SIGNATURE_REQUIRED", "Для call_* сигналов обязательна подпись client key"); + } + if (!clientSignatureB64.isBlank()) { String clientPreimage = buildClientPreimage(fromLogin, fromSessionId, toLogin, targetMode, targetSessionId, signalType, signalRequestId, timeMs, digestB64); if (!verifySignature(senderUser.getClientKey(), clientPreimage, clientSignatureB64, "clientKey")) { @@ -107,7 +120,13 @@ public class Net_SendSignal_Handler implements JsonMessageHandler { } List targets = resolveTargets(targetMode, toLogin, targetSessionId); - if (targets.isEmpty()) { + boolean isCallInviteAllSessions = TARGET_MODE_ALL.equals(targetMode) && SIGNAL_CALL_INVITE.equals(signalType); + boolean isCallAcceptSingleSession = TARGET_MODE_SINGLE.equals(targetMode) && SIGNAL_CALL_ACCEPT.equals(signalType); + boolean isCallTerminalSingleSession = TARGET_MODE_SINGLE.equals(targetMode) + && (SIGNAL_CALL_DECLINE_BUSY.equals(signalType) + || SIGNAL_CALL_TIMEOUT.equals(signalType) + || SIGNAL_CALL_HANGUP.equals(signalType)); + if (targets.isEmpty() && !isCallInviteAllSessions) { String code = TARGET_MODE_SINGLE.equals(targetMode) ? "SESSION_NOT_FOUND" : "NO_TARGET_SESSIONS"; String msg = TARGET_MODE_SINGLE.equals(targetMode) ? "Целевая сессия не найдена" : "Нет активных сессий для доставки сигнала"; return NetExceptionResponseFactory.error(req, 404, code, msg); @@ -135,16 +154,62 @@ public class Net_SendSignal_Handler implements JsonMessageHandler { } } - if (deliveredSessionIds.isEmpty()) { + int webPushDelivered = 0; + if (isCallInviteAllSessions) { + webPushDelivered = sendIncomingCallPushToOfflineSessions( + toLogin, + fromLogin, + fromSessionId, + signalRequestId, + deliveredSessionIds + ); + } + + if (deliveredSessionIds.isEmpty() && webPushDelivered <= 0) { return NetExceptionResponseFactory.error(req, 404, "DELIVERY_FAILED", "Не удалось доставить сигнал ни в одну целевую сессию"); } + if (isCallAcceptSingleSession) { + notifyStopOnOtherSessions( + fromLogin, + fromSessionId, + fromLogin, + fromSessionId, + signalRequestId, + "accepted_on_other_device" + ); + } + + if (isCallTerminalSingleSession) { + String deliveredTargetSessionId = deliveredSessionIds.isEmpty() ? targetSessionId : deliveredSessionIds.get(0); + String reason = "terminal_call_signal_" + signalType; + notifyStopOnOtherSessions( + fromLogin, + fromSessionId, + fromLogin, + fromSessionId, + signalRequestId, + reason + ); + notifyStopOnOtherSessions( + toLogin, + deliveredTargetSessionId, + fromLogin, + fromSessionId, + signalRequestId, + reason + ); + } + Net_SendSignal_Response resp = new Net_SendSignal_Response(); resp.setOp(req.getOp()); resp.setRequestId(req.getRequestId()); resp.setStatus(WireCodes.Status.OK); - resp.setDeliveredCount(deliveredSessionIds.size()); + resp.setDeliveredCount(deliveredSessionIds.size() + webPushDelivered); resp.setDeliveredSessionIds(deliveredSessionIds); + resp.setDeliveredWsSessions(deliveredSessionIds.size()); + resp.setDeliveredFcmSessions(webPushDelivered); + resp.setDeliveredWebPushSessions(webPushDelivered); return resp; } @@ -190,6 +255,19 @@ public class Net_SendSignal_Handler implements JsonMessageHandler { + dataSha256B64; } + private static boolean isCallSignalType(String signalType) { + return SIGNAL_CALL_INVITE.equals(signalType) + || "call_ringing".equals(signalType) + || SIGNAL_CALL_ACCEPT.equals(signalType) + || SIGNAL_CALL_DECLINE_BUSY.equals(signalType) + || SIGNAL_CALL_TIMEOUT.equals(signalType) + || SIGNAL_CALL_HANGUP.equals(signalType) + || SIGNAL_CALL_CONNECT_START.equals(signalType) + || "call_offer".equals(signalType) + || "call_answer".equals(signalType) + || "call_ice".equals(signalType); + } + private static boolean verifySignature(String publicKeyValue, String preimage, String signatureB64, String fieldName) throws Exception { byte[] publicKey32 = AuthKeyUtils.parseEd25519PublicKey(publicKeyValue, fieldName); byte[] signature64 = Base64Ws.decodeLen(signatureB64, 64, "signatureB64"); @@ -214,7 +292,130 @@ public class Net_SendSignal_Handler implements JsonMessageHandler { return targets; } + private int sendIncomingCallPushToOfflineSessions( + String toLogin, + String fromLogin, + String fromSessionId, + String callId, + List deliveredOnlineSessionIds + ) throws Exception { + List persistedSessions = ActiveSessionsDAO.getInstance().getByLogin(toLogin); + long sentAtMs = System.currentTimeMillis(); + long expiresAtMs = sentAtMs + PUSH_CALL_TTL_MS; + int delivered = 0; + for (ActiveSessionEntry session : persistedSessions) { + String sessionId = safe(session.getSessionId()); + if (!sessionId.isBlank() && deliveredOnlineSessionIds.contains(sessionId)) { + continue; + } + if (isBlank(session.getPushEndpoint()) || isBlank(session.getPushP256dhKey()) || isBlank(session.getPushAuthKey())) { + continue; + } + String payload = "{\"kind\":\"incoming_call\"" + + ",\"title\":\"SHiNE: входящий звонок\"" + + ",\"text\":\"Вам звонит " + jsonEscape(fromLogin) + "\"" + + ",\"fromLogin\":\"" + jsonEscape(fromLogin) + "\"" + + ",\"fromSessionId\":\"" + jsonEscape(fromSessionId) + "\"" + + ",\"targetSessionId\":\"" + jsonEscape(sessionId) + "\"" + + ",\"toLogin\":\"" + jsonEscape(toLogin) + "\"" + + ",\"callId\":\"" + jsonEscape(callId) + "\"" + + ",\"sentAtMs\":" + sentAtMs + + ",\"expiresAtMs\":" + expiresAtMs + + "}"; + boolean pushed = WebPushSender.sendBase64Payload( + session.getPushEndpoint(), + session.getPushP256dhKey(), + session.getPushAuthKey(), + payload + ); + if (pushed) { + delivered++; + } + } + return delivered; + } + + private void notifyStopOnOtherSessions( + String targetLogin, + String excludeSessionId, + String fromLogin, + String fromSessionId, + String callId, + String reason + ) throws Exception { + if (isBlank(targetLogin) || isBlank(callId)) { + return; + } + + Set onlineSessionIds = new java.util.HashSet<>(); + Set sameUserSessions = ActiveConnectionsRegistry.getInstance().getByLogin(targetLogin); + for (ConnectionContext siblingCtx : sameUserSessions) { + if (siblingCtx == null || siblingCtx.getWsSession() == null || !siblingCtx.getWsSession().isOpen()) continue; + onlineSessionIds.add(safe(siblingCtx.getSessionId())); + if (!isBlank(excludeSessionId) && excludeSessionId.equals(siblingCtx.getSessionId())) continue; + + String siblingEventId = server.logic.ws_protocol.JSON.utils.NetIdGenerator.eventId("evt"); + ObjectNode siblingPayload = MAPPER.createObjectNode(); + siblingPayload.put("eventId", siblingEventId); + siblingPayload.put("fromLogin", fromLogin); + siblingPayload.put("fromSessionId", fromSessionId); + siblingPayload.put("toLogin", targetLogin); + siblingPayload.put("targetMode", TARGET_MODE_SINGLE); + siblingPayload.put("targetSessionId", safe(siblingCtx.getSessionId())); + siblingPayload.put("signalType", SIGNAL_CALL_HANGUP); + siblingPayload.put("signalRequestId", callId); + siblingPayload.put("data", "{\"callId\":\"" + jsonEscape(callId) + "\",\"type\":150,\"data\":\"" + jsonEscape(reason) + "\"}"); + siblingPayload.put("timeMs", System.currentTimeMillis()); + WsEventSender.sendEvent(siblingCtx, "IncomingSignal", siblingEventId, siblingPayload); + } + + List persistedSessions = ActiveSessionsDAO.getInstance().getByLogin(targetLogin); + long sentAtMs = System.currentTimeMillis(); + for (ActiveSessionEntry session : persistedSessions) { + String sessionId = safe(session.getSessionId()); + if (!isBlank(excludeSessionId) && excludeSessionId.equals(sessionId)) continue; + if (!sessionId.isBlank() && onlineSessionIds.contains(sessionId)) continue; + if (isBlank(session.getPushEndpoint()) || isBlank(session.getPushP256dhKey()) || isBlank(session.getPushAuthKey())) { + continue; + } + String pushPayload = "{\"kind\":\"stop_call\"" + + ",\"callId\":\"" + jsonEscape(callId) + "\"" + + ",\"reason\":\"" + jsonEscape(reason) + "\"" + + ",\"fromLogin\":\"" + jsonEscape(fromLogin) + "\"" + + ",\"fromSessionId\":\"" + jsonEscape(fromSessionId) + "\"" + + ",\"targetSessionId\":\"" + jsonEscape(sessionId) + "\"" + + ",\"toLogin\":\"" + jsonEscape(targetLogin) + "\"" + + ",\"sentAtMs\":" + sentAtMs + + "}"; + WebPushSender.sendBase64Payload( + session.getPushEndpoint(), + session.getPushP256dhKey(), + session.getPushAuthKey(), + pushPayload + ); + } + } + private static String safe(String value) { return value == null ? "" : value.trim(); } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } + + private static String jsonEscape(String s) { + if (s == null) return ""; + StringBuilder out = new StringBuilder(); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c == '\\') out.append("\\\\"); + else if (c == '"') out.append("\\\""); + else if (c == '\n') out.append("\\n"); + else if (c == '\r') out.append("\\r"); + else if (c == '\t') out.append("\\t"); + else out.append(c); + } + return out.toString(); + } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_CallInviteBroadcast_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_CallInviteBroadcast_Request.java index 139c5fa0..3703a33c 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_CallInviteBroadcast_Request.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_CallInviteBroadcast_Request.java @@ -6,6 +6,7 @@ public class Net_CallInviteBroadcast_Request extends Net_Request { private String toLogin; private String callId; private Integer type; + private String data; public String getToLogin() { return toLogin; } public void setToLogin(String toLogin) { this.toLogin = toLogin; } @@ -15,4 +16,7 @@ public class Net_CallInviteBroadcast_Request extends Net_Request { public Integer getType() { return type; } public void setType(Integer type) { this.type = type; } + + public String getData() { return data; } + public void setData(String data) { this.data = data; } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendSignal_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendSignal_Response.java index 53b807df..8842212f 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendSignal_Response.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendSignal_Response.java @@ -8,6 +8,9 @@ import java.util.List; public class Net_SendSignal_Response extends Net_Response { private int deliveredCount; private List deliveredSessionIds = new ArrayList<>(); + private int deliveredWsSessions; + private int deliveredFcmSessions; + private int deliveredWebPushSessions; public int getDeliveredCount() { return deliveredCount; @@ -24,4 +27,28 @@ public class Net_SendSignal_Response extends Net_Response { public void setDeliveredSessionIds(List deliveredSessionIds) { this.deliveredSessionIds = deliveredSessionIds; } + + public int getDeliveredWsSessions() { + return deliveredWsSessions; + } + + public void setDeliveredWsSessions(int deliveredWsSessions) { + this.deliveredWsSessions = deliveredWsSessions; + } + + public int getDeliveredFcmSessions() { + return deliveredFcmSessions; + } + + public void setDeliveredFcmSessions(int deliveredFcmSessions) { + this.deliveredFcmSessions = deliveredFcmSessions; + } + + public int getDeliveredWebPushSessions() { + return deliveredWebPushSessions; + } + + public void setDeliveredWebPushSessions(int deliveredWebPushSessions) { + this.deliveredWebPushSessions = deliveredWebPushSessions; + } } diff --git a/TODO/README.md b/TODO/README.md index 6a13506f..43d0b4dc 100644 --- a/TODO/README.md +++ b/TODO/README.md @@ -49,6 +49,7 @@ - `medium/2026-05-26_0029_esp32s3_file_storage.md` - ESP32S3 как личное файловое хранилище SHiNE для файлов переписок и вложений. - `medium/2026-06-02_сессионные_homeserver_в_pda.md` - несколько homeserver-ов пользователя как типизированные сессии в PDA с версией записи. - `medium/2026-06-03_подключение_других_устройств_через_qr.md` - довести подключение других устройств через QR: сейчас заготовка есть, но сценарий работает нестабильно и его нужно будет отдельно доделать. +- `medium/2026-08-09_esp32_переход_на_sendsignal_и_client_key.md` - перевести ESP32 и wallet-extension на единый `SendSignal`, обязательный `client key` и подготовить почву для будущего E2E-шифрования payload. - `medium/2026-07-22_переход_с_sqlite_на_postgresql.md` - завершить зачистку хвостов после перевода серверной БД с `SQLite` на `PostgreSQL`. ### dao_запуск diff --git a/TODO/medium/2026-08-09_esp32_переход_на_sendsignal_и_client_key.md b/TODO/medium/2026-08-09_esp32_переход_на_sendsignal_и_client_key.md new file mode 100644 index 00000000..f8e56e6d --- /dev/null +++ b/TODO/medium/2026-08-09_esp32_переход_на_sendsignal_и_client_key.md @@ -0,0 +1,89 @@ +# ESP32 и wallet-extension: перейти на единый `SendSignal` и обязательную подпись `client key` + +## Зачем + +Сейчас в проекте уже есть универсальный transport `SendSignal`, который умеет работать и в режим `all_sessions`, и в режим `single_session`. + +При этом в коде всё ещё живут старые call-like транспорты: + +- `CallInviteBroadcast` +- `CallSignalToSession` + +Для web-call логики их можно постепенно убрать в пользу одного `SendSignal`. + +Отдельный хвост остался в ESP32 homeserver/wallet-сценариях: + +- в ESP32-скетче технический ответ wallet RPC всё ещё уходит через `CallSignalToSession`; +- в ESP32 send-signal сценарии сейчас допускается режим только с `sessionSignatureB64`, без обязательной подписи `client key`; +- такое поведение выбивается из общей модели безопасности и создаёт лишнюю специальную ветку. + +## Что уже есть в коде + +- универсальный серверный transport `SendSignal` уже существует и поддерживает: + - `single_session`; + - `all_sessions`. +- web UI уже использует `SendSignal` в части межсессионных сценариев. +- звонки пока ещё используют старые call-specific методы. +- ESP32 homeserver main использует: + - `CallSignalToSession` для wallet RPC response; + - `SendSignal` для части технических ответов. +- в ESP32 есть код генерации: + - `sessionSignatureB64`; + - `clientSignatureB64`; + но для некоторых send-signal вызовов `client key` сейчас не обязателен. + +## Что сделать + +1. Перевести ESP32 сценарии со старого `CallSignalToSession` на `SendSignal(single_session)`. +2. Убрать из ESP32-special flow странное исключение, где можно жить без `client key`. +3. Переделать wallet-extension / связанный ESP32-клиент так, чтобы он тоже хранил `client key` и подписывал им `SendSignal`, как обычные клиенты SHiNE. +4. Сделать `SendSignal` контрактно обязательным по подписи `client key`, а не только `session key`. +5. После этого зачистить legacy call-like транспорт там, где он больше не нужен. +6. Отдельно проверить, не осталось ли старых обработчиков, завязанных на: + - `IncomingCallSignal`; + - `IncomingCallInvite`; + там, где должен работать общий `IncomingSignal`. + +## Отдельно про шифрование + +Нужно продумать будущее расширение: чтобы через `SendSignal` можно было передавать не только подписанные, но и зашифрованные payload. + +Предварительный вывод по текущей архитектуре: + +- для сервера это почти не должно требовать специальной логики; +- сервер в `SendSignal` в основном: + - валидирует подписи; + - маршрутизирует событие в нужные сессии; + - пересылает `data` дальше. + +Если UI/ESP32 начнут передавать уже клиентски зашифрованный `data`, сервер должен это пережить без серьёзных изменений, пока: + +- формат `data` остаётся строковым/JSON-совместимым; +- сервер продолжает считать `SHA-256` от фактической передаваемой строки; +- подписи строятся по уже зашифрованному payload. + +То есть возможное E2E-шифрование `SendSignal` в будущем скорее относится к клиентским участникам протокола, а не к серверной маршрутизации. + +## Что важно учесть при миграции + +- не ломать текущие звонки и текущий wallet RPC сценарий одним большим рывком; +- сначала перевести ESP32 и wallet-extension на единый `SendSignal`; +- только потом убирать старые call-specific методы; +- не оставлять “особый ESP32-режим” без `client key`; +- проверить, что у расширения/ESP32 есть безопасное хранение `client key` и понятный сценарий восстановления/перепривязки. + +## Откуда продолжать + +- от `TODO/medium/2026-06-28_send_signal_перенос_старых_сигналов.md`; +- от текущего ESP32 homeserver-скетча: + - `ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino` +- от web UI auth/call transport логики: + - `shine-UI/js/services/auth-service.js` + - `shine-UI/js/services/call-service.js` + +## Какие документы потом обновить + +- `TODO/medium/2026-06-28_send_signal_перенос_старых_сигналов.md`; +- `TODO/README.md`; +- документацию по ESP32 homeserver/wallet flow, если появится отдельный утверждённый transport contract; +- документацию по межсессионным сигналам, если будет зафиксирован единый список `signalType`. diff --git a/VERSION.properties b/VERSION.properties index 6e597b10..4bc97b5c 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.5.4 -server.version=1.4.6 +client.version=1.5.5 +server.version=1.4.7 diff --git a/docs/API/05_Technical_Requests_API.md b/docs/API/05_Technical_Requests_API.md index d458d04a..84f51356 100644 --- a/docs/API/05_Technical_Requests_API.md +++ b/docs/API/05_Technical_Requests_API.md @@ -297,6 +297,7 @@ Первый целевой сценарий: - `remote AddBlock via homeserver session` +- межсессионная доставка звонковых `call_*` сигналов между устройствами пользователя То есть телефон без локального `blockchain.key` может: @@ -336,6 +337,12 @@ - запрос должен быть подписан и `session key`, и `client key`; - в будущем для отдельных wallet-сценариев `clientSignatureB64` может быть пустой. +Для звонковых сигналов `signalType = call_*` правило строже: + +- `clientSignatureB64` обязателен; +- сервер отклоняет такой `SendSignal`, если `client key` подпись не передана; +- это правило действует и для `single_session`, и для `all_sessions`. + ### Запрос в одну сессию ```json @@ -366,11 +373,20 @@ "ok": true, "payload": { "deliveredCount": 1, - "deliveredSessionIds": ["sess-hs-001"] + "deliveredSessionIds": ["sess-hs-001"], + "deliveredWsSessions": 1, + "deliveredFcmSessions": 0, + "deliveredWebPushSessions": 0 } } ``` +Для `call_invite` с `targetMode = "all_sessions"` сервер может дополнительно отправить offline web-push в те сессии адресата, которые сейчас не подключены по WebSocket. В таком случае: + +- `deliveredWsSessions` — сколько сессий получили `IncomingSignal` по WebSocket; +- `deliveredWebPushSessions` — сколько offline-сессий получили web-push; +- `deliveredFcmSessions` пока дублирует тот же счётчик push-доставки и зарезервирован под отдельные push-каналы. + ### Событие на принимающей стороне ```json @@ -428,6 +444,7 @@ - `404 / USER_NOT_FOUND` — логин адресата не найден. - `400 / BAD_DATA` — сервер не смог обработать `data`. - `400 / BAD_SESSION_SIGNATURE` — некорректная подпись `session key`. +- `400 / CLIENT_SIGNATURE_REQUIRED` — для `call_*` сигнала не передана обязательная подпись `client key`. - `400 / BAD_CLIENT_SIGNATURE` — некорректная подпись `client key`. - `404 / SESSION_NOT_FOUND` — при `single_session` целевая сессия не найдена или не онлайн. - `404 / NO_TARGET_SESSIONS` — при `all_sessions` у пользователя сейчас нет активных онлайн-сессий. diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index 82e141ee..fe92aee7 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -10,6 +10,7 @@ import { handleIncomingCallInvite, handleIncomingCallPush, handleIncomingCallSignal, + handleIncomingCallSignalViaSendSignal, handleStopCallPush, setCallDebugReporter, startDebugConnectionAsInitiator, @@ -1313,6 +1314,10 @@ async function init() { try { await handleIncomingCallSignal(evt); } catch {} }); + authService.onEvent('IncomingSignal', async (evt) => { + try { await handleIncomingCallSignalViaSendSignal(evt); } catch {} + }); + authService.onEvent('DebugConnectPrepareResponder', async (evt) => { try { const p = evt?.payload || {}; diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index fd1ec31f..432147ce 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -2631,8 +2631,8 @@ export class AuthService { } - async callInviteBroadcast({ toLogin, callId, type = 100 }) { - const response = await this.ws.request('CallInviteBroadcast', { toLogin, callId, type }); + async callInviteBroadcast({ toLogin, callId, type = 100, data = '' }) { + const response = await this.ws.request('CallInviteBroadcast', { toLogin, callId, type, data }); if (response.status !== 200) throw opError('CallInviteBroadcast', response); return response.payload || {}; } diff --git a/shine-UI/js/services/call-service.js b/shine-UI/js/services/call-service.js index c1865340..6e35c49e 100644 --- a/shine-UI/js/services/call-service.js +++ b/shine-UI/js/services/call-service.js @@ -8,10 +8,25 @@ const TYPES = { DECLINE_BUSY: 130, TIMEOUT: 140, HANGUP: 150, + CONNECT_START: 170, OFFER: 200, ANSWER: 210, ICE: 220, }; +const SIGNAL_TARGET_SINGLE = 'single_session'; +const SIGNAL_TARGET_ALL = 'all_sessions'; +const CALL_SIGNAL_TYPES = Object.freeze({ + [TYPES.INVITE]: 'call_invite', + [TYPES.RINGING]: 'call_ringing', + [TYPES.ACCEPT]: 'call_accept', + [TYPES.DECLINE_BUSY]: 'call_decline_busy', + [TYPES.TIMEOUT]: 'call_timeout', + [TYPES.HANGUP]: 'call_hangup', + [TYPES.CONNECT_START]: 'call_connect_start', + [TYPES.OFFER]: 'call_offer', + [TYPES.ANSWER]: 'call_answer', + [TYPES.ICE]: 'call_ice', +}); const calls = new Map(); const callStateListeners = new Set(); @@ -71,12 +86,54 @@ function getSignalTypeName(type) { if (normalized === TYPES.DECLINE_BUSY) return 'decline_busy'; if (normalized === TYPES.TIMEOUT) return 'timeout'; if (normalized === TYPES.HANGUP) return 'hangup'; + if (normalized === TYPES.CONNECT_START) return 'connect_start'; if (normalized === TYPES.OFFER) return 'offer'; if (normalized === TYPES.ANSWER) return 'answer'; if (normalized === TYPES.ICE) return 'ice'; return `type_${String(type || '')}`; } +function buildConnectStartData(winnerSessionId) { + return `session=${String(winnerSessionId || '').trim()}`; +} + +function parseConnectStartWinnerSessionId(data = '') { + const raw = String(data || '').trim(); + if (!raw) return ''; + if (raw.startsWith('session=')) return raw.slice('session='.length).trim(); + if (raw.startsWith('winnerSessionId=')) return raw.slice('winnerSessionId='.length).trim(); + return ''; +} + +function getCallSignalType(type) { + return CALL_SIGNAL_TYPES[Number(type)] || ''; +} + +function encodeCallSignalData(callId, type, data = '') { + return JSON.stringify({ + callId: String(callId || '').trim(), + type: Number(type), + data: String(data || ''), + }); +} + +function decodeCallSignalData(raw) { + try { + const parsed = JSON.parse(String(raw || '{}')); + return { + callId: String(parsed?.callId || '').trim(), + type: Number(parsed?.type), + data: String(parsed?.data || ''), + }; + } catch { + return { + callId: '', + type: Number.NaN, + data: '', + }; + } +} + function ensureCallTimeline(call) { if (!call) return; if (!Array.isArray(call.timelineEvents)) call.timelineEvents = []; @@ -1168,13 +1225,7 @@ async function finalizeCall(call, { 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: dataValue, - }); + await sendSignal(call, TYPES.HANGUP, dataValue); } catch {} } @@ -1255,13 +1306,22 @@ async function emitDebug(call, level, message, details = '') { async function sendSignal(call, type, data = '') { if (!call.remoteSessionId) return; const signalName = getSignalTypeName(type); + const signalType = getCallSignalType(type); + const login = String(state?.session?.login || '').trim(); + const storagePwd = String(state?.session?.storagePwdInMemory || '').trim(); + if (!login || !storagePwd || !signalType) { + throw new Error('Call SendSignal: missing login/storagePwd/signalType'); + } try { - await authService.callSignalToSession({ + await authService.sendSignal({ toLogin: call.peerLogin, + targetMode: SIGNAL_TARGET_SINGLE, targetSessionId: call.remoteSessionId, - callId: call.callId, - type, - data, + signalType, + signalRequestId: call.callId, + data: encodeCallSignalData(call.callId, type, data), + storagePwd, + includeClientSignature: true, }); recordCallTimeline(call, `signal_out_${signalName}`, `toSession=${call.remoteSessionId}; len=${String(data || '').length}`); await emitDebug(call, 'info', `signal_sent_${type}`, `len=${String(data || '').length}`); @@ -1280,6 +1340,50 @@ async function sendSignal(call, type, data = '') { } } +async function broadcastSignal(call, type, data = '') { + const signalName = getSignalTypeName(type); + const signalType = getCallSignalType(type); + const login = String(state?.session?.login || '').trim(); + const storagePwd = String(state?.session?.storagePwdInMemory || '').trim(); + if (!call?.peerLogin || !login || !storagePwd || !signalType) { + throw new Error('Call SendSignal broadcast: missing peer/login/storagePwd/signalType'); + } + try { + const response = await authService.sendSignal({ + toLogin: call.peerLogin, + targetMode: SIGNAL_TARGET_ALL, + signalType, + signalRequestId: call.callId, + data: encodeCallSignalData(call.callId, type, data), + storagePwd, + includeClientSignature: true, + }); + recordCallTimeline( + call, + `signal_broadcast_${signalName}`, + `ws=${Number(response?.deliveredWsSessions || response?.deliveredCount || 0)}; push=${Number(response?.deliveredWebPushSessions || response?.deliveredFcmSessions || 0)}`, + ); + return response || {}; + } catch (error) { + recordCallTimeline(call, `signal_broadcast_${signalName}_failed`, toErrorText(error), 'warn'); + throw error; + } +} + +async function notifyPeerSessionsAboutConnectStart(call) { + const winnerSessionId = String(call?.remoteSessionId || '').trim(); + if (!call || !winnerSessionId) return; + const data = buildConnectStartData(winnerSessionId); + try { + await broadcastSignal(call, TYPES.CONNECT_START, data); + recordCallTimeline(call, 'connect_start_broadcast_sent', `winnerSession=${winnerSessionId}`); + await emitDebug(call, 'info', 'connect_start_broadcast_sent', `winnerSession=${winnerSessionId}`); + } catch (error) { + recordCallTimeline(call, 'connect_start_broadcast_failed', `winnerSession=${winnerSessionId}; error=${toErrorText(error)}`, 'warn'); + await emitDebug(call, 'warn', 'connect_start_broadcast_failed', toErrorText(error)); + } +} + async function ensurePeerConnection(call) { if (call.pc) return call.pc; @@ -1451,6 +1555,7 @@ async function onAccept(call) { cleanupTimers(call); setStatus(call, 'Соединяем…', 'connecting'); try { + await notifyPeerSessionsAboutConnectStart(call); const pc = await ensurePeerConnection(call); const offer = await pc.createOffer(); await pc.setLocalDescription(offer); @@ -1496,16 +1601,40 @@ async function handleIncomingInvitePayload(payload, { source = 'ws' } = {}) { const callId = String(payload?.callId || '').trim(); const fromLogin = String(payload?.fromLogin || '').trim(); const fromSessionId = String(payload?.fromSessionId || '').trim(); + const type = Number(payload?.type || TYPES.INVITE); + if (type === TYPES.CONNECT_START) { + const call = getCall(callId); + if (!call || call.direction !== 'in' || call.phase === 'ended') return call; + const winnerSessionId = parseConnectStartWinnerSessionId(payload?.data || ''); + const currentSessionId = String(state?.session?.sessionId || '').trim(); + recordCallTimeline(call, 'connect_start_broadcast_received', `winnerSession=${winnerSessionId || '-'}; currentSession=${currentSessionId || '-'}`); + if (winnerSessionId && currentSessionId && winnerSessionId === currentSessionId) { + await emitDebug(call, 'info', 'connect_start_kept_on_winner_session', winnerSessionId); + return call; + } + dismissCallUiLocally(call); + await finalizeCall(call, { + localReasonCode: 'completed', + debugReason: 'answered_elsewhere_session_selected', + suppressRemoteSignal: true, + suppressReports: true, + suppressSummary: true, + }); + return call; + } if (!callId || !fromLogin || !fromSessionId) return null; if (activeCallId && activeCallId !== callId) { try { - await authService.callSignalToSession({ + await authService.sendSignal({ toLogin: fromLogin, + targetMode: SIGNAL_TARGET_SINGLE, targetSessionId: fromSessionId, - callId, - type: TYPES.DECLINE_BUSY, - data: 'busy', + signalType: getCallSignalType(TYPES.DECLINE_BUSY), + signalRequestId: callId, + data: encodeCallSignalData(callId, TYPES.DECLINE_BUSY, 'busy'), + storagePwd: state?.session?.storagePwdInMemory || '', + includeClientSignature: true, }); } catch {} return null; @@ -1804,7 +1933,7 @@ export async function startOutgoingCall(peerLogin) { }, 35000); try { - call.inviteDelivery = await authService.callInviteBroadcast({ toLogin: cleanPeer, callId, type: TYPES.INVITE }); + call.inviteDelivery = await broadcastSignal(call, TYPES.INVITE); recordCallTimeline( call, 'invite_broadcast_ok', @@ -1822,7 +1951,7 @@ export async function startOutgoingCall(peerLogin) { const recovered = await ensureSessionForCall({ timeoutMs: preflightTimeoutMs, force: true }); if (recovered) { try { - call.inviteDelivery = await authService.callInviteBroadcast({ toLogin: cleanPeer, callId, type: TYPES.INVITE }); + call.inviteDelivery = await broadcastSignal(call, TYPES.INVITE); if (getInviteDeliveredCount(call) > 0 && call.phase === 'searching') { setStatus(call, 'Вызываем…', 'ringing'); } @@ -1842,6 +1971,33 @@ export async function handleIncomingCallInvite(evt) { await handleIncomingInvitePayload(evt?.payload || {}, { source: 'ws' }); } +export async function handleIncomingCallSignalViaSendSignal(evt) { + const payload = evt?.payload || {}; + const signalType = String(payload?.signalType || '').trim(); + if (!signalType.startsWith('call_')) return false; + + const decoded = decodeCallSignalData(payload?.data || ''); + if (!Number.isFinite(decoded.type)) return true; + + const normalizedEvent = { + payload: { + callId: decoded.callId || String(payload?.signalRequestId || '').trim(), + fromLogin: payload?.fromLogin, + fromSessionId: payload?.fromSessionId, + type: decoded.type, + data: decoded.data, + }, + }; + + if (decoded.type === TYPES.INVITE || decoded.type === TYPES.CONNECT_START) { + await handleIncomingInvitePayload(normalizedEvent.payload, { source: 'send_signal' }); + return true; + } + + await handleIncomingCallSignal(normalizedEvent); + return true; +} + export async function acceptIncomingCall() { const call = getActiveCall(); if (!call || call.direction !== 'in' || call.phase !== 'incoming') return; @@ -1885,23 +2041,11 @@ export async function handleIncomingCallSignal(evt) { recordCallTimeline(call, `signal_in_${signalName}`, `from=${fromSessionId || '-'}; len=${data.length}`); if (call.direction === 'out') { if (type === TYPES.RINGING) { - if (!call.remoteSessionId && fromSessionId) { - setRemoteSessionId(call, fromSessionId, 'ringing'); - } - if (call.remoteSessionId && fromSessionId && call.remoteSessionId !== fromSessionId) { - recordCallTimeline(call, 'signal_in_ringing_ignored', `selected=${call.remoteSessionId}; from=${fromSessionId}`); - await emitDebug( - call, - 'info', - 'ringing_from_non_selected_session_ignored', - `selected=${call.remoteSessionId}; from=${fromSessionId}`, - ); - return; - } + recordCallTimeline(call, 'ringing_received', `from=${fromSessionId || '-'}`); } else if (type === TYPES.ACCEPT) { if (fromSessionId) { - if (!call.remoteSessionId || !call.initialOfferSent) { - setRemoteSessionId(call, fromSessionId, 'accept_before_offer_lock'); + if (!call.remoteSessionId) { + setRemoteSessionId(call, fromSessionId, 'accept'); } else if (call.remoteSessionId !== fromSessionId) { recordCallTimeline(call, 'signal_in_accept_ignored', `selected=${call.remoteSessionId}; from=${fromSessionId}`, 'warn'); await emitDebug( @@ -1961,6 +2105,12 @@ export async function handleIncomingCallSignal(evt) { return; } + if (type === TYPES.CONNECT_START) { + const winnerSessionId = parseConnectStartWinnerSessionId(data); + recordCallTimeline(call, 'connect_start_received', `winnerSession=${winnerSessionId || '-'}; currentRemoteSession=${call.remoteSessionId || '-'}`); + return; + } + if (type === TYPES.ACCEPT) { if (call.direction !== 'out') { await emitDebug(call, 'warn', 'accept_ignored_for_non_outgoing_call', `direction=${call.direction || ''}`);