From 28826e050ccc4dda3890fa36c28a94837ef32337 Mon Sep 17 00:00:00 2001 From: evgeny Date: Sun, 18 Oct 2026 14:46:04 +0200 Subject: [PATCH] Add local UDP discovery and invitation delivery for chat clients --- lib/debug_config.c | 1 + lib/debug_config.h | 3 +- src/Makefile.am | 14 + src/chat/chat_event.c | 3 +- src/chat/chat_event.h | 5 + src/chat/chat_local.c | 109 ++++ src/chat/chat_local.h | 32 ++ src/chat/chat_profile.c | 2 + src/chat/chat_setting.c | 8 +- src/local_transport/local_transport.c | 506 ++++++++++++++++++ src/local_transport/local_transport.h | 63 +++ src/local_transport/platform.c | 178 ++++++ src/local_transport/platform.h | 27 + src/local_transport/protocol.h | 41 ++ src/utun_instance.c | 5 +- src/utun_instance.h | 1 + tests/Makefile.am | 6 +- tests/local_transport_ipv6.sh | 15 + tests/test_local_transport.c | 278 ++++++++++ .../app/src/main/AndroidManifest.xml | 1 + .../java/com/utun/chat/ChatApplication.kt | 1 + .../main/java/com/utun/chat/MainActivity.kt | 15 + .../java/com/utun/chat/data/ConfigProvider.kt | 8 + .../java/com/utun/chat/data/LocalDiscovery.kt | 112 ++++ .../main/java/com/utun/chat/data/NativeLib.kt | 5 + .../utun/chat/ui/screens/InviteLinkDialog.kt | 79 ++- .../utun/chat/ui/screens/SettingsScreen.kt | 32 ++ .../app/src/main/res/values-ru/strings.xml | 11 + .../app/src/main/res/values/strings.xml | 11 + .../jni_bridge/android_jni_bridge.c | 32 ++ tools/chatgui/CMakeLists.txt | 6 + tools/chatgui/src/invitedialog.cpp | 106 +++- tools/chatgui/src/invitedialog.h | 10 + tools/chatgui/src/mainwindow.cpp | 17 + tools/chatgui/src/networksettingspage.cpp | 45 +- tools/chatgui/src/networksettingspage.h | 8 +- tools/chatgui/src/settingsdialog.cpp | 16 +- tools/chatgui/tests/test_gui_language.cpp | 22 + tools/chatgui/tests/test_local_invite_ui.cpp | 87 +++ tools/chatgui/translations/chatgui_en.ts | 17 +- tools/chatgui/translations/chatgui_ru.ts | 17 +- tools/chatgui/transport/gui_bridge.h | 6 + tools/chatgui/transport/gui_bridge_impl.cpp | 16 + 43 files changed, 1934 insertions(+), 43 deletions(-) create mode 100644 src/chat/chat_local.c create mode 100644 src/chat/chat_local.h create mode 100644 src/local_transport/local_transport.c create mode 100644 src/local_transport/local_transport.h create mode 100644 src/local_transport/platform.c create mode 100644 src/local_transport/platform.h create mode 100644 src/local_transport/protocol.h create mode 100644 tests/local_transport_ipv6.sh create mode 100644 tests/test_local_transport.c create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/data/LocalDiscovery.kt create mode 100644 tools/chatgui/tests/test_local_invite_ui.cpp diff --git a/lib/debug_config.c b/lib/debug_config.c index 1762246d..ffa9f791 100644 --- a/lib/debug_config.c +++ b/lib/debug_config.c @@ -137,6 +137,7 @@ static const struct { {"vad", DEBUG_CATEGORY_VAD}, {"aec", DEBUG_CATEGORY_AEC}, {"stream", DEBUG_CATEGORY_STREAM}, + {"discovery", DEBUG_CATEGORY_DISCOVERY}, {"all", DEBUG_CATEGORY_ALL}, {NULL, DEBUG_CATEGORY_NONE} }; diff --git a/lib/debug_config.h b/lib/debug_config.h index fa61be6a..cc1a7237 100644 --- a/lib/debug_config.h +++ b/lib/debug_config.h @@ -75,7 +75,8 @@ typedef int debug_category_t; #define DEBUG_CATEGORY_VAD 34 // VAD — Silero voice activity detector (ONNX Runtime) #define DEBUG_CATEGORY_AEC 35 // AEC — acoustic echo cancellation (SpeexDSP) #define DEBUG_CATEGORY_STREAM 36 // Подписанные потоки, FEC, буферы и выбор поставщиков -#define DEBUG_CATEGORY_COUNT 37 // Total number of categories +#define DEBUG_CATEGORY_DISCOVERY 37 // LAN UDP: discovery and delivery +#define DEBUG_CATEGORY_COUNT 38 // Total number of categories #define DEBUG_CATEGORY_ALL (-1) // special value for all categories /* Debug configuration structure */ diff --git a/src/Makefile.am b/src/Makefile.am index b150eef1..28694c99 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -153,6 +153,13 @@ utun_CORE_SOURCES = \ lwip_tcp/lwip_tcp.c \ lwip_tcp/lwip_tcp_in.c \ lwip_tcp/lwip_tcp_out.c \ + local_transport/local_transport.c \ + local_transport/local_transport.h \ + local_transport/platform.c \ + local_transport/platform.h \ + local_transport/protocol.h \ + chat/chat_local.c \ + chat/chat_local.h \ chat/chat_event.c \ chat/chat_setting.c \ chat/chat_core.c \ @@ -327,6 +334,13 @@ libutun_a_SOURCES = \ lwip_tcp/lwip_tcp.c \ lwip_tcp/lwip_tcp_in.c \ lwip_tcp/lwip_tcp_out.c \ + local_transport/local_transport.c \ + local_transport/local_transport.h \ + local_transport/platform.c \ + local_transport/platform.h \ + local_transport/protocol.h \ + chat/chat_local.c \ + chat/chat_local.h \ chat/chat_event.c \ chat/chat_setting.c \ chat/chat_core.c \ diff --git a/src/chat/chat_event.c b/src/chat/chat_event.c index 00c78ca4..654cd8dd 100644 --- a/src/chat/chat_event.c +++ b/src/chat/chat_event.c @@ -54,10 +54,11 @@ void chat_event_post(struct UTUN_INSTANCE* inst, int type, const uint8_t* data, [56]="MSG_SUBMITTED", [57]="CPU_LOAD", [59]="DM_DELETE_RESULT", + [61]="LOCAL_DEVICES", [62]="LOCAL_INVITE", [63]="LOCAL_DELIVERY", [64]="LOCAL_DISCOVERY_ACTIVE", }; const char* n = (type >= 1 && type < (int)(sizeof(names) / sizeof(names[0]))) ? names[type] : "?"; DEBUG_DEBUG(DEBUG_CATEGORY_CHAT, "chat_event: %s(%d) data=%d bytes", n, type, len); - if (data && len > 0) log_dump(DEBUG_LEVEL_DEBUG, DEBUG_CATEGORY_CHAT, " ", data, len); + if (data && len > 0 && type != CHAT_EVT_LOCAL_INVITE) log_dump(DEBUG_LEVEL_DEBUG, DEBUG_CATEGORY_CHAT, " ", data, len); } /* Идентификатор связывает результат с конкретной отправкой, независимо от текущего канала GUI. */ diff --git a/src/chat/chat_event.h b/src/chat/chat_event.h index 6ab462ea..bf61027f 100644 --- a/src/chat/chat_event.h +++ b/src/chat/chat_event.h @@ -83,6 +83,11 @@ extern "C" { #define CHAT_EVT_DM_DELETE_RESULT 59 /* [request:8LE][conv:8LE][generation:8LE][success:1] — после транзакции */ #define CHAT_EVT_ATTACHMENT_KEYS_CHANGED 60 /* [channel_len:1][channel][success?:1], без секретов; success — результат команды GUI */ +#define CHAT_EVT_LOCAL_DEVICES 61 /* [count:2BE][node:8BE,name_len:1,name:UTF8]* */ +#define CHAT_EVT_LOCAL_INVITE 62 /* [node:8BE][name_len:1][name:UTF8][invite_link:UTF8], без NUL */ +#define CHAT_EVT_LOCAL_DELIVERY 63 /* [request:8BE][node:8BE][status:1], local_delivery_status, не результат JOIN */ +#define CHAT_EVT_LOCAL_DISCOVERY_ACTIVE 64 /* [active:1], Android MulticastLock */ + typedef void (*chat_event_handler_fn)(struct UTUN_INSTANCE* inst, int type, const uint8_t* data, int len); void chat_event_set_handler(struct UTUN_INSTANCE* inst, chat_event_handler_fn handler); diff --git a/src/chat/chat_local.c b/src/chat/chat_local.c new file mode 100644 index 00000000..6cb8aedb --- /dev/null +++ b/src/chat/chat_local.c @@ -0,0 +1,109 @@ +#include "chat_local.h" +#include "chat_event.h" +#include "invite_link.h" +#include "chat_setting.h" +#include "../utun_instance.h" +#include "../local_transport/protocol.h" +#include "../../lib/debug_config.h" +#include "../../lib/mem.h" +#include + +void chat_local_peers_trampoline(void* arg) { + struct UTUN_INSTANCE* inst = arg; + if (!inst) { DEBUG_ERROR(DEBUG_CATEGORY_DISCOVERY, "device list request without instance"); return; } + struct local_peer peers[LOCAL_TRANSPORT_PEERS_MAX]; + size_t count = local_transport_peers(inst->local_transport, peers, LOCAL_TRANSPORT_PEERS_MAX), offset = 2; + uint8_t data[2 + LOCAL_TRANSPORT_PEERS_MAX * (8 + 1 + LOCAL_TRANSPORT_NAME_MAX - 1)]; + data[0] = (uint8_t)(count >> 8); data[1] = (uint8_t)count; + for (size_t i = 0; i < count; i++) { + size_t len = strlen(peers[i].name); + local_put64(data + offset, peers[i].node_id); data[offset + 8] = (uint8_t)len; + memcpy(data + offset + 9, peers[i].name, len); offset += 9 + len; + } + chat_event_post(inst, CHAT_EVT_LOCAL_DEVICES, data, (int)offset); +} + +static void peers_changed(void* arg) { chat_local_peers_trampoline(arg); } + +static int valid_link(const char* link, size_t len) { + struct InviteData invite; + char error[128]; + if (!len || len > LOCAL_TRANSPORT_DATA_MAX || memchr(link, 0, len) || len < 7 || memcmp(link, "utun://", 7)) { + DEBUG_WARN(DEBUG_CATEGORY_DISCOVERY, "invalid local invitation length/prefix bytes=%zu", len); return 0; + } + if (invite_link_decode(link, len, &invite, error, sizeof(error)) < 0) { + DEBUG_WARN(DEBUG_CATEGORY_DISCOVERY, "invalid local invitation: %s", error); return 0; + } + return 1; +} + +static int received(void* arg, const struct local_peer* peer, const uint8_t* link, size_t len) { + struct UTUN_INSTANCE* inst = arg; + if (!chat_setting_get_int(inst, "local_invites_enabled", 1)) { + DEBUG_WARN(DEBUG_CATEGORY_DISCOVERY, "local invitation rejected peer=%016llx reason=receiving disabled", + (unsigned long long)peer->node_id); return LOCAL_DELIVERY_FORBIDDEN; + } + if (!inst->chat_event_handler || !valid_link((const char*)link, len)) { + DEBUG_WARN(DEBUG_CATEGORY_DISCOVERY, "local invitation rejected peer=%016llx handler=%d", + (unsigned long long)peer->node_id, inst->chat_event_handler != NULL); return -1; + } + uint8_t data[9 + LOCAL_TRANSPORT_NAME_MAX + LOCAL_TRANSPORT_DATA_MAX]; + size_t name_len = strlen(peer->name); + local_put64(data, peer->node_id); data[8] = (uint8_t)name_len; + memcpy(data + 9, peer->name, name_len); memcpy(data + 9 + name_len, link, len); + chat_event_post(inst, CHAT_EVT_LOCAL_INVITE, data, (int)(9 + name_len + len)); + return 0; +} + +static void delivered(void* arg, uint64_t request, uint64_t peer, int status) { + uint8_t data[17]; + local_put64(data, request); local_put64(data + 8, peer); data[16] = (uint8_t)status; + chat_event_post(arg, CHAT_EVT_LOCAL_DELIVERY, data, sizeof(data)); +} + +static void activity_changed(void* arg, int active) { + uint8_t value = active != 0; + chat_event_post(arg, CHAT_EVT_LOCAL_DISCOVERY_ACTIVE, &value, 1); +} + +static void activity(struct UTUN_INSTANCE* inst, int active, void* arg) { + (void)arg; + if (local_transport_set_active(inst->local_transport, inst->client_type != CLIENT_TYPE_MOBILE || active) < 0) + DEBUG_ERROR(DEBUG_CATEGORY_DISCOVERY, "failed to apply discovery activity node=%016llx", (unsigned long long)inst->node_id); +} + +int chat_local_start(struct UTUN_INSTANCE* inst) { + if (inst->local_transport || inst->client_type == CLIENT_TYPE_SERVER) return 0; + struct local_transport_callbacks callbacks = { peers_changed, received, delivered, activity_changed }; + inst->local_transport = local_transport_create(inst->ua, inst->node_id, inst->name, &callbacks, inst); + if (!inst->local_transport) return -1; + if (chat_local_apply_settings(inst) < 0) { chat_local_stop(inst); return -1; } + utun_add_activity_cbk(inst, activity, inst); + activity(inst, inst->client_activity == CLIENT_ACTIVITY_ACTIVE, inst); + return 0; +} + +int chat_local_apply_settings(struct UTUN_INSTANCE* inst) { + if (!inst || !inst->local_transport) return 0; + int receive = chat_setting_get_int(inst, "local_invites_enabled", 1); + int broadcasts = chat_setting_get_int(inst, "local_broadcasts_enabled", 1); + DEBUG_INFO(DEBUG_CATEGORY_DISCOVERY, "local permissions applied node=%016llx receive_invites=%d broadcast=%d", + (unsigned long long)inst->node_id, receive, broadcasts); + return local_transport_set_broadcasts(inst->local_transport, broadcasts); +} + +void chat_local_stop(struct UTUN_INSTANCE* inst) { + if (!inst || !inst->local_transport) return; + utun_remove_activity_cbk(inst, activity, inst); + local_transport_destroy(inst->local_transport); inst->local_transport = NULL; +} + +void chat_local_send_trampoline(void* arg) { + struct chat_local_send_request* req = arg; + if (!req) return; + size_t len = strnlen(req->link, sizeof(req->link)); + if (!req->inst) { DEBUG_ERROR(DEBUG_CATEGORY_DISCOVERY, "invitation send without instance"); u_free(req); return; } + if (!valid_link(req->link, len) || local_transport_send(req->inst->local_transport, req->peer_id, + req->request_id, req->link, len) < 0) delivered(req->inst, req->request_id, req->peer_id, 0); + u_free(req); +} diff --git a/src/chat/chat_local.h b/src/chat/chat_local.h new file mode 100644 index 00000000..681b33b1 --- /dev/null +++ b/src/chat/chat_local.h @@ -0,0 +1,32 @@ +/* Адаптер локального транспорта: только доставка готовых invite-ссылок, без запуска JOIN. */ +#ifndef CHAT_LOCAL_H +#define CHAT_LOCAL_H + +#include "../local_transport/local_transport.h" + +#ifdef __cplusplus +extern "C" { +#endif + +struct UTUN_INSTANCE; +/* Создать транспорт для desktop/mobile, подписать активность. Повтор и server — 0; ошибка — -1. */ +int chat_local_start(struct UTUN_INSTANCE* inst); +/* Снять подписку и уничтожить транспорт чат-сервиса; NULL/repeat допустимы. */ +void chat_local_stop(struct UTUN_INSTANCE* inst); +/* Применить local_invites_enabled/local_broadcasts_enabled из chat_setting без рестарта. + * При отсутствии транспорта ничего не делает. Возвращает 0/-1. */ +int chat_local_apply_settings(struct UTUN_INSTANCE* inst); +/* Трамплины uasync. peers получает заимствованный inst; send освобождает u_malloc request. */ +void chat_local_peers_trampoline(void* inst); +struct chat_local_send_request { + struct UTUN_INSTANCE* inst; // заимствованный instance, которому передаётся команда + uint64_t peer_id; // устройство из CHAT_EVT_LOCAL_DEVICES + uint64_t request_id; // ненулевой ID UI, возвращается LOCAL_DELIVERY + char link[LOCAL_TRANSPORT_DATA_MAX + 1]; // готовая зарегистрированная ссылка, завершается NUL +}; +void chat_local_send_trampoline(void* request); + +#ifdef __cplusplus +} +#endif +#endif diff --git a/src/chat/chat_profile.c b/src/chat/chat_profile.c index 3fa97e67..fe12d725 100644 --- a/src/chat/chat_profile.c +++ b/src/chat/chat_profile.c @@ -7,6 +7,7 @@ #include "chat_core_priv.h" #include "chat_event.h" #include "chat_member.h" +#include "chat_local.h" #include "../../lib/json_flat.h" #include "../routing_layer/topo_node_sqlite.h" #include "../routing_layer/topo_node.h" @@ -36,6 +37,7 @@ void chat_core_update_my_name(struct UTUN_INSTANCE* inst, const char* name) { name, inst->my_keys.public_key, inst->my_ed25519_pubkey, (uint64_t)ntp_time_get_seconds(inst), 0); snprintf(inst->name, sizeof(inst->name), "%s", name); + if (inst->local_transport) local_transport_set_name(inst->local_transport, inst->name); chat_core_update_my_member(inst); } diff --git a/src/chat/chat_setting.c b/src/chat/chat_setting.c index 06238352..9043160b 100644 --- a/src/chat/chat_setting.c +++ b/src/chat/chat_setting.c @@ -6,6 +6,7 @@ */ #include "chat_setting.h" +#include "chat_local.h" #include "../call/call_video_rate.h" #include "../utun_instance.h" #include "../../lib/debug_config.h" @@ -73,6 +74,8 @@ static const struct chat_setting_def g_setting_defs[] = { {"member_files_path", CHAT_SETTING_STRING, 0, 0, 0}, {"member_files_user_quota_mb", CHAT_SETTING_INT, 1024, 1, 1048576}, {"member_files_upload_ttl_sec", CHAT_SETTING_INT, 3600, 60, 604800}, + {"local_invites_enabled", CHAT_SETTING_BOOL, 1, 0, 1}, + {"local_broadcasts_enabled", CHAT_SETTING_BOOL, 1, 0, 1}, }; #define CHAT_SETTING_COUNT (sizeof(g_setting_defs) / sizeof(g_setting_defs[0])) @@ -204,7 +207,10 @@ static struct chat_setting_state* cs_state(struct UTUN_INSTANCE* inst) { int chat_setting_set(struct UTUN_INSTANCE* inst, const char* name, const char* value) { struct chat_setting_state* s = cs_state(inst); if (!s) return -2; - return chat_setting_set_state(s, name, value); + int result = chat_setting_set_state(s, name, value); + if (!result && (!strcmp(name, "local_invites_enabled") || !strcmp(name, "local_broadcasts_enabled"))) + return chat_local_apply_settings(inst); + return result; } const char* chat_setting_get(struct UTUN_INSTANCE* inst, const char* name) { diff --git a/src/local_transport/local_transport.c b/src/local_transport/local_transport.c new file mode 100644 index 00000000..ee0f1004 --- /dev/null +++ b/src/local_transport/local_transport.c @@ -0,0 +1,506 @@ +#include "local_transport.h" +#include "platform.h" +#include "protocol.h" +#include "../../lib/u_async.h" +#include "../../lib/debug_config.h" +#include "../../lib/mem.h" +#include +#include + +#define ANNOUNCE_TB 100000 +#define EXPIRE_TB 300000 +#define TICK_TB 10000 +#define ENDPOINT_MAX 8 +#define PENDING_MAX 8 +#define SEEN_MAX 128 + +struct local_socket { + struct local_socket* next; + struct local_transport* owner; + struct local_interface interface; + socket_t fd; + void* handle; + int present; +}; +struct local_endpoint { struct sockaddr_storage address; uint32_t index; uint64_t seen; }; +struct peer_entry { + struct local_peer peer; + uint64_t epoch; + struct local_endpoint endpoints[ENDPOINT_MAX]; + size_t count; +}; +struct pending_entry { + uint64_t request, peer_id, epoch; + size_t size; + unsigned attempts; + uint8_t data[LOCAL_TRANSPORT_DATA_MAX]; +}; +struct seen_entry { uint64_t peer, epoch, request, time; }; +struct local_transport { + struct UASYNC* ua; + uint64_t node_id, epoch, next_announce; + char name[LOCAL_TRANSPORT_NAME_MAX]; + struct local_transport_callbacks callbacks; + void* arg; + void* timer; + struct local_socket* sockets; + uint32_t joined6[LOCAL_INTERFACE_MAX]; + size_t joined6_count; + struct peer_entry peers[LOCAL_TRANSPORT_PEERS_MAX]; + size_t peer_count; + struct pending_entry pending[PENDING_MAX]; + struct seen_entry seen[SEEN_MAX]; + unsigned seen_next; + int active, broadcasts; +}; + +static void socket_read(socket_t fd, void* arg); +static void tick(void* arg); + +static void peers_changed(struct local_transport* t) { + if (t->callbacks.peers_changed) t->callbacks.peers_changed(t->arg); +} + +static struct peer_entry* find_peer(struct local_transport* t, uint64_t node_id) { + for (size_t i = 0; i < t->peer_count; i++) if (t->peers[i].peer.node_id == node_id) return &t->peers[i]; + return NULL; +} + +static int same_address(const struct sockaddr_storage* a, const struct sockaddr_storage* b) { + if (a->ss_family != b->ss_family || ss_get_port(a) != ss_get_port(b)) return 0; + if (a->ss_family == AF_INET) + return ((const struct sockaddr_in*)a)->sin_addr.s_addr == ((const struct sockaddr_in*)b)->sin_addr.s_addr; + return !memcmp(&((const struct sockaddr_in6*)a)->sin6_addr, &((const struct sockaddr_in6*)b)->sin6_addr, 16); +} + +static struct local_socket* find_socket(struct local_transport* t, const struct sockaddr_storage* source, uint32_t index) { + for (struct local_socket* s = t->sockets; s; s = s->next) + if (s->interface.index == index && index && local_on_interface(&s->interface, source)) return s; + return NULL; +} + +static int send_packet(struct local_socket* s, const struct sockaddr_storage* address, uint8_t type, + uint64_t peer, uint64_t epoch, uint64_t request, const void* payload, size_t len) { + struct local_transport* t = s->owner; + uint8_t data[LOCAL_PACKET_MAX]; + memcpy(data, LOCAL_MAGIC, 16); data[16] = 1; data[17] = type; + data[18] = (uint8_t)(len >> 8); data[19] = (uint8_t)len; + local_put64(data + 20, t->node_id); local_put64(data + 28, t->epoch); + local_put64(data + 36, peer); local_put64(data + 44, epoch); local_put64(data + 52, request); + if (len) memcpy(data + LOCAL_HEADER_SIZE, payload, len); + socklen_t address_len = address->ss_family == AF_INET ? sizeof(struct sockaddr_in) : sizeof(struct sockaddr_in6); + ssize_t result = socket_sendto(s->fd, data, LOCAL_HEADER_SIZE + len, (const struct sockaddr*)address, address_len); + if (result != (ssize_t)(LOCAL_HEADER_SIZE + len)) { + DEBUG_WARN(DEBUG_CATEGORY_DISCOVERY, "send failed type=%u interface=%u destination=%s bytes=%zu result=%zd error=%s", + type, s->interface.index, sockaddr_storage_to_str(address).str, len, result, socket_strerror(socket_get_error())); + return -1; + } + DEBUG_DEBUG(DEBUG_CATEGORY_DISCOVERY, "sent type=%u interface=%u destination=%s peer=%016llx request=%llu bytes=%zu", + type, s->interface.index, sockaddr_storage_to_str(address).str, (unsigned long long)peer, (unsigned long long)request, len); + return 0; +} + +static void announce(struct local_socket* s, const struct sockaddr_storage* address) { + send_packet(s, address, LOCAL_ANNOUNCE, 0, 0, 0, s->owner->name, strlen(s->owner->name)); +} + +static void announce_all(struct local_transport* t) { + if (!t->active || !t->broadcasts) return; + for (struct local_socket* s = t->sockets; s; s = s->next) + if (s->interface.index) announce(s, &s->interface.broadcast); +} + +static void delivery(struct local_transport* t, struct pending_entry* p, int status) { + uint64_t request = p->request, peer = p->peer_id; + memset(p, 0, sizeof(*p)); + if (status == LOCAL_DELIVERY_RECEIVED) DEBUG_INFO(DEBUG_CATEGORY_DISCOVERY, "message delivered peer=%016llx request=%llu", + (unsigned long long)peer, (unsigned long long)request); + else if (status == LOCAL_DELIVERY_FORBIDDEN) DEBUG_WARN(DEBUG_CATEGORY_DISCOVERY, + "message receipt forbidden by peer=%016llx request=%llu; enable receiving in recipient settings", + (unsigned long long)peer, (unsigned long long)request); + else DEBUG_WARN(DEBUG_CATEGORY_DISCOVERY, "message delivery failed peer=%016llx request=%llu", + (unsigned long long)peer, (unsigned long long)request); + if (t->callbacks.delivery) t->callbacks.delivery(t->arg, request, peer, status); +} + +static void expire(struct local_transport* t, uint64_t now) { + int changed = 0; + for (size_t i = 0; i < t->peer_count;) { + struct peer_entry* p = &t->peers[i]; + for (size_t j = 0; j < p->count;) { + struct local_endpoint* e = &p->endpoints[j]; + if (now - e->seen < EXPIRE_TB && find_socket(t, &e->address, e->index)) { j++; continue; } + DEBUG_DEBUG(DEBUG_CATEGORY_DISCOVERY, "endpoint expired peer=%016llx interface=%u address=%s age_ms=%llu", + (unsigned long long)p->peer.node_id, e->index, sockaddr_storage_to_str(&e->address).str, + (unsigned long long)((now - e->seen) / 10)); + p->endpoints[j] = p->endpoints[--p->count]; + } + if (p->count) { i++; continue; } + DEBUG_INFO(DEBUG_CATEGORY_DISCOVERY, "device expired peer=%016llx name=%s", (unsigned long long)p->peer.node_id, p->peer.name); + t->peers[i] = t->peers[--t->peer_count]; changed = 1; + } + if (changed) peers_changed(t); +} + +static void receive_packet(struct local_socket* s, struct sockaddr_storage* source, const uint8_t* data, size_t size) { + struct local_transport* t = s->owner; + if (size < 16 || memcmp(data, LOCAL_MAGIC, 16)) { + DEBUG_DEBUG(DEBUG_CATEGORY_DISCOVERY, "foreign UDP ignored interface=%u source=%s bytes=%zu", + s->interface.index, sockaddr_storage_to_str(source).str, size); return; + } + if (size < LOCAL_HEADER_SIZE || size > LOCAL_PACKET_MAX || data[16] != 1 || + (size_t)((unsigned)data[18] << 8 | data[19]) != size - LOCAL_HEADER_SIZE) { + DEBUG_WARN(DEBUG_CATEGORY_DISCOVERY, "invalid local header source=%s bytes=%zu", sockaddr_storage_to_str(source).str, size); return; + } + uint64_t peer_id = local_get64(data + 20), epoch = local_get64(data + 28), request = local_get64(data + 52); + uint64_t destination = local_get64(data + 36), destination_epoch = local_get64(data + 44); + if (!peer_id || !epoch) { DEBUG_WARN(DEBUG_CATEGORY_DISCOVERY, "zero sender identity in local packet"); return; } + if (peer_id == t->node_id) return; + uint8_t type = data[17]; + const uint8_t* payload = data + LOCAL_HEADER_SIZE; + size_t len = size - LOCAL_HEADER_SIZE; + uint64_t now = get_time_tb(); + struct peer_entry* p = find_peer(t, peer_id); + DEBUG_DEBUG(DEBUG_CATEGORY_DISCOVERY, "received type=%u peer=%016llx interface=%u source=%s request=%llu bytes=%zu", + type, (unsigned long long)peer_id, s->interface.index, sockaddr_storage_to_str(source).str, (unsigned long long)request, len); + if (type == LOCAL_ANNOUNCE) { + if (destination || destination_epoch || request || !local_name_valid(payload, len)) { + DEBUG_WARN(DEBUG_CATEGORY_DISCOVERY, "invalid announcement peer=%016llx name_bytes=%zu", (unsigned long long)peer_id, len); return; + } + int changed = !p; + if (!p) { + if (t->peer_count == LOCAL_TRANSPORT_PEERS_MAX) { + DEBUG_WARN(DEBUG_CATEGORY_DISCOVERY, "device table full peer=%016llx", (unsigned long long)peer_id); return; + } + p = &t->peers[t->peer_count++]; memset(p, 0, sizeof(*p)); p->peer.node_id = peer_id; + } + if (p->epoch != epoch) { p->epoch = epoch; p->count = 0; changed = 1; } + if (strlen(p->peer.name) != len || memcmp(p->peer.name, payload, len)) { + memcpy(p->peer.name, payload, len); p->peer.name[len] = 0; changed = 1; + } + size_t j; + for (j = 0; j < p->count; j++) + if (p->endpoints[j].index == s->interface.index && same_address(&p->endpoints[j].address, source)) break; + int new_endpoint = j == p->count; + if (new_endpoint) { + if (j == ENDPOINT_MAX) { + DEBUG_WARN(DEBUG_CATEGORY_DISCOVERY, "endpoint limit peer=%016llx", (unsigned long long)peer_id); return; + } + p->count++; + } + p->endpoints[j] = (struct local_endpoint){ .address = *source, .index = s->interface.index, .seen = now }; + if (changed) { + DEBUG_INFO(DEBUG_CATEGORY_DISCOVERY, "device discovered/updated peer=%016llx name=%s endpoints=%zu", + (unsigned long long)peer_id, p->peer.name, p->count); peers_changed(t); + } + if (new_endpoint) announce(s, source); + return; + } + if ((type != LOCAL_MESSAGE && type != LOCAL_ACK) || !request || !p || p->epoch != epoch || + destination != t->node_id || destination_epoch != t->epoch) { + DEBUG_WARN(DEBUG_CATEGORY_DISCOVERY, "local packet rejected type=%u peer=%016llx reason=type/identity/destination", + type, (unsigned long long)peer_id); return; + } + size_t j; + for (j = 0; j < p->count; j++) + if (p->endpoints[j].index == s->interface.index && same_address(&p->endpoints[j].address, source) && + now - p->endpoints[j].seen < EXPIRE_TB) break; + if (j == p->count) { DEBUG_WARN(DEBUG_CATEGORY_DISCOVERY, "unknown/expired message endpoint peer=%016llx", (unsigned long long)peer_id); return; } + if (type == LOCAL_ACK) { + if (len != 1 || payload[0] > LOCAL_DELIVERY_FORBIDDEN) { DEBUG_WARN(DEBUG_CATEGORY_DISCOVERY, "invalid ACK payload"); return; } + for (int i = 0; i < PENDING_MAX; i++) { + struct pending_entry* pending = &t->pending[i]; + if (pending->request == request && pending->peer_id == peer_id && pending->epoch == epoch) { delivery(t, pending, payload[0]); return; } + } + DEBUG_DEBUG(DEBUG_CATEGORY_DISCOVERY, "late ACK peer=%016llx request=%llu", (unsigned long long)peer_id, (unsigned long long)request); + return; + } + if (!len) { DEBUG_WARN(DEBUG_CATEGORY_DISCOVERY, "empty local message peer=%016llx", (unsigned long long)peer_id); return; } + uint8_t status = LOCAL_DELIVERY_FAILED; + for (int i = 0; i < SEEN_MAX; i++) { + const struct seen_entry* entry = &t->seen[i]; + if (entry->peer == peer_id && entry->epoch == epoch && entry->request == request && now - entry->time < EXPIRE_TB) { + status = LOCAL_DELIVERY_RECEIVED; break; + } + } + if (status != LOCAL_DELIVERY_RECEIVED && t->callbacks.received) { + int result = t->callbacks.received(t->arg, &p->peer, payload, len); + if (!result) { + status = LOCAL_DELIVERY_RECEIVED; + t->seen[t->seen_next++ % SEEN_MAX] = (struct seen_entry){ peer_id, epoch, request, now }; + DEBUG_INFO(DEBUG_CATEGORY_DISCOVERY, "message accepted peer=%016llx request=%llu bytes=%zu", + (unsigned long long)peer_id, (unsigned long long)request, len); + } else if (result == LOCAL_DELIVERY_FORBIDDEN) status = LOCAL_DELIVERY_FORBIDDEN; + } + if (status == LOCAL_DELIVERY_FAILED) + DEBUG_WARN(DEBUG_CATEGORY_DISCOVERY, "application rejected local message peer=%016llx", (unsigned long long)peer_id); + send_packet(s, source, LOCAL_ACK, peer_id, epoch, request, &status, 1); +} + +static void socket_read(socket_t fd, void* arg) { + struct local_socket* socket = arg; + struct local_transport* t = socket->owner; + /* Ограничение burst не позволяет локальному трафику вытеснить остальные callbacks ядра. */ + for (int i = 0; i < 32; i++) { + uint8_t data[LOCAL_PACKET_MAX]; + struct sockaddr_storage source; + uint32_t index; + ssize_t size = local_receive(fd, data, sizeof(data), &source, &index); + if (size == -1) { + int error = socket_get_error(); + if (error != ERR_WOULDBLOCK && error != ERR_AGAIN && error != ERR_INTR) + DEBUG_WARN(DEBUG_CATEGORY_DISCOVERY, "receive failed: %s", socket_strerror(error)); + return; + } + if (size == -2) { DEBUG_WARN(DEBUG_CATEGORY_DISCOVERY, "truncated local datagram dropped"); continue; } + /* Unicast-сокет уже привязан к адресу интерфейса. При обмене двух процессов на одном + * хосте ОС может указать loopback как ingress, но IPv6 scope нужен от локальной привязки. */ + if (socket->interface.index) index = socket->interface.index; + struct local_socket* s = find_socket(t, &source, index); + if (!s) { + DEBUG_DEBUG(DEBUG_CATEGORY_DISCOVERY, "non-local source ignored interface=%u source=%s", index, sockaddr_storage_to_str(&source).str); + continue; + } + if (source.ss_family == AF_INET6) ((struct sockaddr_in6*)&source)->sin6_scope_id = index; + receive_packet(s, &source, data, (size_t)size); + } +} + +static void close_socket(struct local_socket* s) { + if (s->handle) uasync_remove_socket(s->owner->ua, s->handle); + socket_close_wrapper(s->fd); u_free(s); +} + +static struct local_socket* open_socket(struct local_transport* t, const struct local_interface* interface) { + int family = interface->address.ss_family, enabled = 1; + struct local_socket* s = u_calloc(1, sizeof(*s)); + if (!s) { DEBUG_ERROR(DEBUG_CATEGORY_DISCOVERY, "socket record allocation failed"); return NULL; } + s->owner = t; s->interface = *interface; s->present = 1; s->fd = socket_create_udp(family); + if (s->fd == SOCKET_INVALID) goto error; + /* Общий порт используется только broadcast/multicast-приёмниками; unicast уходит на уникальные ephemeral-порты. */ + if ((!interface->index && socket_set_reuseaddr(s->fd, 1)) || socket_set_nonblocking(s->fd) || + local_receive_options(s->fd, family)) goto error; + if (family == AF_INET && setsockopt(s->fd, SOL_SOCKET, SO_BROADCAST, (const char*)&enabled, sizeof(enabled))) goto error; + if (family == AF_INET && interface->index) { + int ttl = 1; + if (setsockopt(s->fd, IPPROTO_IP, IP_TTL, (const char*)&ttl, sizeof(ttl))) goto error; +#ifdef IP_UNICAST_IF + /* bind(IP) фиксирует источник, но не egress при перекрывающихся подсетях/VPN. */ + uint32_t index = htonl(interface->index); + if (setsockopt(s->fd, IPPROTO_IP, IP_UNICAST_IF, (const char*)&index, sizeof(index))) goto error; +#endif + } + if (family == AF_INET6) { + int hops = 1; + if (setsockopt(s->fd, IPPROTO_IPV6, IPV6_V6ONLY, (const char*)&enabled, sizeof(enabled))) goto error; + if (interface->index && (setsockopt(s->fd, IPPROTO_IPV6, IPV6_MULTICAST_IF, + (const char*)&interface->index, sizeof(interface->index)) || + setsockopt(s->fd, IPPROTO_IPV6, IPV6_MULTICAST_HOPS, (const char*)&hops, sizeof(hops)))) goto error; + } + struct sockaddr_storage address = interface->address; + if (!interface->index) { + if (family == AF_INET) ((struct sockaddr_in*)&address)->sin_port = htons(LOCAL_TRANSPORT_PORT); + else ((struct sockaddr_in6*)&address)->sin6_port = htons(LOCAL_TRANSPORT_PORT); + } + socklen_t len = family == AF_INET ? sizeof(struct sockaddr_in) : sizeof(struct sockaddr_in6); + if (bind(s->fd, (struct sockaddr*)&address, len)) goto error; + s->handle = uasync_add_socket_t(t->ua, s->fd, socket_read, NULL, NULL, "local_transport", s); + if (!s->handle) goto error; + s->next = t->sockets; t->sockets = s; + DEBUG_INFO(DEBUG_CATEGORY_DISCOVERY, "local UDP socket opened family=%d interface=%u address=%s port=%u", + family, interface->index, sockaddr_storage_to_str(&address).str, socket_get_bound_port(s->fd)); + return s; +error: + DEBUG_ERROR(DEBUG_CATEGORY_DISCOVERY, "local UDP socket failed family=%d interface=%u: %s", + family, interface->index, socket_strerror(socket_get_error())); + if (s->fd != SOCKET_INVALID) socket_close_wrapper(s->fd); + u_free(s); return NULL; +} + +static int membership(struct local_socket* receiver, uint32_t index, int join) { + struct ipv6_mreq group = {0}; + inet_pton(AF_INET6, LOCAL_MULTICAST6, &group.ipv6mr_multiaddr); group.ipv6mr_interface = index; + if (setsockopt(receiver->fd, IPPROTO_IPV6, join ? IPV6_JOIN_GROUP : IPV6_LEAVE_GROUP, (const char*)&group, sizeof(group))) { + DEBUG_WARN(DEBUG_CATEGORY_DISCOVERY, "multicast %s failed interface=%u: %s", join ? "join" : "leave", index, + socket_strerror(socket_get_error())); + return -1; + } + DEBUG_DEBUG(DEBUG_CATEGORY_DISCOVERY, "multicast %s interface=%u group=%s", join ? "joined" : "left", index, LOCAL_MULTICAST6); + return 0; +} + +static void reconcile(struct local_transport* t) { + struct local_interface interfaces[LOCAL_INTERFACE_MAX]; + int count = local_interfaces(interfaces, LOCAL_INTERFACE_MAX); + if (count < 0) return; + struct local_socket* receiver6 = NULL; + int receiver4_found = 0; + for (struct local_socket* s = t->sockets; s; s = s->next) { + s->present = !s->interface.index; + if (!s->interface.index && s->interface.address.ss_family == AF_INET6) receiver6 = s; + if (!s->interface.index && s->interface.address.ss_family == AF_INET) receiver4_found = 1; + } + if (!receiver4_found) { + struct local_interface listener = {0}; listener.address.ss_family = AF_INET; + open_socket(t, &listener); + } + if (!receiver6) { + t->joined6_count = 0; + struct local_interface listener = {0}; listener.address.ss_family = AF_INET6; + receiver6 = open_socket(t, &listener); + } + for (int i = 0; i < count; i++) { + struct local_socket* found = NULL; + for (struct local_socket* s = t->sockets; s; s = s->next) + if (s->interface.index == interfaces[i].index && same_address(&s->interface.address, &interfaces[i].address)) { found = s; break; } + if (found) { + found->present = 1; found->interface = interfaces[i]; + continue; + } + open_socket(t, &interfaces[i]); + } + struct local_socket** link = &t->sockets; + while (*link) { + struct local_socket* s = *link; + if (s->present) { link = &s->next; continue; } + DEBUG_INFO(DEBUG_CATEGORY_DISCOVERY, "local UDP socket removed interface=%u address=%s", + s->interface.index, sockaddr_storage_to_str(&s->interface.address).str); + *link = s->next; close_socket(s); + } + if (!receiver6) return; + for (size_t i = 0; i < t->joined6_count;) { + int present = 0; + for (struct local_socket* s = t->sockets; s; s = s->next) + if (s->interface.index == t->joined6[i] && s->interface.address.ss_family == AF_INET6) present = 1; + if (present) { i++; continue; } + membership(receiver6, t->joined6[i], 0); t->joined6[i] = t->joined6[--t->joined6_count]; + } + for (struct local_socket* s = t->sockets; s; s = s->next) { + if (!s->interface.index || s->interface.address.ss_family != AF_INET6) continue; + size_t i; + for (i = 0; i < t->joined6_count; i++) if (t->joined6[i] == s->interface.index) break; + if (i == t->joined6_count && !membership(receiver6, s->interface.index, 1)) t->joined6[t->joined6_count++] = s->interface.index; + } +} + +static void try_send(struct local_transport* t, struct pending_entry* p) { + struct peer_entry* peer = find_peer(t, p->peer_id); + if (!peer || peer->epoch != p->epoch || !peer->count || p->attempts >= 3) { delivery(t, p, 0); return; } + p->attempts++; + int sent = 0; + for (size_t i = 0; i < peer->count; i++) { + struct local_endpoint* e = &peer->endpoints[i]; + struct local_socket* s = find_socket(t, &e->address, e->index); + if (s) { + /* Получатель узнаёт отправителя и без его broadcast-объявлений. */ + announce(s, &e->address); + if (!send_packet(s, &e->address, LOCAL_MESSAGE, p->peer_id, p->epoch, p->request, p->data, p->size)) sent = 1; + } + } + if (!sent) DEBUG_WARN(DEBUG_CATEGORY_DISCOVERY, "no usable endpoint peer=%016llx attempt=%u", + (unsigned long long)p->peer_id, p->attempts); +} + +static void tick(void* arg) { + struct local_transport* t = arg; + t->timer = NULL; + uint64_t now = get_time_tb(); + if (now >= t->next_announce) { + reconcile(t); t->next_announce = now + ANNOUNCE_TB; + announce_all(t); + } + expire(t, now); + for (int i = 0; i < PENDING_MAX; i++) if (t->pending[i].request) try_send(t, &t->pending[i]); + t->timer = uasync_set_timeout(t->ua, TICK_TB, t, tick, "local_transport"); + if (!t->timer) { + DEBUG_ERROR(DEBUG_CATEGORY_DISCOVERY, "local transport timer allocation failed; disabling discovery"); + local_transport_set_active(t, 0); + } +} + +struct local_transport* local_transport_create(struct UASYNC* ua, uint64_t node_id, const char* name, + const struct local_transport_callbacks* callbacks, void* arg) { + if (!ua || !node_id || !name || !callbacks || !local_name_valid((const uint8_t*)name, strlen(name))) { + DEBUG_ERROR(DEBUG_CATEGORY_DISCOVERY, "invalid local transport configuration"); return NULL; + } + struct local_transport* t = u_calloc(1, sizeof(*t)); + if (!t) { DEBUG_ERROR(DEBUG_CATEGORY_DISCOVERY, "local transport allocation failed"); return NULL; } + if (RAND_bytes((uint8_t*)&t->epoch, sizeof(t->epoch)) != 1 || !t->epoch) { + DEBUG_ERROR(DEBUG_CATEGORY_DISCOVERY, "local transport epoch generation failed"); u_free(t); return NULL; + } + t->ua = ua; t->node_id = node_id; t->callbacks = *callbacks; t->arg = arg; + t->broadcasts = 1; + memcpy(t->name, name, strlen(name) + 1); + return t; +} + +int local_transport_set_active(struct local_transport* t, int active) { + if (!t) { DEBUG_ERROR(DEBUG_CATEGORY_DISCOVERY, "activity update without transport"); return -1; } + if (t->active == !!active) return 0; + t->active = !!active; + DEBUG_INFO(DEBUG_CATEGORY_DISCOVERY, "local discovery %s node=%016llx port=%u", + active ? "active" : "paused", (unsigned long long)t->node_id, LOCAL_TRANSPORT_PORT); + if (t->callbacks.activity_changed) t->callbacks.activity_changed(t->arg, t->active); + if (active) { t->next_announce = 0; tick(t); return t->active ? 0 : -1; } + if (t->timer) { uasync_cancel_timeout(t->ua, t->timer); t->timer = NULL; } + while (t->sockets) { struct local_socket* s = t->sockets; t->sockets = s->next; close_socket(s); } + t->joined6_count = 0; + for (int i = 0; i < PENDING_MAX; i++) if (t->pending[i].request) delivery(t, &t->pending[i], 0); + if (t->peer_count) { t->peer_count = 0; peers_changed(t); } + return 0; +} + +void local_transport_destroy(struct local_transport* t) { + if (!t) return; + local_transport_set_active(t, 0); u_free(t); +} + +int local_transport_set_broadcasts(struct local_transport* t, int enabled) { + if (!t) { DEBUG_ERROR(DEBUG_CATEGORY_DISCOVERY, "broadcast update without transport"); return -1; } + if (t->broadcasts == !!enabled) return 0; + t->broadcasts = !!enabled; + DEBUG_INFO(DEBUG_CATEGORY_DISCOVERY, "local broadcast announcements %s node=%016llx", + enabled ? "enabled" : "disabled", (unsigned long long)t->node_id); + if (enabled) announce_all(t); + return 0; +} + +int local_transport_set_name(struct local_transport* t, const char* name) { + if (!t || !name || !local_name_valid((const uint8_t*)name, strlen(name))) { + DEBUG_WARN(DEBUG_CATEGORY_DISCOVERY, "invalid device name"); return -1; + } + if (!strcmp(t->name, name)) return 0; + memcpy(t->name, name, strlen(name) + 1); + announce_all(t); + return 0; +} + +size_t local_transport_peers(struct local_transport* t, struct local_peer* out, size_t capacity) { + if (!t || !out) return 0; + expire(t, get_time_tb()); + size_t count = t->peer_count < capacity ? t->peer_count : capacity; + for (size_t i = 0; i < count; i++) out[i] = t->peers[i].peer; + return count; +} + +int local_transport_send(struct local_transport* t, uint64_t peer_id, uint64_t request_id, const void* data, size_t len) { + if (!t || !t->active || !request_id || !data || !len || len > LOCAL_TRANSPORT_DATA_MAX) { + DEBUG_WARN(DEBUG_CATEGORY_DISCOVERY, "invalid local send peer=%016llx request=%llu bytes=%zu", + (unsigned long long)peer_id, (unsigned long long)request_id, len); return -1; + } + expire(t, get_time_tb()); + struct peer_entry* peer = find_peer(t, peer_id); + if (!peer) { DEBUG_WARN(DEBUG_CATEGORY_DISCOVERY, "local device unavailable peer=%016llx", (unsigned long long)peer_id); return -1; } + struct pending_entry* pending = NULL; + for (int i = 0; i < PENDING_MAX; i++) { + if (t->pending[i].request == request_id) { + DEBUG_WARN(DEBUG_CATEGORY_DISCOVERY, "duplicate local request=%llu", (unsigned long long)request_id); return -1; + } + if (!t->pending[i].request) pending = &t->pending[i]; + } + if (!pending) { DEBUG_WARN(DEBUG_CATEGORY_DISCOVERY, "local delivery queue full"); return -1; } + pending->request = request_id; pending->peer_id = peer_id; pending->epoch = peer->epoch; + pending->size = len; pending->attempts = 0; memcpy(pending->data, data, len); + try_send(t, pending); + return 0; +} diff --git a/src/local_transport/local_transport.h b/src/local_transport/local_transport.h new file mode 100644 index 00000000..779d6fd2 --- /dev/null +++ b/src/local_transport/local_transport.h @@ -0,0 +1,63 @@ +/* Открытый UDP-транспорт локальной сети. Не создаёт ETCP-сокетов и не подключает пиры. + * Все вызовы и callbacks — в потоке ua. destroy/set_active — вне callbacks транспорта. + * Данные callback заимствованы до возврата; received возвращает 0 после принятия сообщения. + * delivery подтверждает приём сообщения приложением, а не выполнение его содержимого. */ +#ifndef LOCAL_TRANSPORT_H +#define LOCAL_TRANSPORT_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define LOCAL_TRANSPORT_PORT 45731 +#define LOCAL_TRANSPORT_NAME_MAX 128 +#define LOCAL_TRANSPORT_DATA_MAX 1100 +#define LOCAL_TRANSPORT_PEERS_MAX 128 + +struct UASYNC; +struct local_transport; +/* Один байт в ACK и результате доставки; FORBIDDEN означает явный запрет приёма у получателя. */ +enum local_delivery_status { + LOCAL_DELIVERY_FAILED = 0, + LOCAL_DELIVERY_RECEIVED = 1, + LOCAL_DELIVERY_FORBIDDEN = 2 +}; +struct local_peer { + uint64_t node_id; // идентификатор из объявления, не подтверждает подлинность + char name[LOCAL_TRANSPORT_NAME_MAX]; // UTF-8 ник, завершается NUL +}; +struct local_transport_callbacks { + void (*peers_changed)(void* arg); // таблица изменилась; получить копию через peers() + int (*received)(void* arg, const struct local_peer* peer, const uint8_t* data, size_t len); // 0 = принято, FORBIDDEN = запрет, -1 = ошибка + void (*delivery)(void* arg, uint64_t request_id, uint64_t peer_id, int status); // один результат: enum local_delivery_status + void (*activity_changed)(void* arg, int active); // сокеты включаются/выключаются, в том числе при ошибке таймера +}; + +/* Создать выключенный транспорт: ua заимствован, name/callbacks копируются, arg передаётся callbacks. + * node_id != 0, name — 1..127 байтов UTF-8. Возвращает владельческий handle либо NULL с ошибкой в логе. */ +struct local_transport* local_transport_create(struct UASYNC* ua, uint64_t node_id, const char* name, + const struct local_transport_callbacks* callbacks, void* arg); +/* Закрыть сокеты, отменить таймеры/доставки и освободить handle. NULL допустим. */ +void local_transport_destroy(struct local_transport* transport); +/* ACTIVE включает сокеты и таймер; выключение отменяет доставки, очищает таблицу и не будит standby. + * Повтор допустим. Возвращает 0/-1; отсутствие локальной сети не является ошибкой активации. */ +int local_transport_set_active(struct local_transport* transport, int active); +/* Разрешить периодические broadcast/multicast-объявления (по умолчанию включены). + * Unicast, приём и таблица устройств сохраняются; включение сразу отправляет объявление. Возвращает 0/-1. */ +int local_transport_set_broadcasts(struct local_transport* transport, int enabled); +/* Обновить ник (те же ограничения, что у create), сразу отправить объявление. Возвращает 0/-1. */ +int local_transport_set_name(struct local_transport* transport, const char* name); +/* Скопировать не более capacity живых устройств в out; возвращает количество, 0 для NULL transport/out. */ +size_t local_transport_peers(struct local_transport* transport, struct local_peer* out, size_t capacity); +/* Скопировать 1..DATA_MAX байтов data для peer_id из текущей таблицы. request_id != 0, уникален среди pending. + * 0 = запрос принят, -1 = callback не будет. До 3 попыток на всех endpoints; дубли получатель не выдаёт приложению. */ +int local_transport_send(struct local_transport* transport, uint64_t peer_id, uint64_t request_id, + const void* data, size_t len); + +#ifdef __cplusplus +} +#endif +#endif diff --git a/src/local_transport/platform.c b/src/local_transport/platform.c new file mode 100644 index 00000000..3272c963 --- /dev/null +++ b/src/local_transport/platform.c @@ -0,0 +1,178 @@ +#ifndef _GNU_SOURCE +#define _GNU_SOURCE +#endif +#include "platform.h" +#include "local_transport.h" +#include "../../lib/debug_config.h" +#include "../../lib/mem.h" +#include + +#ifdef _WIN32 +#include +#include +#else +#include +#include +#ifdef __FreeBSD__ +#include +#endif +#endif + +static int add_interface(struct local_interface* out, size_t capacity, int count, uint32_t index, + const struct sockaddr* address, uint32_t mask) { + if (!index || !address) return count; + int family = address->sa_family; + if (family != AF_INET && family != AF_INET6) return count; + if (family == AF_INET6 && !IN6_IS_ADDR_LINKLOCAL(&((const struct sockaddr_in6*)address)->sin6_addr)) return count; + if (family == AF_INET && (!mask || mask == UINT32_MAX)) return count; + struct local_interface entry = {0}; + entry.index = index; entry.mask4 = mask; + if (family == AF_INET) { + struct sockaddr_in* addr = (struct sockaddr_in*)&entry.address; + *addr = *(const struct sockaddr_in*)address; addr->sin_port = 0; + struct sockaddr_in* dest = (struct sockaddr_in*)&entry.broadcast; + dest->sin_family = AF_INET; dest->sin_port = htons(LOCAL_TRANSPORT_PORT); + dest->sin_addr.s_addr = addr->sin_addr.s_addr | ~mask; + } else { + struct sockaddr_in6* addr = (struct sockaddr_in6*)&entry.address; + *addr = *(const struct sockaddr_in6*)address; addr->sin6_port = 0; addr->sin6_scope_id = index; + struct sockaddr_in6* dest = (struct sockaddr_in6*)&entry.broadcast; + dest->sin6_family = AF_INET6; dest->sin6_port = htons(LOCAL_TRANSPORT_PORT); dest->sin6_scope_id = index; + inet_pton(AF_INET6, LOCAL_MULTICAST6, &dest->sin6_addr); + } + for (int i = 0; i < count; i++) { + if (out[i].index != index || out[i].address.ss_family != family) continue; + if (family == AF_INET6 || ((struct sockaddr_in*)&out[i].address)->sin_addr.s_addr == + ((struct sockaddr_in*)&entry.address)->sin_addr.s_addr) return count; + } + if ((size_t)count == capacity) { + DEBUG_WARN(DEBUG_CATEGORY_DISCOVERY, "interface limit reached count=%d index=%u family=%d", count, index, family); + return count; + } + out[count] = entry; + return count + 1; +} + +int local_interfaces(struct local_interface* out, size_t capacity) { + int count = 0; +#ifdef _WIN32 + ULONG size = 16384, result; + IP_ADAPTER_ADDRESSES* adapters = NULL; + for (int attempt = 0; attempt < 3; attempt++) { + u_free(adapters); adapters = u_malloc(size); + if (!adapters) { DEBUG_ERROR(DEBUG_CATEGORY_DISCOVERY, "adapter allocation failed bytes=%lu", size); return -1; } + result = GetAdaptersAddresses(AF_UNSPEC, GAA_FLAG_SKIP_ANYCAST | GAA_FLAG_SKIP_MULTICAST | + GAA_FLAG_SKIP_DNS_SERVER, NULL, adapters, &size); + if (result != ERROR_BUFFER_OVERFLOW) break; + } + if (result != NO_ERROR && result != ERROR_NO_DATA) { + DEBUG_ERROR(DEBUG_CATEGORY_DISCOVERY, "GetAdaptersAddresses failed error=%lu", result); u_free(adapters); return -1; + } + for (IP_ADAPTER_ADDRESSES* a = result == NO_ERROR ? adapters : NULL; a; a = a->Next) { + if (a->OperStatus != IfOperStatusUp || a->IfType == IF_TYPE_SOFTWARE_LOOPBACK || a->IfType == IF_TYPE_PPP || + a->IfType == IF_TYPE_TUNNEL) continue; + for (IP_ADAPTER_UNICAST_ADDRESS* p = a->FirstUnicastAddress; p; p = p->Next) { + struct sockaddr* address = p->Address.lpSockaddr; + uint32_t mask = p->OnLinkPrefixLength && p->OnLinkPrefixLength < 32 ? + htonl(UINT32_MAX << (32 - p->OnLinkPrefixLength)) : 0; + count = add_interface(out, capacity, count, address->sa_family == AF_INET ? a->IfIndex : a->Ipv6IfIndex, address, mask); + } + } + u_free(adapters); +#else + struct ifaddrs* addresses = NULL; + if (getifaddrs(&addresses)) { + DEBUG_ERROR(DEBUG_CATEGORY_DISCOVERY, "getifaddrs failed: %s", strerror(errno)); return -1; + } + for (struct ifaddrs* a = addresses; a; a = a->ifa_next) { + if (!a->ifa_addr || !(a->ifa_flags & IFF_UP) || (a->ifa_flags & (IFF_LOOPBACK | IFF_POINTOPOINT))) continue; + int family = a->ifa_addr->sa_family; + if (family == AF_INET && !(a->ifa_flags & IFF_BROADCAST)) continue; + if (family == AF_INET6 && !(a->ifa_flags & IFF_MULTICAST)) continue; + uint32_t mask = family == AF_INET && a->ifa_netmask ? ((struct sockaddr_in*)a->ifa_netmask)->sin_addr.s_addr : 0; + count = add_interface(out, capacity, count, if_nametoindex(a->ifa_name), a->ifa_addr, mask); + } + freeifaddrs(addresses); +#endif + return count; +} + +int local_receive_options(socket_t fd, int family) { + int enabled = 1, option, level; + if (family == AF_INET6) { + level = IPPROTO_IPV6; +#ifdef _WIN32 + option = IPV6_PKTINFO; +#else + option = IPV6_RECVPKTINFO; +#endif + } + else { + level = IPPROTO_IP; +#ifdef __FreeBSD__ + option = IP_RECVIF; +#else + option = IP_PKTINFO; +#endif + } + if (!setsockopt(fd, level, option, (const char*)&enabled, sizeof(enabled))) return 0; + DEBUG_ERROR(DEBUG_CATEGORY_DISCOVERY, "receive interface option failed family=%d: %s", + family, socket_strerror(socket_get_error())); + return -1; +} + +ssize_t local_receive(socket_t fd, void* data, size_t capacity, struct sockaddr_storage* source, uint32_t* index) { + *index = 0; memset(source, 0, sizeof(*source)); +#ifdef _WIN32 + GUID guid = WSAID_WSARECVMSG; + LPFN_WSARECVMSG receive = NULL; + DWORD bytes = 0; + if (WSAIoctl(fd, SIO_GET_EXTENSION_FUNCTION_POINTER, &guid, sizeof(guid), &receive, sizeof(receive), &bytes, NULL, NULL)) return -1; + WSABUF buffer = { (ULONG)capacity, data }; + union { WSACMSGHDR alignment; char bytes[256]; } control; + WSAMSG message = {0}; + message.name = (struct sockaddr*)source; message.namelen = sizeof(*source); + message.lpBuffers = &buffer; message.dwBufferCount = 1; + message.Control.buf = control.bytes; message.Control.len = sizeof(control.bytes); + if (receive(fd, &message, &bytes, NULL, NULL)) return socket_get_error() == WSAEMSGSIZE ? -2 : -1; + if (message.dwFlags & (MSG_TRUNC | MSG_CTRUNC)) return -2; + for (WSACMSGHDR* c = WSA_CMSG_FIRSTHDR(&message); c; c = WSA_CMSG_NXTHDR(&message, c)) { + if (c->cmsg_level == IPPROTO_IP && c->cmsg_type == IP_PKTINFO) + *index = ((IN_PKTINFO*)WSA_CMSG_DATA(c))->ipi_ifindex; + if (c->cmsg_level == IPPROTO_IPV6 && c->cmsg_type == IPV6_PKTINFO) + *index = ((IN6_PKTINFO*)WSA_CMSG_DATA(c))->ipi6_ifindex; + } + return (ssize_t)bytes; +#else + struct iovec buffer = { data, capacity }; + union { struct cmsghdr alignment; char bytes[256]; } control; + struct msghdr message = {0}; + message.msg_name = source; message.msg_namelen = sizeof(*source); + message.msg_iov = &buffer; message.msg_iovlen = 1; + message.msg_control = control.bytes; message.msg_controllen = sizeof(control.bytes); + ssize_t size = recvmsg(fd, &message, 0); + if (size < 0) return -1; + if (message.msg_flags & (MSG_TRUNC | MSG_CTRUNC)) return -2; + for (struct cmsghdr* c = CMSG_FIRSTHDR(&message); c; c = CMSG_NXTHDR(&message, c)) { +#ifdef __FreeBSD__ + if (c->cmsg_level == IPPROTO_IP && c->cmsg_type == IP_RECVIF) + *index = ((struct sockaddr_dl*)CMSG_DATA(c))->sdl_index; +#else + if (c->cmsg_level == IPPROTO_IP && c->cmsg_type == IP_PKTINFO) + *index = ((struct in_pktinfo*)CMSG_DATA(c))->ipi_ifindex; +#endif + if (c->cmsg_level == IPPROTO_IPV6 && c->cmsg_type == IPV6_PKTINFO) + *index = ((struct in6_pktinfo*)CMSG_DATA(c))->ipi6_ifindex; + } + return size; +#endif +} + +int local_on_interface(const struct local_interface* interface, const struct sockaddr_storage* source) { + if (source->ss_family != interface->address.ss_family || !ss_get_port(source)) return 0; + if (source->ss_family == AF_INET6) return IN6_IS_ADDR_LINKLOCAL(&((const struct sockaddr_in6*)source)->sin6_addr); + uint32_t address = ((const struct sockaddr_in*)source)->sin_addr.s_addr; + uint32_t own = ((const struct sockaddr_in*)&interface->address)->sin_addr.s_addr; + return address && address != UINT32_MAX && (address & interface->mask4) == (own & interface->mask4) && + address != (own | ~interface->mask4) && address != (own & interface->mask4); +} diff --git a/src/local_transport/platform.h b/src/local_transport/platform.h new file mode 100644 index 00000000..7e9aa52b --- /dev/null +++ b/src/local_transport/platform.h @@ -0,0 +1,27 @@ +/* Внутренний API ОС: снимок локальных адресов и метаданные входящих UDP-пакетов. */ +#ifndef LOCAL_TRANSPORT_PLATFORM_H +#define LOCAL_TRANSPORT_PLATFORM_H + +#include "../../lib/platform_compat.h" +#include "../../lib/socket_compat.h" + +#define LOCAL_INTERFACE_MAX 64 +#define LOCAL_MULTICAST6 "ff12::7574:756e" + +struct local_interface { + uint32_t index; // индекс интерфейса ОС; задаёт IPv6 scope + struct sockaddr_storage address; // локальный unicast IP, порт 0 для отдельного сокета отправки + struct sockaddr_storage broadcast; // broadcast IPv4 или multicast IPv6 с фиксированным discovery-портом + uint32_t mask4; // IPv4 netmask в network byte order; 0 для IPv6 +}; + +/* Снимок UP-интерфейсов: IPv4 broadcast и IPv6 link-local, без loopback/P2P. count (<= capacity) либо -1. */ +int local_interfaces(struct local_interface* out, size_t capacity); +/* Включить получение interface index через ancillary data для family. Возвращает 0/-1. */ +int local_receive_options(socket_t fd, int family); +/* -2 = пакет усечён, -1 = ошибка ОС. index — входящий интерфейс (в том числе для IPv4 broadcast). */ +ssize_t local_receive(socket_t fd, void* data, size_t capacity, struct sockaddr_storage* source, uint32_t* index); +/* Проверить адрес/порт источника: IPv4 той же подсети либо IPv6 link-local; 1 = подходит. */ +int local_on_interface(const struct local_interface* interface, const struct sockaddr_storage* source); + +#endif diff --git a/src/local_transport/protocol.h b/src/local_transport/protocol.h new file mode 100644 index 00000000..04bdb14b --- /dev/null +++ b/src/local_transport/protocol.h @@ -0,0 +1,41 @@ +/* Внутренний wire-формат: magic(16), version/type(1+1), length(2), пять uint64 BE. + * source/epoch, destination/epoch, request. Объявление не имеет destination/request. */ +#ifndef LOCAL_TRANSPORT_PROTOCOL_H +#define LOCAL_TRANSPORT_PROTOCOL_H + +#include "local_transport.h" +#include + +#define LOCAL_MAGIC "uTunChatLocalUDP" +#define LOCAL_HEADER_SIZE 60 +#define LOCAL_PACKET_MAX (LOCAL_HEADER_SIZE + LOCAL_TRANSPORT_DATA_MAX) +#define LOCAL_ANNOUNCE 1 +#define LOCAL_MESSAGE 2 +#define LOCAL_ACK 3 + +static inline void local_put64(uint8_t* p, uint64_t value) { + for (int i = 7; i >= 0; i--) { p[i] = (uint8_t)value; value >>= 8; } +} +static inline uint64_t local_get64(const uint8_t* p) { + uint64_t value = 0; + for (int i = 0; i < 8; i++) value = value << 8 | p[i]; + return value; +} + +/* Непустой UTF-8 ник без NUL, управляющих ASCII, overlong, surrogate и кодов > U+10FFFF. */ +static inline int local_name_valid(const uint8_t* p, size_t len) { + if (!len || len >= LOCAL_TRANSPORT_NAME_MAX) return 0; + for (size_t i = 0; i < len;) { + unsigned c = p[i++], value, count, minimum; + if (c < 0x80) { if (c < 0x20 || c == 0x7f) return 0; continue; } + if (c >= 0xc2 && c <= 0xdf) { value = c & 31; count = 1; minimum = 0x80; } + else if (c >= 0xe0 && c <= 0xef) { value = c & 15; count = 2; minimum = 0x800; } + else if (c >= 0xf0 && c <= 0xf4) { value = c & 7; count = 3; minimum = 0x10000; } + else return 0; + if (count > len - i) return 0; + while (count--) { c = p[i++]; if ((c & 0xc0) != 0x80) return 0; value = value << 6 | (c & 63); } + if (value < minimum || value > 0x10ffff || (value >= 0xd800 && value <= 0xdfff)) return 0; + } + return 1; +} +#endif diff --git a/src/utun_instance.c b/src/utun_instance.c index 4efc29b8..0250287e 100644 --- a/src/utun_instance.c +++ b/src/utun_instance.c @@ -1,5 +1,6 @@ // utun_instance.c - Root instance implementation #include "utun_instance.h" +#include "chat/chat_local.h" #include "stream/stream.h" #include "etcp_router.h" #include "config_parser.h" @@ -840,6 +841,7 @@ int chat_service_start(struct UTUN_INSTANCE* instance) { } + if (chat_local_start(instance) < 0) goto fail; instance->chat_started = 1; DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "chat service started"); return 0; fail: @@ -850,6 +852,7 @@ fail: void chat_service_stop(struct UTUN_INSTANCE* instance) { if (!instance) return; instance->chat_started = 0; + chat_local_stop(instance); chat_headless_control_destroy(instance); call_headless_destroy(instance); radio_headless_destroy(instance); stream_service_destroy(instance); @@ -1106,7 +1109,7 @@ struct UTUN_INSTANCE *utun_instance_reload(struct UTUN_INSTANCE *instance, struc void utun_add_activity_cbk(struct UTUN_INSTANCE* instance, utun_activity_cbk_fn fn, void* arg) { if (!instance || !fn) return; struct utun_activity_cbk_entry* e = u_malloc(sizeof(struct utun_activity_cbk_entry)); - if (!e) return; + if (!e) { DEBUG_ERROR(DEBUG_CATEGORY_SYS, "client activity subscription allocation failed"); return; } e->fn = fn; e->arg = arg; e->next = instance->activity_cbks; instance->activity_cbks = e; } diff --git a/src/utun_instance.h b/src/utun_instance.h index 62c11579..dca94fe1 100644 --- a/src/utun_instance.h +++ b/src/utun_instance.h @@ -229,6 +229,7 @@ struct UTUN_INSTANCE { // Контексты чат-сервиса; освобождаются при chat_service_stop. struct chat_core_ctx* chat_core; // каналы, сообщения и доступ к общей БД + struct local_transport* local_transport; // отдельный открытый LAN UDP, принадлежит чат-сервису struct chat_sync* chat_sync; // join и синхронизация каналов struct join_key_entry* join_keys; // зарегистрированные ключи приглашений struct chat_join_registration* join_registrations; // ожидания KEY_REGISTER_ACK diff --git a/tests/Makefile.am b/tests/Makefile.am index 078d7228..3ea17780 100644 --- a/tests/Makefile.am +++ b/tests/Makefile.am @@ -1,7 +1,7 @@ # Tests Makefile.am for utun - all tests with new ll_queue library EXTRA_DIST = fixtures/reality_openssl_hello.hex data/silero_speech.pcm data/silero_speech.txt test_net.h nat_fixture.h \ - run_tests.py test_run_tests.py test_build_script.py stream_netns/topology.py stream_netns/network.py \ + run_tests.py test_run_tests.py test_build_script.py local_transport_ipv6.sh stream_netns/topology.py stream_netns/network.py \ stream_netns/probe.py stream_netns/run.py stream_netns/scenario.py stream_netns/README.md stream_netns/TESTED.md \ stream_netns/test_isolation.py stream_netns/nat_scenario.py stream_netns/stun_server.py stream_netns/stun_scenario.py @@ -246,6 +246,10 @@ endif # Вариант общего ядра с Android duty-cycle, без JNI и устройства. if OS_LINUX +check_PROGRAMS += test_local_transport +test_local_transport_SOURCES = test_local_transport.c $(test_clock_sources) +test_local_transport_LDADD = $(LIBUTUN) $(COMMON_LIBS) +test_local_transport_LDFLAGS = $(test_clock_ldflags) -Wl,--wrap=local_interfaces -Wl,--wrap=socket_sendto test_stcp_LDFLAGS = -Wl,--wrap=send -Wl,--wrap=recv -Wl,--wrap=random_bytes -Wl,--wrap=u_malloc_impl -Wl,--wrap=u_calloc_impl standby_test_sources = ../tools/chatgui-android/libutun_lite/standby.c ../src/utun_instance.c ../src/transport_layer/etcp_keepalive.c standby_test_cppflags = $(AM_CPPFLAGS) -I$(top_srcdir)/src/chat -I$(top_srcdir)/tools/chatgui-android/libutun_lite -DUTUN_HAVE_STANDBY diff --git a/tests/local_transport_ipv6.sh b/tests/local_transport_ipv6.sh new file mode 100644 index 00000000..d1538b7c --- /dev/null +++ b/tests/local_transport_ipv6.sh @@ -0,0 +1,15 @@ +#!/bin/sh +# Изолированная IPv6-only LAN без default route. Namespace исчезает вместе с тестом. +set -eu +if [ "${1:-}" != "--inside" ]; then + exec unshare --user --map-root-user --net --fork -- sh "$0" --inside +fi +ip link set lo up +ip link add local-a type veth peer name local-b +ip link set local-a addrgenmode none +ip link set local-b addrgenmode none +ip link set local-a up +ip link set local-b up +ip -6 addr add fe80::1/64 dev local-a nodad +ip -6 addr add fe80::2/64 dev local-b nodad +exec "$(dirname "$0")/test_local_transport" --ipv6 diff --git a/tests/test_local_transport.c b/tests/test_local_transport.c new file mode 100644 index 00000000..19010a97 --- /dev/null +++ b/tests/test_local_transport.c @@ -0,0 +1,278 @@ +/* Настоящий UDP на loopback, ускоренные монотонные часы и потеря ACK. + * Подменяется только снимок интерфейсов: тест не объявляет тестовые узлы в пользовательской LAN. */ +#include "../src/local_transport/local_transport.h" +#include "../src/local_transport/platform.h" +#include "../src/local_transport/protocol.h" +#include "../src/chat/chat_local.h" +#include "../src/chat/invite_link.h" +#include "../src/chat/chat_setting.h" +#include "../src/utun_instance.h" +#include "../lib/u_async.h" +#include "../lib/debug_config.h" +#include "../lib/mem.h" +#include "test_clock.h" +#include +#include +#include + +#define REQUIRE(x) do { if (!(x)) { DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "line=%d failed: %s", __LINE__, #x); exit(1); } } while (0) + +static uint32_t loopback_index; +static uint64_t suppress_announcements; +static int drop_ack, announce_count; +static int announcements[5]; +static int broadcasts[5], forbidden_acks; +static int ipv6_only, ipv6_messages; +static uint8_t announcement[LOCAL_PACKET_MAX]; +static size_t announcement_size; +static struct sockaddr_storage endpoint_a; + +int __real_local_interfaces(struct local_interface*, size_t); +int __wrap_local_interfaces(struct local_interface* interfaces, size_t capacity) { + if (ipv6_only) { + int count = __real_local_interfaces(interfaces, capacity), used = 0; + for (int i = 0; i < count; i++) if (interfaces[i].address.ss_family == AF_INET6) interfaces[used++] = interfaces[i]; + return count < 0 ? count : used; + } + REQUIRE(capacity > 0); + memset(interfaces, 0, sizeof(*interfaces)); + interfaces[0].index = loopback_index; interfaces[0].mask4 = htonl(0xff000000); + struct sockaddr_in* source = (struct sockaddr_in*)&interfaces[0].address; + source->sin_family = AF_INET; inet_pton(AF_INET, "127.0.0.1", &source->sin_addr); + struct sockaddr_in* destination = (struct sockaddr_in*)&interfaces[0].broadcast; + destination->sin_family = AF_INET; destination->sin_port = htons(LOCAL_TRANSPORT_PORT); + inet_pton(AF_INET, "127.255.255.255", &destination->sin_addr); + return 1; +} + +ssize_t __real_socket_sendto(socket_t, const void*, size_t, const struct sockaddr*, socklen_t); +ssize_t __wrap_socket_sendto(socket_t fd, const void* buffer, size_t len, const struct sockaddr* destination, socklen_t size) { + const uint8_t* data = buffer; + if (len >= LOCAL_HEADER_SIZE && !memcmp(data, LOCAL_MAGIC, 16)) { + uint64_t sender = local_get64(data + 20); + if (data[17] == LOCAL_MESSAGE && destination->sa_family == AF_INET6) ipv6_messages++; + if (data[17] == LOCAL_ANNOUNCE) { + announce_count++; + if (sender < 5) announcements[sender]++; + unsigned port = destination->sa_family == AF_INET ? ntohs(((const struct sockaddr_in*)destination)->sin_port) : + ntohs(((const struct sockaddr_in6*)destination)->sin6_port); + if (sender < 5 && port == LOCAL_TRANSPORT_PORT) broadcasts[sender]++; + if (sender == 1) { + memcpy(announcement, data, len); announcement_size = len; + socklen_t address_len = sizeof(endpoint_a); + REQUIRE(!getsockname(fd, (struct sockaddr*)&endpoint_a, &address_len)); + } + if (sender == suppress_announcements) return (ssize_t)len; + } + if (data[17] == LOCAL_ACK && len == LOCAL_HEADER_SIZE + 1 && data[LOCAL_HEADER_SIZE] == LOCAL_DELIVERY_FORBIDDEN) + forbidden_acks++; + if (data[17] == LOCAL_ACK && drop_ack) { if (drop_ack > 0) drop_ack--; return (ssize_t)len; } + } + return __real_socket_sendto(fd, buffer, len, destination, size); +} + +struct result { int changes, received, delivered, failed, active; uint64_t request; }; +static void changed(void* arg) { ((struct result*)arg)->changes++; } +static int received(void* arg, const struct local_peer* peer, const uint8_t* data, size_t len) { + struct result* r = arg; + REQUIRE(peer->node_id && peer->name[0]); + REQUIRE(len == 10 && !memcmp(data, "invitation", len)); r->received++; + return 0; +} +static void delivered(void* arg, uint64_t request, uint64_t peer, int success) { + struct result* r = arg; + REQUIRE(peer && request); r->request = request; + if (success) r->delivered++; else r->failed++; +} +static void activity(void* arg, int active) { ((struct result*)arg)->active = active; } + +static size_t peers(struct local_transport* transport, uint64_t id, const char* name) { + struct local_peer list[LOCAL_TRANSPORT_PEERS_MAX]; + size_t count = local_transport_peers(transport, list, LOCAL_TRANSPORT_PEERS_MAX); + if (id) { + int found = 0; + for (size_t i = 0; i < count; i++) if (list[i].node_id == id) { REQUIRE(!strcmp(list[i].name, name)); found = 1; } + REQUIRE(found); + } + return count; +} + +static void raw_send(socket_t fd, struct UASYNC* ua, const void* data, size_t len) { + REQUIRE(__real_socket_sendto(fd, data, len, (struct sockaddr*)&endpoint_a, sizeof(struct sockaddr_in)) == (ssize_t)len); + test_clock_run_for(ua, 1000); +} + +struct chat_result { int invites, delivery, success; uint64_t request; char link[LOCAL_TRANSPORT_DATA_MAX + 1]; }; +static void chat_event(struct UTUN_INSTANCE* inst, int type, const uint8_t* data, int len) { + struct chat_result* result = inst->test_user_ptr; + if (type == CHAT_EVT_LOCAL_INVITE) { + REQUIRE(len > 9 + data[8]); result->invites++; + size_t size = (size_t)len - 9 - data[8]; + memcpy(result->link, data + 9 + data[8], size); result->link[size] = 0; + } + if (type == CHAT_EVT_LOCAL_DELIVERY) { + REQUIRE(len == 17); result->delivery++; result->success = data[16]; result->request = local_get64(data); + } +} + +static void send_invite(struct UTUN_INSTANCE* inst, uint64_t request, const char* link) { + struct chat_local_send_request* req = u_calloc(1, sizeof(*req)); REQUIRE(req); + req->inst = inst; req->peer_id = 4; req->request_id = request; strcpy(req->link, link); + chat_local_send_trampoline(req); +} + +static void chat_adapter(struct UASYNC* ua) { + struct chat_result rc = {0}, rd = {0}; + struct UTUN_INSTANCE c = { .ua = ua, .node_id = 3, .name = "Desktop", .client_type = CLIENT_TYPE_DESKTOP, + .chat_event_handler = chat_event, .test_user_ptr = &rc }; + struct UTUN_INSTANCE d = { .ua = ua, .node_id = 4, .name = "Phone", .client_type = CLIENT_TYPE_MOBILE, + .client_activity = CLIENT_ACTIVITY_ACTIVE, + .chat_event_handler = chat_event, .test_user_ptr = &rd }; + REQUIRE(chat_setting_get_int(&c, "local_invites_enabled", 1) == 1); + REQUIRE(chat_setting_get_int(&c, "local_broadcasts_enabled", 1) == 1); + REQUIRE(!chat_setting_set(&c, "local_broadcasts_enabled", "0")); + int before_broadcast = broadcasts[3]; + REQUIRE(!chat_local_start(&c) && !chat_local_start(&d)); + test_clock_run_for(ua, 10000); + REQUIRE(broadcasts[3] == before_broadcast); + REQUIRE(peers(c.local_transport, 4, "Phone") == 1); + REQUIRE(!local_transport_set_name(c.local_transport, "Desktop renamed")); + test_clock_run_for(ua, 110000); REQUIRE(broadcasts[3] == before_broadcast); + struct InviteData invite = { .channelId = 123, .join_key = 456, .pubkey = {1}, .addrCount = 1 }; + invite.addrs[0] = (struct InviteAddr){ .family = 4, .address = {127, 0, 0, 1}, .proto = INVITE_PROTO_UDP, .port = 12345 }; + char link[LOCAL_TRANSPORT_DATA_MAX + 1]; REQUIRE(invite_link_encode(&invite, NULL, link, sizeof(link)) > 0); + send_invite(&c, 501, link); test_clock_run_for(ua, 10000); + REQUIRE(rd.invites == 1 && !strcmp(rd.link, link) && rc.delivery == 1 && rc.success && rc.request == 501); + REQUIRE(!c.etcp_sockets && !d.etcp_sockets && !c.connections && !d.connections); + send_invite(&c, 502, "invalid"); REQUIRE(rc.delivery == 2 && !rc.success && rc.request == 502); + d.chat_event_handler = NULL; + send_invite(&c, 503, link); test_clock_run_for(ua, 10000); + REQUIRE(rc.delivery == 3 && !rc.success && rd.invites == 1); + d.chat_event_handler = chat_event; + REQUIRE(!utun_set_client_activity(&d, 0)); + REQUIRE(!peers(d.local_transport, 0, NULL)); + int before = announcements[4]; test_clock_run_for(ua, 110000); REQUIRE(announcements[4] == before); + REQUIRE(!utun_set_client_activity(&d, 1)); REQUIRE(announcements[4] > before); + test_clock_run_for(ua, 10000); + send_invite(&c, 504, link); test_clock_run_for(ua, 10000); + REQUIRE(rc.delivery == 4 && rc.success && rd.invites == 2); + REQUIRE(!chat_setting_set(&d, "local_invites_enabled", "0")); + send_invite(&c, 505, link); test_clock_run_for(ua, 40000); + REQUIRE(rc.delivery == 5 && rc.success == LOCAL_DELIVERY_FORBIDDEN && rc.request == 505 && rd.invites == 2); + REQUIRE(forbidden_acks > 0); + REQUIRE(!chat_setting_set(&d, "local_invites_enabled", "1")); + REQUIRE(chat_setting_set(&d, "local_invites_enabled", "invalid") < 0); + send_invite(&c, 506, link); test_clock_run_for(ua, 10000); + REQUIRE(rc.delivery == 6 && rc.success == LOCAL_DELIVERY_RECEIVED && rd.invites == 3); + REQUIRE(!chat_setting_set(&c, "local_invites_enabled", "0")); + send_invite(&c, 507, link); test_clock_run_for(ua, 10000); + REQUIRE(rc.delivery == 7 && rc.success == LOCAL_DELIVERY_RECEIVED && rd.invites == 4); + REQUIRE(broadcasts[3] == before_broadcast); + REQUIRE(!local_transport_send(d.local_transport, 3, 508, link, strlen(link))); + test_clock_run_for(ua, 10000); + REQUIRE(rd.delivery == 1 && rd.success == LOCAL_DELIVERY_FORBIDDEN && rc.invites == 0); + REQUIRE(!chat_setting_set(&c, "local_broadcasts_enabled", "1")); + REQUIRE(broadcasts[3] > before_broadcast); + chat_local_stop(&c); chat_local_stop(&d); + REQUIRE(!c.local_transport && !d.local_transport && !c.activity_cbks && !d.activity_cbks); +} + +int main(int argc, char** argv) { + debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN); + debug_set_category_level(DEBUG_CATEGORY_DISCOVERY, DEBUG_LEVEL_DEBUG); + debug_set_category_level(DEBUG_CATEGORY_GENERAL, DEBUG_LEVEL_INFO); + test_clock_init(10000); + const char config_text[] = "[chatserver]\nlocal_invites_enabled=no\nlocal_broadcasts_enabled=false\n"; + struct utun_config* config = parse_config_from_buf(config_text, sizeof(config_text) - 1, "local-permissions-test"); + REQUIRE(config); + REQUIRE(!chat_setting_get_int_state(&config->global.chat_settings, "local_invites_enabled", 1)); + REQUIRE(!chat_setting_get_int_state(&config->global.chat_settings, "local_broadcasts_enabled", 1)); + free_config(config); + ipv6_only = argc == 2 && !strcmp(argv[1], "--ipv6"); + loopback_index = if_nametoindex("lo"); REQUIRE(loopback_index); + struct UASYNC* ua = uasync_create(); REQUIRE(ua); + struct result ra = {0}, rb = {0}; + struct local_transport_callbacks callbacks = { changed, received, delivered, activity }; + struct local_transport* a = local_transport_create(ua, 1, "Same nick", &callbacks, &ra); + struct local_transport* b = local_transport_create(ua, 2, "Same nick", &callbacks, &rb); + REQUIRE(a && b); + REQUIRE(!local_transport_set_active(a, 1) && !local_transport_set_active(b, 1)); + test_clock_run_for(ua, 10000); + REQUIRE(peers(a, 2, "Same nick") == 1 && peers(b, 1, "Same nick") == 1); + REQUIRE(ra.active && rb.active && announce_count >= 4); + REQUIRE(!local_transport_set_name(b, "Телефон")); + test_clock_run_for(ua, 10000); + REQUIRE(peers(a, 2, "Телефон") == 1); + + /* Потеря ACK приводит к повтору UDP, но callback получателя выполняется один раз. */ + drop_ack = 1; + REQUIRE(!local_transport_send(a, 2, 101, "invitation", 10)); + REQUIRE(local_transport_send(a, 2, 101, "invitation", 10) < 0); + test_clock_run_for(ua, 30000); + REQUIRE(rb.received == 1 && ra.delivered == 1 && !ra.failed && ra.request == 101); + REQUIRE(local_transport_send(a, 99, 102, "invitation", 10) < 0); + REQUIRE(local_transport_send(a, 2, 0, "invitation", 10) < 0); + REQUIRE(local_transport_send(a, 2, 102, "invitation", LOCAL_TRANSPORT_DATA_MAX + 1) < 0); + + /* Чужой трафик, усечённые заголовки, длины, версии, UTF-8 и собственные объявления. */ + socket_t raw = SOCKET_INVALID; + if (!ipv6_only) { + raw = socket_create_udp(AF_INET); REQUIRE(raw != SOCKET_INVALID); + uint8_t bad[LOCAL_PACKET_MAX + 1]; + REQUIRE(announcement_size > LOCAL_HEADER_SIZE); + memcpy(bad, announcement, announcement_size); local_put64(bad + 20, 3); + size_t size = announcement_size; + raw_send(raw, ua, "foreign UDP", 11); + raw_send(raw, ua, bad, 16); + bad[16] = 2; raw_send(raw, ua, bad, size); bad[16] = 1; + raw_send(raw, ua, bad, size - 1); + bad[LOCAL_HEADER_SIZE] = 0; raw_send(raw, ua, bad, size); + bad[LOCAL_HEADER_SIZE] = 0xff; raw_send(raw, ua, bad, size); + memset(bad, 0, sizeof(bad)); memcpy(bad, LOCAL_MAGIC, 16); raw_send(raw, ua, bad, sizeof(bad)); + REQUIRE(peers(a, 2, "Телефон") == 1 && !ra.received); + } + REQUIRE(!local_name_valid((const uint8_t*)"\xc0\x80", 2)); + REQUIRE(!local_name_valid((const uint8_t*)"\xed\xa0\x80", 3)); + REQUIRE(!local_name_valid((const uint8_t*)"\xf4\x90\x80\x80", 4)); + REQUIRE(!local_name_valid((const uint8_t*)"name\n", 5)); + REQUIRE(local_name_valid((const uint8_t*)"Телефон", strlen("Телефон"))); + + /* Только ANNOUNCE продлевает TTL: последний ACK не удерживает устройство в таблице. */ + REQUIRE(!local_transport_set_name(b, "TTL")); + test_clock_run_for(ua, 1000); + suppress_announcements = 2; + test_clock_run_for(ua, 260000); + REQUIRE(!local_transport_send(a, 2, 103, "invitation", 10)); + test_clock_run_for(ua, 10000); + REQUIRE(ra.delivered == 2 && rb.received == 2); + test_clock_run_for(ua, 50000); + REQUIRE(peers(a, 0, NULL) == 0); + suppress_announcements = 0; + REQUIRE(!local_transport_set_name(b, "Back")); + test_clock_run_for(ua, 10000); REQUIRE(peers(a, 2, "Back") == 1); + + /* Недоступный ACK даёт один failure; остановка отменяет запрос и удаляет все ресурсы. */ + drop_ack = -1; + REQUIRE(!local_transport_send(a, 2, 104, "invitation", 10)); + test_clock_run_for(ua, 50000); + REQUIRE(ra.failed == 1 && ra.request == 104 && rb.received == 3); + REQUIRE(!local_transport_send(a, 2, 105, "invitation", 10)); + REQUIRE(!local_transport_set_active(a, 0)); + REQUIRE(!ra.active && ra.failed == 2 && ra.request == 105 && !peers(a, 0, NULL)); + REQUIRE(!local_transport_set_active(b, 0)); + uasync_poll(ua, 0); // uasync освобождает отменённые heap nodes на следующем проходе + size_t alloc, freed, sockets, closed; + uasync_get_stats(ua, &alloc, &freed, &sockets, &closed); + DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "after stop: timers=%zu/%zu sockets=%zu/%zu", alloc, freed, sockets, closed); + REQUIRE(alloc == freed && sockets == closed); + int before = announce_count; test_clock_run_for(ua, 110000); REQUIRE(announce_count == before); + REQUIRE(!local_transport_set_active(a, 1)); REQUIRE(announce_count > before); + local_transport_destroy(a); local_transport_destroy(b); + if (raw != SOCKET_INVALID) socket_close_wrapper(raw); + drop_ack = 0; + chat_adapter(ua); + REQUIRE(!ipv6_only || ipv6_messages > 0); + uasync_destroy(ua, 0); test_clock_finish(); + DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "PASS: UDP discovery/delivery, ACK retry/dedup, TTL, malformed packets, activity and teardown"); + return 0; +} diff --git a/tools/chatgui-android/app/src/main/AndroidManifest.xml b/tools/chatgui-android/app/src/main/AndroidManifest.xml index def0d7da..e5795bf9 100644 --- a/tools/chatgui-android/app/src/main/AndroidManifest.xml +++ b/tools/chatgui-android/app/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ + diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt index 112a5aba..aa43e8e1 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt @@ -31,6 +31,7 @@ object AppEventHandler : ChatEventCallback { val events: SharedFlow> = _events override fun onEvent(type: Int, data: ByteArray?) { + if (type == 13 || type in 61..64) mainHandler.post { com.utun.chat.data.LocalDiscovery.onEvent(type, data) } if (type == 12) mainHandler.post { com.utun.chat.headless.HeadlessService.onCoreStarted() } if (type == 13 || type in 49..50) mainHandler.post { ChatApplication.instance.memberFiles.onEvent(type, data) } if (type in listOf(4, 8, 9, 12, 13, 23, 24, 25, 31) || type in 49..53) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt index a139f487..be1a64a9 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt @@ -522,6 +522,21 @@ class MainActivity : AppCompatActivity() { pmInvite?.let { state -> PmInviteDialog(state, vm::selectPmInviteGroup, vm::sendPmInvite, vm::dismissPmInvite) } + val localInvitations by com.utun.chat.data.LocalDiscovery.invitations.collectAsState() + localInvitations.firstOrNull()?.let { invitation -> + AlertDialog( + onDismissRequest = com.utun.chat.data.LocalDiscovery::dismissInvitation, + title = { Text(stringResource(R.string.invite_link)) }, + text = { Text(stringResource(R.string.local_invite_received, invitation.device.name)) }, + confirmButton = { TextButton(onClick = { + com.utun.chat.data.LocalDiscovery.dismissInvitation() + vm.openInviteLink(invitation.link) + }) { Text(stringResource(R.string.connect)) } }, + dismissButton = { TextButton(onClick = com.utun.chat.data.LocalDiscovery::dismissInvitation) { + Text(stringResource(R.string.close)) + } } + ) + } linkJoin?.let { state -> AlertDialog( onDismissRequest = vm::dismissLinkJoin, diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt index f2486f9c..20557b81 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt @@ -78,6 +78,8 @@ class ConfigProvider(private val context: Context) { private val chatStandbyMinSleepSec = intPreferencesKey("chat_standby_min_sleep_sec") private val chatStandbyKeepaliveMs = intPreferencesKey("chat_standby_keepalive_ms") private val chatGroupAutoconnect = intPreferencesKey("chat_group_autoconnect") + private val chatLocalInvitesEnabled = intPreferencesKey("chat_local_invites_enabled") + private val chatLocalBroadcastsEnabled = intPreferencesKey("chat_local_broadcasts_enabled") private val firstLaunchDone = booleanPreferencesKey("first_launch_done") private val connectionModeKey = stringPreferencesKey("connection_mode") private val serviceEnabledKey = booleanPreferencesKey("service_enabled") @@ -230,6 +232,8 @@ class ConfigProvider(private val context: Context) { key == "chat.standby_min_sleep_sec" -> context.dataStore.data.first()[chatStandbyMinSleepSec] ?: 15 key == "chat.standby_keepalive_ms" -> context.dataStore.data.first()[chatStandbyKeepaliveMs] ?: 30000 key == "chat.group_autoconnect" -> context.dataStore.data.first()[chatGroupAutoconnect] ?: 1 + key == "chat.local_invites_enabled" -> context.dataStore.data.first()[chatLocalInvitesEnabled] ?: 1 + key == "chat.local_broadcasts_enabled" -> context.dataStore.data.first()[chatLocalBroadcastsEnabled] ?: 1 key == "radio.auto_enabled" -> context.dataStore.data.first()[radioAutoEnabledKey] ?: 0 key == "radio.vad_threshold" -> context.dataStore.data.first()[radioVadThresholdKey] ?: 50 key == "radio.auto_hangover_ms" -> context.dataStore.data.first()[radioAutoHangoverMsKey] ?: 200 @@ -272,6 +276,8 @@ class ConfigProvider(private val context: Context) { "chat.standby_min_sleep_sec" -> prefs[chatStandbyMinSleepSec] = value.toIntOrNull() ?: 15 "chat.standby_keepalive_ms" -> prefs[chatStandbyKeepaliveMs] = value.toIntOrNull() ?: 30000 "chat.group_autoconnect" -> prefs[chatGroupAutoconnect] = value.toIntOrNull() ?: 1 + "chat.local_invites_enabled" -> prefs[chatLocalInvitesEnabled] = value.toIntOrNull() ?: 1 + "chat.local_broadcasts_enabled" -> prefs[chatLocalBroadcastsEnabled] = value.toIntOrNull() ?: 1 "radio.auto_enabled" -> prefs[radioAutoEnabledKey] = value.toIntOrNull() ?: 0 "radio.vad_threshold" -> prefs[radioVadThresholdKey] = value.toIntOrNull() ?: 50 "radio.auto_hangover_ms" -> prefs[radioAutoHangoverMsKey] = value.toIntOrNull() ?: 200 @@ -736,6 +742,8 @@ class ConfigProvider(private val context: Context) { appendLine("standby_min_sleep_sec=${getInt("chat.standby_min_sleep_sec")}") appendLine("standby_keepalive_ms=${getInt("chat.standby_keepalive_ms")}") appendLine("group_autoconnect=${getInt("chat.group_autoconnect")}") + appendLine("local_invites_enabled=${getInt("chat.local_invites_enabled")}") + appendLine("local_broadcasts_enabled=${getInt("chat.local_broadcasts_enabled")}") appendLine() appendLine("[debug]") val dbgCat = getString("debug.categories") ?: "" diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/LocalDiscovery.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/LocalDiscovery.kt new file mode 100644 index 00000000..85d5b089 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/LocalDiscovery.kt @@ -0,0 +1,112 @@ +package com.utun.chat.data + +import android.content.Context +import android.net.wifi.WifiManager +import com.utun.chat.ChatApplication +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.nio.ByteBuffer +import java.nio.ByteOrder + +data class LocalDevice(val nodeId: Long, val name: String) { + val label: String get() = "$name [${nodeId.toULong().toString(16).takeLast(6)}]" +} +data class LocalInvitation(val device: LocalDevice, val link: String) +data class LocalDelivery(val request: Long, val pending: Boolean, val received: Boolean = false, val forbidden: Boolean = false) + +/** Состояние приложения переживает Activity и не повторяет приглашения из replay событий ViewModel. + * Все методы вызываются на main; MulticastLock следует эффективной активности native-сервиса. */ +object LocalDiscovery { + private val _devices = MutableStateFlow>(emptyList()) + val devices = _devices.asStateFlow() + private val _invitations = MutableStateFlow>(emptyList()) + val invitations = _invitations.asStateFlow() + private val _delivery = MutableStateFlow(null) + val delivery = _delivery.asStateFlow() + private var nextRequest = 0L + private var multicastLock: WifiManager.MulticastLock? = null + + fun send(device: LocalDevice, link: String) { + if (_delivery.value?.pending == true) return + val request = ++nextRequest + _delivery.value = LocalDelivery(request, pending = true) + if (!NativeLib.sendLocalInvite(device.nodeId, request, link)) { + LogManager.addLog("WARN", "discovery", "invitation enqueue failed peer=${device.nodeId}") + _delivery.value = LocalDelivery(request, pending = false) + } + } + + fun clearDelivery() { if (_delivery.value?.pending != true) _delivery.value = null } + fun dismissInvitation() { _invitations.value = _invitations.value.drop(1) } + + fun onEvent(type: Int, data: ByteArray?) { + if (type == 13) { + setActive(false); _devices.value = emptyList() + _delivery.value?.takeIf { it.pending }?.let { _delivery.value = it.copy(pending = false) } + return + } + if (data == null) { LogManager.addLog("WARN", "discovery", "empty event type=$type"); return } + val input = ByteBuffer.wrap(data).order(ByteOrder.BIG_ENDIAN) + when (type) { + 61 -> { + if (input.remaining() < 2) return invalid(type, data.size) + val count = input.short.toInt() and 0xffff + if (count > 128) return invalid(type, data.size) + val devices = ArrayList(count) + repeat(count) { + if (input.remaining() < 9) return invalid(type, data.size) + val id = input.long + val size = input.get().toInt() and 0xff + if (size !in 1..127 || input.remaining() < size) return invalid(type, data.size) + val name = ByteArray(size).also { input.get(it) }.toString(Charsets.UTF_8) + devices.add(LocalDevice(id, name)) + } + if (input.hasRemaining()) return invalid(type, data.size) + _devices.value = devices.sortedBy { it.label } + } + 62 -> { + if (input.remaining() < 10) return invalid(type, data.size) + val id = input.long + val size = input.get().toInt() and 0xff + if (size !in 1..127 || input.remaining() <= size) return invalid(type, data.size) + val name = ByteArray(size).also { input.get(it) }.toString(Charsets.UTF_8) + val link = ByteArray(input.remaining()).also { input.get(it) }.toString(Charsets.UTF_8) + if (!link.startsWith("utun://")) return invalid(type, data.size) + if (_invitations.value.size >= 8) { + LogManager.addLog("WARN", "discovery", "incoming invitation queue full"); return + } + _invitations.value += LocalInvitation(LocalDevice(id, name), link) + } + 63 -> { + if (data.size != 17 || data[16].toInt() !in 0..2) return invalid(type, data.size) + val request = input.long + if (_delivery.value?.request == request) + _delivery.value = LocalDelivery(request, pending = false, received = data[16].toInt() == 1, + forbidden = data[16].toInt() == 2) + } + 64 -> { + if (data.size != 1 || data[0].toInt() !in 0..1) return invalid(type, data.size) + setActive(data[0].toInt() == 1) + } + } + } + + private fun invalid(type: Int, size: Int) { LogManager.addLog("WARN", "discovery", "invalid event type=$type bytes=$size") } + + private fun setActive(active: Boolean) { + try { + if (active && multicastLock?.isHeld != true) { + val wifi = ChatApplication.instance.getSystemService(Context.WIFI_SERVICE) as? WifiManager + if (wifi == null) { LogManager.addLog("WARN", "discovery", "Wi-Fi service unavailable"); return } + multicastLock = wifi.createMulticastLock("utun-local-discovery").apply { setReferenceCounted(false); acquire() } + LogManager.addLog("INFO", "discovery", "Wi-Fi multicast receive enabled") + } else if (!active) { + multicastLock?.let { if (it.isHeld) it.release() } + multicastLock = null + LogManager.addLog("INFO", "discovery", "Wi-Fi multicast receive disabled") + } + } catch (error: Exception) { + LogManager.addLog("ERROR", "discovery", "multicast lock failed active=$active: ${error.message}") + } + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt index 06871570..6c4c08ba 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt @@ -82,6 +82,9 @@ object NativeLib { fun requestInviteCandidates(channelId: String) { nativeRequestInviteCandidates(channelId) } fun requestInviteLink(channelId: String, targetNodeId: Long, requestId: Long) { nativeRequestInviteLink(channelId, targetNodeId, requestId) } + fun requestLocalDevices(): Boolean = nativeRequestLocalDevices() + fun sendLocalInvite(peer: Long, request: Long, link: String): Boolean = + nativeSendLocalInvite(peer, request, link.toByteArray(Charsets.UTF_8)) fun ping() { nativePing() } fun requestCpuLoad(): Boolean = nativeRequestCpuLoad() @@ -341,6 +344,8 @@ object NativeLib { private external fun nativeGetMyNodeId(): Long private external fun nativeRequestInviteCandidates(channelId: String) private external fun nativeRequestInviteLink(channelId: String, targetNodeId: Long, requestId: Long) + private external fun nativeRequestLocalDevices(): Boolean + private external fun nativeSendLocalInvite(peer: Long, request: Long, link: ByteArray): Boolean private external fun nativeRestart(configText: String): Boolean private external fun nativeUpdateSockets(configText: String): Boolean private external fun nativePing() diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/InviteLinkDialog.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/InviteLinkDialog.kt index cf5a5f47..b07170fb 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/InviteLinkDialog.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/InviteLinkDialog.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.google.zxing.BarcodeFormat @@ -24,6 +25,8 @@ import com.google.zxing.MultiFormatWriter import com.google.zxing.common.BitMatrix import com.utun.chat.data.InviteCandidate import com.utun.chat.data.LogManager +import com.utun.chat.data.LocalDiscovery +import com.utun.chat.data.NativeLib @Composable private fun candidateLabel(c: InviteCandidate): String = @@ -45,6 +48,12 @@ fun InviteLinkDialog( var copied by remember { mutableStateOf(false) } var selectedNodeId by remember { mutableStateOf(null) } var menuOpen by remember { mutableStateOf(false) } + val devices by LocalDiscovery.devices.collectAsState() + val delivery by LocalDiscovery.delivery.collectAsState() + var selectedDevice by remember { mutableStateOf(null) } + var devicesOpen by remember { mutableStateOf(false) } + val device = devices.firstOrNull { it.nodeId == selectedDevice } ?: devices.firstOrNull() + LaunchedEffect(Unit) { LocalDiscovery.clearDelivery(); NativeLib.requestLocalDevices() } val effectiveSelected = remember(candidates, selectedNodeId) { candidates.firstOrNull { it.nodeId == selectedNodeId } ?: candidates.firstOrNull() @@ -58,27 +67,31 @@ fun InviteLinkDialog( Column(horizontalAlignment = Alignment.CenterHorizontally) { Text(stringResource(R.string.invite_lifetime), style = MaterialTheme.typography.bodySmall) Spacer(Modifier.height(8.dp)) - Box { - OutlinedButton(onClick = { menuOpen = true }) { - Text( - effectiveSelected?.let { candidateLabel(it) } ?: stringResource(R.string.select_node), - maxLines = 1 - ) - } - DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { - candidates.forEach { c -> - DropdownMenuItem( - text = { Text(candidateLabel(c)) }, - onClick = { - menuOpen = false - if (selectedNodeId != c.nodeId) { - selectedNodeId = c.nodeId - copied = false - onSelectNode(c.nodeId) - } - } + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringResource(R.string.invite_connect_via)) + Box(modifier = Modifier.weight(1f)) { + OutlinedButton(onClick = { menuOpen = true }, modifier = Modifier.fillMaxWidth()) { + Text( + effectiveSelected?.let { candidateLabel(it) } ?: stringResource(R.string.select_node), + maxLines = 1, overflow = TextOverflow.Ellipsis ) } + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + candidates.forEach { c -> + DropdownMenuItem( + text = { Text(candidateLabel(c)) }, + onClick = { + menuOpen = false + if (selectedNodeId != c.nodeId) { + selectedNodeId = c.nodeId + copied = false + onSelectNode(c.nodeId) + } + } + ) + } + } } } Spacer(modifier = Modifier.height(8.dp)) @@ -92,10 +105,36 @@ fun InviteLinkDialog( Spacer(modifier = Modifier.height(8.dp)) } Text(link, fontFamily = FontFamily.Monospace, fontSize = 11.sp, - modifier = Modifier.padding(8.dp)) + maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth().padding(8.dp)) } else -> Text(stringResource(R.string.invite_build_failed), fontFamily = FontFamily.Monospace, fontSize = 11.sp) } + if (devices.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + Text(stringResource(R.string.local_invite_devices), style = MaterialTheme.typography.bodyMedium) + Box { + OutlinedButton(onClick = { devicesOpen = true }) { Text(device?.label.orEmpty(), maxLines = 1) } + DropdownMenu(expanded = devicesOpen, onDismissRequest = { devicesOpen = false }) { + devices.forEach { item -> + DropdownMenuItem(text = { Text(item.label) }, onClick = { + selectedDevice = item.nodeId; devicesOpen = false + }) + } + } + } + Button(enabled = link != null && !pending && device != null && delivery?.pending != true, onClick = { + if (device != null && link != null) LocalDiscovery.send(device, link) + }) { Text(stringResource(R.string.local_invite_send)) } + } + delivery?.let { status -> + Text(stringResource(when { + status.pending -> R.string.local_invite_sending + status.forbidden -> R.string.local_invite_forbidden + status.received -> R.string.local_invite_delivered + else -> R.string.local_invite_failed + }), style = MaterialTheme.typography.bodySmall) + } } }, confirmButton = { diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt index d06abeac..da39083a 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt @@ -172,6 +172,8 @@ private fun GeneralSettingsTab(onDirty: (Boolean) -> Unit) { val provider = ChatApplication.instance.configProvider val scope = rememberCoroutineScope() var nodeName by remember { mutableStateOf("") } + var localInvitesEnabled by remember { mutableStateOf(provider.getInt("chat.local_invites_enabled") != 0) } + var localBroadcastsEnabled by remember { mutableStateOf(provider.getInt("chat.local_broadcasts_enabled") != 0) } LaunchedEffect(Unit) { nodeName = provider.getString("node.name") ?: provider.getDeviceName() @@ -190,6 +192,36 @@ private fun GeneralSettingsTab(onDirty: (Boolean) -> Unit) { label = { Text(stringResource(R.string.node_name)) }, singleLine = true, modifier = Modifier.heightIn(min = 40.dp)) } item(key = "profile-avatar") { ProfileAvatarControls() } + item(key = "local-network-title") { + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + Text(stringResource(R.string.local_network), style = MaterialTheme.typography.titleMedium) + } + item(key = "local-invites") { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(R.string.local_invites_enabled), Modifier.weight(1f)) + Switch(checked = localInvitesEnabled, onCheckedChange = { enabled -> + localInvitesEnabled = enabled + scope.launch { + val value = if (enabled) "1" else "0" + provider.setValue("chat.local_invites_enabled", value) + if (NativeLib.isReady()) NativeLib.setChatSetting("local_invites_enabled", value) + } + }) + } + } + item(key = "local-broadcasts") { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(R.string.local_broadcasts_enabled), Modifier.weight(1f)) + Switch(checked = localBroadcastsEnabled, onCheckedChange = { enabled -> + localBroadcastsEnabled = enabled + scope.launch { + val value = if (enabled) "1" else "0" + provider.setValue("chat.local_broadcasts_enabled", value) + if (NativeLib.isReady()) NativeLib.setChatSetting("local_broadcasts_enabled", value) + } + }) + } + } } } diff --git a/tools/chatgui-android/app/src/main/res/values-ru/strings.xml b/tools/chatgui-android/app/src/main/res/values-ru/strings.xml index cb2ac9ec..81621649 100644 --- a/tools/chatgui-android/app/src/main/res/values-ru/strings.xml +++ b/tools/chatgui-android/app/src/main/res/values-ru/strings.xml @@ -103,6 +103,7 @@ " (свой узел)" "Ссылка-приглашение" "Выбор узла…" + Подключим через "Генерация ссылки…" "QR-код" "Не удалось собрать ссылку" @@ -506,4 +507,14 @@ Автор отказал в доступе Расшифровка… Ошибка расшифровки. Нажмите для повтора + Устройства в локальной сети: + Отправить на устройство + Отправка приглашения… + Приглашение доставлено + Не удалось доставить приглашение + %1$s прислал приглашение по локальной сети. Подключиться? + Локальная сеть + Разрешить получать приглашения из локальной сети + Разрешить отправлять широковещательные объявления + Приём приглашений запрещён. Разрешите его в настройках устройства получателя. diff --git a/tools/chatgui-android/app/src/main/res/values/strings.xml b/tools/chatgui-android/app/src/main/res/values/strings.xml index 7acead29..fed0c5ed 100644 --- a/tools/chatgui-android/app/src/main/res/values/strings.xml +++ b/tools/chatgui-android/app/src/main/res/values/strings.xml @@ -104,6 +104,7 @@ " (self)" "Invite link" "Select node…" + Connect via "Generating link…" "QR code" "Could not build invite link" @@ -505,4 +506,14 @@ Access denied by author Decrypting… Decryption failed. Tap to retry + Devices on the local network: + Send to device + Sending invitation… + Invitation delivered + Could not deliver invitation + %1$s sent an invitation over the local network. Connect? + Local network + Receive invitations from the local network + Broadcast presence on the local network + Receiving invitations is forbidden. Enable it in the recipient device\'s settings. diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.c b/tools/chatgui-android/jni_bridge/android_jni_bridge.c index 6c49c99a..4b75b4e9 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.c +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.c @@ -29,6 +29,7 @@ #include "../../../src/chat/chat_core.h" #include "../../../src/media_async/encrypted_file.h" #include "../../../src/chat/chat_event.h" +#include "../../../src/chat/chat_local.h" #include "../../../src/chat/chat_poll.h" #include "../../../lib/strbuf.h" #include "../../../src/chat/chat_member.h" @@ -1974,6 +1975,37 @@ JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeRequestInviteLink /* ── nativeSetEventCallback(eventCb) ── */ +JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeRequestLocalDevices(JNIEnv* env, jobject thiz) { + (void)env; (void)thiz; + struct UASYNC* ua = instance_lite_get_uasync(); + struct UTUN_INSTANCE* inst = instance_lite_get_instance(); + if (!inst || !uasync_is_running(ua)) { + DEBUG_WARN(DEBUG_CATEGORY_DISCOVERY, "device list requested while core is stopped"); return JNI_FALSE; + } + uasync_post(ua, chat_local_peers_trampoline, inst); + return JNI_TRUE; +} + +JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeSendLocalInvite( + JNIEnv* env, jobject thiz, jlong peer, jlong request, jbyteArray link) { + (void)thiz; + struct UASYNC* ua = instance_lite_get_uasync(); + struct UTUN_INSTANCE* inst = instance_lite_get_instance(); + jsize len = link ? (*env)->GetArrayLength(env, link) : 0; + if (!inst || !uasync_is_running(ua) || !peer || !request || len <= 0 || len > LOCAL_TRANSPORT_DATA_MAX) { + DEBUG_WARN(DEBUG_CATEGORY_DISCOVERY, "invalid/stopped local send request bytes=%d", (int)len); return JNI_FALSE; + } + struct chat_local_send_request* req = u_calloc(1, sizeof(*req)); + if (!req) { DEBUG_ERROR(DEBUG_CATEGORY_DISCOVERY, "local send request allocation failed"); return JNI_FALSE; } + req->inst = inst; req->peer_id = (uint64_t)peer; req->request_id = (uint64_t)request; + (*env)->GetByteArrayRegion(env, link, 0, len, (jbyte*)req->link); + if ((*env)->ExceptionCheck(env)) { + DEBUG_ERROR(DEBUG_CATEGORY_DISCOVERY, "copying local invitation from JNI failed"); u_free(req); return JNI_FALSE; + } + uasync_post(ua, chat_local_send_trampoline, req); + return JNI_TRUE; +} + JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeSetEventCallback( JNIEnv* env, jobject thiz, jobject callback) { (void)thiz; diff --git a/tools/chatgui/CMakeLists.txt b/tools/chatgui/CMakeLists.txt index 0b5febcf..e6854284 100644 --- a/tools/chatgui/CMakeLists.txt +++ b/tools/chatgui/CMakeLists.txt @@ -508,6 +508,12 @@ target_link_libraries(test_invite_links PRIVATE ${QT_LIBS} ${QT_TEST_LIB} rlotti add_test(NAME test_invite_links COMMAND test_invite_links) set_tests_properties(test_invite_links PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen" TIMEOUT 30) +add_executable(test_local_invite_ui tests/test_local_invite_ui.cpp src/invitedialog.cpp src/qrcode_utils.cpp) +target_include_directories(test_local_invite_ui PRIVATE ${UTUN_INCLUDE_DIRS}) +target_link_libraries(test_local_invite_ui PRIVATE ${QT_LIBS} ${QT_TEST_LIB} ZXing::ZXing utun) +add_test(NAME test_local_invite_ui COMMAND test_local_invite_ui) +set_tests_properties(test_local_invite_ui PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen" TIMEOUT 30) + add_executable(test_config_updater tests/test_config_updater.cpp transport/config_updater.cpp diff --git a/tools/chatgui/src/invitedialog.cpp b/tools/chatgui/src/invitedialog.cpp index 450b3169..ae9006c2 100644 --- a/tools/chatgui/src/invitedialog.cpp +++ b/tools/chatgui/src/invitedialog.cpp @@ -14,14 +14,19 @@ #include #include #include +#include +#include #include extern "C" { #include "../../../src/chat/invite_build.h" #include "../../../src/chat/chat_join.h" +#include "../../../src/chat/chat_local.h" #include "../../../lib/mem.h" } +static QPointer s_shareInviteDialog; + ShareInviteDialog::ShareInviteDialog(const QString& channelId, DbManager* db, QWidget* parent) : QDialog(parent) , m_channelId(channelId) @@ -30,6 +35,7 @@ ShareInviteDialog::ShareInviteDialog(const QString& channelId, DbManager* db, QW setWindowTitle(QCoreApplication::translate("ChatGui", "Invite link")); setMinimumSize(420, 560); setModal(true); + s_shareInviteDialog = this; auto *layout = new QVBoxLayout(this); layout->setSpacing(12); @@ -38,7 +44,7 @@ ShareInviteDialog::ShareInviteDialog(const QString& channelId, DbManager* db, QW expiry->setWordWrap(true); layout->addWidget(expiry); - auto *nodeLabel = new QLabel(QCoreApplication::translate("ChatGui", "Entry node:"), this); + auto *nodeLabel = new QLabel(QCoreApplication::translate("ChatGui", "Connect via"), this); nodeLabel->setStyleSheet("font-size: 12px; color: palette(window-text);"); m_nodeCombo = new QComboBox(this); @@ -72,6 +78,32 @@ ShareInviteDialog::ShareInviteDialog(const QString& channelId, DbManager* db, QW linkRow->addWidget(copyBtn); layout->addLayout(linkRow); + m_localRow = new QWidget(this); + auto* localLayout = new QVBoxLayout(m_localRow); + localLayout->setContentsMargins(0, 0, 0, 0); + localLayout->addWidget(new QLabel(QCoreApplication::translate("ChatGui", "Devices on the local network:"), m_localRow)); + auto* deviceRow = new QHBoxLayout(); + m_devices = new QComboBox(m_localRow); + m_devices->setObjectName(QStringLiteral("localDevices")); + m_sendLocal = new QPushButton(QCoreApplication::translate("ChatGui", "Send to device"), m_localRow); + m_sendLocal->setObjectName(QStringLiteral("sendLocalInvite")); + m_sendLocal->setEnabled(false); + deviceRow->addWidget(m_devices, 1); deviceRow->addWidget(m_sendLocal); + localLayout->addLayout(deviceRow); + layout->addWidget(m_localRow); m_localRow->hide(); + m_delivery = new QLabel(this); + m_delivery->setWordWrap(true); layout->addWidget(m_delivery); + connect(m_sendLocal, &QPushButton::clicked, this, &ShareInviteDialog::sendLocalInvite); + connect(m_devices, QOverload::of(&QComboBox::currentIndexChanged), this, [this] { updateLocalSend(); }); + gui_bridge_set_local_devices_cb([](const uint8_t* data, int len) { + if (s_shareInviteDialog) s_shareInviteDialog->onLocalDevices(data, len); + }); + gui_bridge_set_local_delivery_cb([](const uint8_t* data, int len) { + if (s_shareInviteDialog) s_shareInviteDialog->onLocalDelivery(data, len); + }); + if (gui_bridge_is_uasync_ready() && gui_bridge_get_inst()) + gui_bridge_post_uasync_fn(chat_local_peers_trampoline, gui_bridge_get_inst()); + m_qrLabel = new QLabel(this); m_qrLabel->setAlignment(Qt::AlignCenter); m_qrLabel->setMinimumSize(180, 180); @@ -86,11 +118,11 @@ ShareInviteDialog::ShareInviteDialog(const QString& channelId, DbManager* db, QW /* колбэки результатов (по образцу JoinDialog) */ gui_bridge_set_invite_link_ready_cb([](const char* chId, int chIdLen, uint64_t requestId, const char* link) { - ShareInviteDialog* dlg = qobject_cast(QApplication::activeModalWidget()); + ShareInviteDialog* dlg = s_shareInviteDialog; if (dlg) dlg->onLinkReady(chId, chIdLen, requestId, link); }); gui_bridge_set_invite_candidates_cb([](const char* chId, int chIdLen, const uint8_t* data, int len) { - ShareInviteDialog* dlg = qobject_cast(QApplication::activeModalWidget()); + ShareInviteDialog* dlg = s_shareInviteDialog; if (dlg) dlg->onCandidates(chId, chIdLen, data, len); }); @@ -103,6 +135,9 @@ ShareInviteDialog::ShareInviteDialog(const QString& channelId, DbManager* db, QW } ShareInviteDialog::~ShareInviteDialog() { + s_shareInviteDialog = nullptr; + gui_bridge_set_local_devices_cb(nullptr); + gui_bridge_set_local_delivery_cb(nullptr); gui_bridge_set_invite_link_ready_cb(nullptr); gui_bridge_set_invite_candidates_cb(nullptr); } @@ -127,6 +162,7 @@ void ShareInviteDialog::requestLink(uint64_t targetNodeId) { req->request_id = m_requestId = ++nextRequestId; req->target_node_id = targetNodeId; m_linkEdit->setText(QString()); + updateLocalSend(); m_linkEdit->setPlaceholderText(QCoreApplication::translate("ChatGui", "Generating link...")); m_qrLabel->clear(); gui_bridge_post_uasync_fn(chat_invite_build_link_trampoline, req); @@ -134,6 +170,7 @@ void ShareInviteDialog::requestLink(uint64_t targetNodeId) { void ShareInviteDialog::setLink(const QString& link) { m_linkEdit->setText(link); + updateLocalSend(); QImage qrImage = generateQRCode(link); if (!qrImage.isNull()) m_qrLabel->setPixmap(QPixmap::fromImage(qrImage).scaled( @@ -195,9 +232,72 @@ void ShareInviteDialog::onLinkReady(const char* chId, int chIdLen, uint64_t requ if (cid != m_channelId) return; if (!link || link[0] == '\0') { m_linkEdit->setText(QString()); + updateLocalSend(); m_linkEdit->setPlaceholderText(QCoreApplication::translate("ChatGui", "Failed to build the link (see log)")); m_qrLabel->clear(); return; } setLink(QString::fromUtf8(link)); } + +void ShareInviteDialog::updateLocalSend() { + m_sendLocal->setEnabled(!m_localRequest && m_devices->currentIndex() >= 0 && !m_linkEdit->text().isEmpty()); +} + +void ShareInviteDialog::onLocalDevices(const uint8_t* data, int len) { + if (len < 2) { GUI_ERROR("discovery: invalid device list bytes=%d", len); return; } + struct Device { quint64 id; QString name; }; + QList devices; + int count = qFromBigEndian(data), offset = 2; + for (int i = 0; i < count; i++) { + if (offset + 9 > len || offset + 9 + data[offset + 8] > len) { + GUI_ERROR("discovery: truncated device list bytes=%d offset=%d", len, offset); return; + } + int nameLen = data[offset + 8]; + devices.append({qFromBigEndian(data + offset), QString::fromUtf8((const char*)data + offset + 9, nameLen)}); + offset += 9 + nameLen; + } + if (offset != len) { GUI_ERROR("discovery: trailing device list bytes=%d", len - offset); return; } + auto selected = m_devices->currentData(); + QSignalBlocker blocker(m_devices); + m_devices->clear(); + for (const auto& device : devices) { + const QString suffix = QString::number(device.id, 16).right(6); + m_devices->addItem(device.name + QStringLiteral(" [%1]").arg(suffix), QVariant::fromValue(device.id)); + } + int index = m_devices->findData(selected); + if (index >= 0) m_devices->setCurrentIndex(index); + m_localRow->setVisible(!devices.isEmpty()); updateLocalSend(); +} + +void ShareInviteDialog::sendLocalInvite() { + QByteArray link = m_linkEdit->text().toUtf8(); + if (m_localRequest || m_devices->currentIndex() < 0 || link.isEmpty()) return; + if (!gui_bridge_is_uasync_ready() || !gui_bridge_get_inst()) { + GUI_ERROR("discovery: core stopped before local send"); + m_delivery->setText(QCoreApplication::translate("ChatGui", "Could not deliver invitation")); return; + } + if (link.size() > LOCAL_TRANSPORT_DATA_MAX) { + GUI_ERROR("discovery: invitation too long bytes=%d", (int)link.size()); + m_delivery->setText(QCoreApplication::translate("ChatGui", "Could not deliver invitation")); return; + } + auto* req = (struct chat_local_send_request*)u_calloc(1, sizeof(struct chat_local_send_request)); + if (!req) { GUI_ERROR("discovery: send request allocation failed"); return; } + static uint64_t nextRequest = 0; + req->inst = gui_bridge_get_inst(); req->peer_id = m_devices->currentData().toULongLong(); + req->request_id = m_localRequest = ++nextRequest; + memcpy(req->link, link.constData(), (size_t)link.size()); + m_delivery->setText(QCoreApplication::translate("ChatGui", "Sending invitation…")); updateLocalSend(); + gui_bridge_post_uasync_fn(chat_local_send_trampoline, req); +} + +void ShareInviteDialog::onLocalDelivery(const uint8_t* data, int len) { + if (len != 17 || data[16] > LOCAL_DELIVERY_FORBIDDEN) { GUI_ERROR("discovery: invalid delivery response bytes=%d", len); return; } + if (!m_localRequest || qFromBigEndian(data) != m_localRequest) return; + m_localRequest = 0; + if (data[16] == LOCAL_DELIVERY_FORBIDDEN) m_delivery->setText(QCoreApplication::translate("ChatGui", + "Receiving invitations is forbidden. Enable it in the recipient device's settings.")); + else m_delivery->setText(QCoreApplication::translate("ChatGui", + data[16] == LOCAL_DELIVERY_RECEIVED ? "Invitation delivered" : "Could not deliver invitation")); + updateLocalSend(); +} diff --git a/tools/chatgui/src/invitedialog.h b/tools/chatgui/src/invitedialog.h index 4c6921f9..17e32c7d 100644 --- a/tools/chatgui/src/invitedialog.h +++ b/tools/chatgui/src/invitedialog.h @@ -10,6 +10,7 @@ class DbManager; class QComboBox; class QLineEdit; class QLabel; +class QPushButton; class ShareInviteDialog : public QDialog { Q_OBJECT @@ -25,6 +26,10 @@ private: void setLink(const QString& link); void onCandidates(const char* chId, int chIdLen, const uint8_t* data, int len); void onLinkReady(const char* chId, int chIdLen, uint64_t requestId, const char* link); + void onLocalDevices(const uint8_t* data, int len); + void onLocalDelivery(const uint8_t* data, int len); + void sendLocalInvite(); + void updateLocalSend(); uint64_t m_requestId = 0; QString m_channelId; @@ -32,4 +37,9 @@ private: QComboBox* m_nodeCombo; QLineEdit* m_linkEdit; QLabel* m_qrLabel; + QWidget* m_localRow; // выбор устройства; скрыт, пока устройств нет + QComboBox* m_devices; + QPushButton* m_sendLocal; + QLabel* m_delivery; + uint64_t m_localRequest = 0; // текущая доставка; 0 — нет ожидания ACK }; diff --git a/tools/chatgui/src/mainwindow.cpp b/tools/chatgui/src/mainwindow.cpp index 4ee0c402..61227ba3 100644 --- a/tools/chatgui/src/mainwindow.cpp +++ b/tools/chatgui/src/mainwindow.cpp @@ -209,6 +209,7 @@ MainWindow::~MainWindow() { s_mainWindow = nullptr; m_cpuLoadTimer->stop(); gui_bridge_set_cpu_load_cb(nullptr); + gui_bridge_set_local_invite_cb(nullptr); DEBUG_DEBUG(DEBUG_CATEGORY_SYS, "desktop CPU indicator polling stopped"); if (m_messageNotification) m_messageNotification->dismiss("shutdown"); m_messageNotification.reset(); @@ -441,6 +442,22 @@ void MainWindow::setupSoundFromConfig() { void MainWindow::setupBridgeCallbacks() { s_mainWindow = this; + gui_bridge_set_local_invite_cb([](const uint8_t* data, int len) { + if (!s_mainWindow || len < 10 || 9 + data[8] >= len) { + DEBUG_WARN(DEBUG_CATEGORY_DISCOVERY, "invalid local invitation event bytes=%d", len); return; + } + QString name = QString::fromUtf8((const char*)data + 9, data[8]); + QString link = QString::fromUtf8((const char*)data + 9 + data[8], len - 9 - data[8]); + auto* window = s_mainWindow; + auto* prompt = new QMessageBox(QMessageBox::Question, QCoreApplication::translate("ChatGui", "Invitation"), + QCoreApplication::translate("ChatGui", "%1 sent an invitation over the local network. Connect?").arg(name), + QMessageBox::Yes | QMessageBox::No, window); + prompt->setTextFormat(Qt::PlainText); prompt->setAttribute(Qt::WA_DeleteOnClose); + QObject::connect(prompt, &QMessageBox::finished, window, [window, link](int result) { + if (result == QMessageBox::Yes) window->onMessageLinkActivated(link); + }); + prompt->open(); + }); gui_bridge_set_cpu_load_cb([](int percent) { if (!s_mainWindow) return; s_mainWindow->m_cpuLoadPending = false; diff --git a/tools/chatgui/src/networksettingspage.cpp b/tools/chatgui/src/networksettingspage.cpp index c903e586..da5e340c 100644 --- a/tools/chatgui/src/networksettingspage.cpp +++ b/tools/chatgui/src/networksettingspage.cpp @@ -2,6 +2,10 @@ // networksettingspage.cpp — Network configuration form (список listen-сокетов) #include "networksettingspage.h" #include "../transport/config_updater.h" +extern "C" { +#include "../../../src/chat/chat_setting.h" +#include "../../../lib/debug_config.h" +} #include #include #include @@ -85,6 +89,15 @@ NetworkSettingsPage::NetworkSettingsPage(QWidget* parent) auto* note = new QLabel(QCoreApplication::translate("ChatGui", "Changes take effect after restart."), this); note->setStyleSheet("color: gray; font-size: 11px;"); layout->addWidget(note); + auto* localTitle = new QLabel(QCoreApplication::translate("ChatGui", "Local network"), this); + localTitle->setStyleSheet("font-weight: bold; font-size: 13px;"); + layout->addWidget(localTitle); + m_localInvitesCheck = new QCheckBox(QCoreApplication::translate("ChatGui", "Receive invitations from the local network"), this); + m_localInvitesCheck->setObjectName(QStringLiteral("localInvitesEnabled")); + m_localInvitesCheck->setChecked(true); layout->addWidget(m_localInvitesCheck); + m_localBroadcastsCheck = new QCheckBox(QCoreApplication::translate("ChatGui", "Broadcast presence on the local network"), this); + m_localBroadcastsCheck->setObjectName(QStringLiteral("localBroadcastsEnabled")); + m_localBroadcastsCheck->setChecked(true); layout->addWidget(m_localBroadcastsCheck); layout->addStretch(); populateInterfaces(); @@ -246,6 +259,7 @@ void NetworkSettingsPage::loadFromConfig(const QString& configPath) { /* auto_sockets из [global] */ bool autoSockets = false; + struct chat_setting_state localSettings = {}; f.setFileName(configPath); if (f.open(QIODevice::ReadOnly | QIODevice::Text)) { QString section; @@ -259,27 +273,40 @@ void NetworkSettingsPage::loadFromConfig(const QString& configPath) { if ((section == "global" || section.isEmpty()) && line.startsWith("auto_sockets=")) { QString v = line.section('=', 1).trimmed(); autoSockets = (v.compare("yes", Qt::CaseInsensitive) == 0 || v == "1"); - break; + } + if (section == "chatserver" && line.contains('=')) { + QByteArray key = line.section('=', 0, 0).trimmed().toUtf8(); + if (key == "local_invites_enabled" || key == "local_broadcasts_enabled") { + QByteArray value = line.section('=', 1).section('#', 0, 0).section(';', 0, 0).trimmed().toUtf8(); + chat_setting_set_state(&localSettings, key.constData(), value.constData()); + } } } f.close(); } m_autoSocketsCheck->setChecked(autoSockets); + m_localInvitesCheck->setChecked(chat_setting_get_int_state(&localSettings, "local_invites_enabled", 1) != 0); + m_localBroadcastsCheck->setChecked(chat_setting_get_int_state(&localSettings, "local_broadcasts_enabled", 1) != 0); refreshServerList(); applyAutoSocketsState(); } -void NetworkSettingsPage::saveToConfig(const QString& configPath) const { - /* 1. Серверные секции — полная перезапись */ - config_rewrite_servers(configPath, m_servers); - - /* 2. auto_sockets */ - config_update_option(configPath, "global", "auto_sockets", - m_autoSocketsCheck->isChecked() ? "yes" : "no", - CONFIG_UPDATE_REPLACE); +bool NetworkSettingsPage::saveToConfig(const QString& configPath) const { + if (!config_rewrite_servers(configPath, m_servers) || + !config_update_option(configPath, "global", "auto_sockets", m_autoSocketsCheck->isChecked() ? "yes" : "no") || + !config_update_option(configPath, "chatserver", "local_invites_enabled", localInvitesEnabled() ? "1" : "0") || + !config_update_option(configPath, "chatserver", "local_broadcasts_enabled", localBroadcastsEnabled() ? "1" : "0")) { + DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "network settings: saving failed path=%s", qPrintable(configPath)); return false; + } + DEBUG_INFO(DEBUG_CATEGORY_DISCOVERY, "LAN permissions saved receive_invites=%d broadcast=%d", + localInvitesEnabled(), localBroadcastsEnabled()); + return true; } +bool NetworkSettingsPage::localInvitesEnabled() const { return m_localInvitesCheck->isChecked(); } +bool NetworkSettingsPage::localBroadcastsEnabled() const { return m_localBroadcastsCheck->isChecked(); } + void NetworkSettingsPage::onAutoSocketsToggled(bool /*on*/) { applyAutoSocketsState(); } diff --git a/tools/chatgui/src/networksettingspage.h b/tools/chatgui/src/networksettingspage.h index aebeabf5..0cdea06e 100644 --- a/tools/chatgui/src/networksettingspage.h +++ b/tools/chatgui/src/networksettingspage.h @@ -19,7 +19,11 @@ public: explicit NetworkSettingsPage(QWidget* parent = nullptr); void loadFromConfig(const QString& configPath); - void saveToConfig(const QString& configPath) const; + /* Сохранить сокеты и LAN-разрешения в INI; false при ошибке записи (подробности в логе). */ + bool saveToConfig(const QString& configPath) const; + /* Значения черновика для применения в ядре после успешного сохранения. */ + bool localInvitesEnabled() const; + bool localBroadcastsEnabled() const; private slots: void onAutoSocketsToggled(bool on); @@ -38,6 +42,8 @@ private: void updateSelectedListItemText(); QCheckBox* m_autoSocketsCheck; + QCheckBox* m_localInvitesCheck; // разрешение входящих LAN-приглашений + QCheckBox* m_localBroadcastsCheck; // рассылка собственного присутствия в LAN QListWidget* m_serverList; QPushButton* m_addBtn; QPushButton* m_removeBtn; diff --git a/tools/chatgui/src/settingsdialog.cpp b/tools/chatgui/src/settingsdialog.cpp index 120795d9..e0a3e613 100644 --- a/tools/chatgui/src/settingsdialog.cpp +++ b/tools/chatgui/src/settingsdialog.cpp @@ -386,7 +386,21 @@ void SettingsDialog::onSave() { } DEBUG_INFO(DEBUG_CATEGORY_CONFIG, "Broadcast settings saved n=%d r=%d; apply on next start", m_streamSubblocks->value(), m_streamRedundant->value()); - m_networkPage->saveToConfig(m_configPath); + if (!m_networkPage->saveToConfig(m_configPath)) { + QMessageBox::warning(this, QCoreApplication::translate("ChatGui", "Settings"), + QCoreApplication::translate("ChatGui", "Failed to save network settings. Check the configuration file.")); return; + } + for (const auto& setting : {qMakePair("local_invites_enabled", m_networkPage->localInvitesEnabled()), + qMakePair("local_broadcasts_enabled", m_networkPage->localBroadcastsEnabled())}) { + if (!gui_bridge_is_uasync_ready() || !gui_bridge_get_inst()) continue; + auto* arg = static_cast(u_calloc(1, sizeof(chat_setting_arg))); + if (!arg) { DEBUG_ERROR(DEBUG_CATEGORY_DISCOVERY, "LAN settings: request allocation failed"); return; } + arg->inst = gui_bridge_get_inst(); + size_t length = strlen(setting.first) + 1; + memcpy(arg->data, setting.first, length); + snprintf(arg->data + length, sizeof(arg->data) - length, "%d", setting.second ? 1 : 0); + gui_bridge_post_uasync_fn(chat_core_set_setting_trampoline, arg); + } /* save channel sort preference to ui_state */ { diff --git a/tools/chatgui/tests/test_gui_language.cpp b/tools/chatgui/tests/test_gui_language.cpp index c0c64a75..e0f69970 100644 --- a/tools/chatgui/tests/test_gui_language.cpp +++ b/tools/chatgui/tests/test_gui_language.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include #include @@ -20,6 +21,27 @@ class GuiLanguageTest : public QObject { private slots: void initTestCase() { debug_config_init(); } + void localNetworkPermissions() { + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QString path = directory.filePath("local.cfg"); + QVERIFY(config_rewrite_servers(path, {})); + NetworkSettingsPage page; page.loadFromConfig(path); + auto* receive = page.findChild("localInvitesEnabled"); + auto* broadcasts = page.findChild("localBroadcastsEnabled"); + QVERIFY(receive && broadcasts); + QVERIFY(page.localInvitesEnabled() && page.localBroadcastsEnabled()); + receive->setChecked(false); broadcasts->setChecked(false); + QVERIFY(page.saveToConfig(path)); + NetworkSettingsPage restored; restored.loadFromConfig(path); + QVERIFY(!restored.localInvitesEnabled() && !restored.localBroadcastsEnabled()); + receive->setChecked(true); QVERIFY(page.saveToConfig(path)); + restored.loadFromConfig(path); + QVERIFY(restored.localInvitesEnabled() && !restored.localBroadcastsEnabled()); + QVERIFY(config_update_option(path, "chatserver", "local_broadcasts_enabled", "yes")); + restored.loadFromConfig(path); QVERIFY(restored.localBroadcastsEnabled()); + } + void systemPreference() { QCOMPARE(resolveGuiLanguage("system", {"ru-RU", "en-US"}), QString("ru")); QCOMPARE(resolveGuiLanguage("system", {"en-GB", "ru-RU"}), QString("en")); diff --git a/tools/chatgui/tests/test_local_invite_ui.cpp b/tools/chatgui/tests/test_local_invite_ui.cpp new file mode 100644 index 00000000..9ddbcd3c --- /dev/null +++ b/tools/chatgui/tests/test_local_invite_ui.cpp @@ -0,0 +1,87 @@ +/* GUI получает готовую ссылку: выбор LAN-устройства не пересобирает её и ACK не означает JOIN. */ +#include "../src/invitedialog.h" +#include "../transport/gui_bridge.h" +#include "../db/db_manager.h" +#include "../../../src/chat/chat_local.h" +#include "../../../src/chat/invite_build.h" +#include "../../../lib/debug_config.h" +#include "../../../lib/mem.h" +#include +#include +#include +#include +#include +#include + +static gui_local_event_fn devices_cb, delivery_cb; +static gui_invite_link_ready_fn link_cb; +static struct chat_local_send_request sent; +static int sends; + +QString DbManager::getDisplayName(quint64 id) const { return QString::number(id); } +struct UTUN_INSTANCE* gui_bridge_get_inst(void) { return reinterpret_cast(1); } +int gui_bridge_is_uasync_ready(void) { return 1; } +void gui_bridge_set_local_devices_cb(gui_local_event_fn cb) { devices_cb = cb; } +void gui_bridge_set_local_delivery_cb(gui_local_event_fn cb) { delivery_cb = cb; } +void gui_bridge_set_invite_link_ready_cb(gui_invite_link_ready_fn cb) { link_cb = cb; } +void gui_bridge_set_invite_candidates_cb(gui_invite_candidates_fn) {} +void gui_bridge_post_uasync_fn(void (*fn)(void*), void* arg) { + if (fn == chat_local_peers_trampoline) return; + if (fn == chat_local_send_trampoline) { sent = *static_cast(arg); sends++; } + u_free(arg); +} + +static QByteArray devices(std::initializer_list ids) { + QByteArray result; + QDataStream stream(&result, QIODevice::WriteOnly); + stream << quint16(ids.size()); + for (quint64 id : ids) { stream << id << quint8(4); stream.writeRawData("Nick", 4); } + return result; +} + +class LocalInviteUiTest : public QObject { + Q_OBJECT +private slots: + void initTestCase() { debug_config_init(); } + + void deliveryAndDeviceChanges() { + { + ShareInviteDialog dialog("42", nullptr); + auto* combo = dialog.findChild("localDevices"); + auto* send = dialog.findChild("sendLocalInvite"); + QVERIFY(combo && send && devices_cb && delivery_cb && link_cb); + QVERIFY(!send->isEnabled()); + auto list = devices({7, 8}); devices_cb((const uint8_t*)list.constData(), list.size()); + QCOMPARE(combo->count(), 2); QVERIFY(!send->isEnabled()); + link_cb("42", 2, 0, "utun://registered-link"); + QVERIFY(send->isEnabled()); combo->setCurrentIndex(1); + list = devices({8, 9, 7}); devices_cb((const uint8_t*)list.constData(), list.size()); + QCOMPARE(combo->currentData().toULongLong(), quint64(8)); + devices_cb((const uint8_t*)list.constData(), list.size() - 1); + QCOMPARE(combo->count(), 3); + QTest::mouseClick(send, Qt::LeftButton); + QCOMPARE(sends, 1); QCOMPARE(sent.peer_id, uint64_t(8)); + QCOMPARE(QByteArray(sent.link), QByteArray("utun://registered-link")); + QVERIFY(!send->isEnabled()); + uint8_t response[17] = {0}; + qToBigEndian(sent.request_id + 1, response); response[16] = 1; + delivery_cb(response, sizeof(response)); QVERIFY(!send->isEnabled()); + qToBigEndian(sent.request_id, response); + delivery_cb(response, sizeof(response)); QVERIFY(send->isEnabled()); + QTest::mouseClick(send, Qt::LeftButton); QCOMPARE(sends, 2); + qToBigEndian(sent.request_id, response); response[16] = LOCAL_DELIVERY_FORBIDDEN; + delivery_cb(response, sizeof(response)); QVERIFY(send->isEnabled()); + bool forbiddenMessage = false; + for (auto* label : dialog.findChildren()) + if (label->text() == "Receiving invitations is forbidden. Enable it in the recipient device's settings.") forbiddenMessage = true; + QVERIFY(forbiddenMessage); + link_cb("42", 2, 0, ""); QVERIFY(!send->isEnabled()); + list = devices({}); devices_cb((const uint8_t*)list.constData(), list.size()); + QCOMPARE(combo->count(), 0); QVERIFY(!send->isEnabled()); + } + QVERIFY(!devices_cb && !delivery_cb && !link_cb); + } +}; + +QTEST_MAIN(LocalInviteUiTest) +#include "test_local_invite_ui.moc" diff --git a/tools/chatgui/translations/chatgui_en.ts b/tools/chatgui/translations/chatgui_en.ts index 7e5db693..e0c873fa 100644 --- a/tools/chatgui/translations/chatgui_en.ts +++ b/tools/chatgui/translations/chatgui_en.ts @@ -3,6 +3,19 @@ ChatGui + Local networkLocal network + Receive invitations from the local networkReceive invitations from the local network + Broadcast presence on the local networkBroadcast presence on the local network + Receiving invitations is forbidden. Enable it in the recipient device's settings.Receiving invitations is forbidden. Enable it in the recipient device's settings. + Failed to save network settings. Check the configuration file.Failed to save network settings. Check the configuration file. + + Devices on the local network:Devices on the local network: + Send to deviceSend to device + Sending invitation…Sending invitation… + Invitation deliveredInvitation delivered + Could not deliver invitationCould not deliver invitation + %1 sent an invitation over the local network. Connect?%1 sent an invitation over the local network. Connect? + Encrypt attachment; approve each recipient's key requestEncrypt attachment; approve each recipient's key request Encrypt attachmentEncrypt attachment Encrypted attachmentEncrypted attachment @@ -1018,8 +1031,8 @@ Buffer drops: %7 · Underruns: %8 - Entry node: - Entry node: + Connect via + Connect via diff --git a/tools/chatgui/translations/chatgui_ru.ts b/tools/chatgui/translations/chatgui_ru.ts index cd4a21f7..64bca8a1 100644 --- a/tools/chatgui/translations/chatgui_ru.ts +++ b/tools/chatgui/translations/chatgui_ru.ts @@ -3,6 +3,19 @@ ChatGui + Local networkЛокальная сеть + Receive invitations from the local networkРазрешить получать приглашения из локальной сети + Broadcast presence on the local networkРазрешить отправлять широковещательные объявления + Receiving invitations is forbidden. Enable it in the recipient device's settings.Приём приглашений запрещён. Разрешите его в настройках устройства получателя. + Failed to save network settings. Check the configuration file.Не удалось сохранить настройки сети. Проверьте файл конфигурации. + + Devices on the local network:Устройства в локальной сети: + Send to deviceОтправить на устройство + Sending invitation…Отправка приглашения… + Invitation deliveredПриглашение доставлено + Could not deliver invitationНе удалось доставить приглашение + %1 sent an invitation over the local network. Connect?%1 прислал приглашение по локальной сети. Подключиться? + Encrypt attachment; approve each recipient's key requestЗашифровать вложение; подтверждать запрос ключа от каждого получателя Encrypt attachmentЗашифровать вложение Encrypted attachmentЗашифрованное вложение @@ -1021,8 +1034,8 @@ Buffer drops: %7 · Underruns: %8 - Entry node: - Узел входа: + Connect via + Подключим через diff --git a/tools/chatgui/transport/gui_bridge.h b/tools/chatgui/transport/gui_bridge.h index 57a8e6c4..714368ff 100644 --- a/tools/chatgui/transport/gui_bridge.h +++ b/tools/chatgui/transport/gui_bridge.h @@ -188,6 +188,12 @@ void gui_bridge_set_invite_link_ready_cb(gui_invite_link_ready_fn cb); typedef void (*gui_invite_candidates_fn)(const char* ch_id, int ch_id_len, const uint8_t* data, int len); void gui_bridge_set_invite_candidates_cb(gui_invite_candidates_fn cb); +/* LAN-события в GUI-потоке; wire-форматы определены CHAT_EVT_LOCAL_* в chat_event.h. */ +typedef void (*gui_local_event_fn)(const uint8_t* data, int len); +void gui_bridge_set_local_devices_cb(gui_local_event_fn cb); +void gui_bridge_set_local_invite_cb(gui_local_event_fn cb); +void gui_bridge_set_local_delivery_cb(gui_local_event_fn cb); + /* Callback для входящего звонка (вызывается из GUI-потока) */ typedef void (*gui_call_incoming_fn)(const char* ch_id, int ch_id_len, uint64_t caller_node_id, uint64_t call_id); void gui_bridge_set_call_incoming_cb(gui_call_incoming_fn cb); diff --git a/tools/chatgui/transport/gui_bridge_impl.cpp b/tools/chatgui/transport/gui_bridge_impl.cpp index cdd56992..c4cc03f6 100644 --- a/tools/chatgui/transport/gui_bridge_impl.cpp +++ b/tools/chatgui/transport/gui_bridge_impl.cpp @@ -53,6 +53,9 @@ static gui_bgp_info_fn g_bgp_info_cb = nullptr; static gui_admin_key_received_fn g_admin_key_received_cb = nullptr; static gui_invite_link_ready_fn g_invite_link_ready_cb = nullptr; static gui_invite_candidates_fn g_invite_candidates_cb = nullptr; +static gui_local_event_fn g_local_devices_cb = nullptr; +static gui_local_event_fn g_local_invite_cb = nullptr; +static gui_local_event_fn g_local_delivery_cb = nullptr; static gui_call_incoming_fn g_call_incoming_cb = nullptr; static gui_call_id_fn g_call_ringing_cb = nullptr; static gui_call_id_fn g_call_accepted_cb = nullptr; @@ -93,6 +96,15 @@ void GuiBridgeReceiver::processPost(int eventType, QByteArray data) { eventType == 16 || eventType == 17 || eventType == 24 || eventType == 25 || eventType == 31 || eventType == 33)) g_network_changed_cb(eventType); switch (eventType) { + case CHAT_EVT_LOCAL_DEVICES: + if (g_local_devices_cb) g_local_devices_cb(d, dlen); + break; + case CHAT_EVT_LOCAL_INVITE: + if (g_local_invite_cb) g_local_invite_cb(d, dlen); + break; + case CHAT_EVT_LOCAL_DELIVERY: + if (g_local_delivery_cb) g_local_delivery_cb(d, dlen); + break; case CHAT_EVT_STREAM_SOURCES: if (g_stream_sources_cb) g_stream_sources_cb(d, dlen); break; @@ -582,6 +594,10 @@ void gui_bridge_set_invite_candidates_cb(gui_invite_candidates_fn cb) { g_invite_candidates_cb = cb; } +void gui_bridge_set_local_devices_cb(gui_local_event_fn cb) { g_local_devices_cb = cb; } +void gui_bridge_set_local_invite_cb(gui_local_event_fn cb) { g_local_invite_cb = cb; } +void gui_bridge_set_local_delivery_cb(gui_local_event_fn cb) { g_local_delivery_cb = cb; } + void gui_bridge_set_call_incoming_cb(gui_call_incoming_fn cb) { g_call_incoming_cb = cb; }