Browse Source

Add local UDP discovery and invitation delivery for chat clients

stream-multiview
evgeny 6 days ago
parent
commit
28826e050c
  1. 1
      lib/debug_config.c
  2. 3
      lib/debug_config.h
  3. 14
      src/Makefile.am
  4. 3
      src/chat/chat_event.c
  5. 5
      src/chat/chat_event.h
  6. 109
      src/chat/chat_local.c
  7. 32
      src/chat/chat_local.h
  8. 2
      src/chat/chat_profile.c
  9. 8
      src/chat/chat_setting.c
  10. 506
      src/local_transport/local_transport.c
  11. 63
      src/local_transport/local_transport.h
  12. 178
      src/local_transport/platform.c
  13. 27
      src/local_transport/platform.h
  14. 41
      src/local_transport/protocol.h
  15. 5
      src/utun_instance.c
  16. 1
      src/utun_instance.h
  17. 6
      tests/Makefile.am
  18. 15
      tests/local_transport_ipv6.sh
  19. 278
      tests/test_local_transport.c
  20. 1
      tools/chatgui-android/app/src/main/AndroidManifest.xml
  21. 1
      tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt
  22. 15
      tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt
  23. 8
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt
  24. 112
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/LocalDiscovery.kt
  25. 5
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt
  26. 79
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/InviteLinkDialog.kt
  27. 32
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt
  28. 11
      tools/chatgui-android/app/src/main/res/values-ru/strings.xml
  29. 11
      tools/chatgui-android/app/src/main/res/values/strings.xml
  30. 32
      tools/chatgui-android/jni_bridge/android_jni_bridge.c
  31. 6
      tools/chatgui/CMakeLists.txt
  32. 106
      tools/chatgui/src/invitedialog.cpp
  33. 10
      tools/chatgui/src/invitedialog.h
  34. 17
      tools/chatgui/src/mainwindow.cpp
  35. 45
      tools/chatgui/src/networksettingspage.cpp
  36. 8
      tools/chatgui/src/networksettingspage.h
  37. 16
      tools/chatgui/src/settingsdialog.cpp
  38. 22
      tools/chatgui/tests/test_gui_language.cpp
  39. 87
      tools/chatgui/tests/test_local_invite_ui.cpp
  40. 17
      tools/chatgui/translations/chatgui_en.ts
  41. 17
      tools/chatgui/translations/chatgui_ru.ts
  42. 6
      tools/chatgui/transport/gui_bridge.h
  43. 16
      tools/chatgui/transport/gui_bridge_impl.cpp

1
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}
};

3
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 */

14
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 \

3
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. */

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

109
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 <string.h>
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);
}

32
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

2
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);
}

8
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) {

506
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 <openssl/rand.h>
#include <string.h>
#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;
}

63
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 <stddef.h>
#include <stdint.h>
#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

178
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 <string.h>
#ifdef _WIN32
#include <iphlpapi.h>
#include <mswsock.h>
#else
#include <ifaddrs.h>
#include <net/if.h>
#ifdef __FreeBSD__
#include <net/if_dl.h>
#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);
}

27
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

41
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 <string.h>
#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

5
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;
}

1
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

6
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

15
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

278
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 <net/if.h>
#include <stdlib.h>
#include <string.h>
#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;
}

1
tools/chatgui-android/app/src/main/AndroidManifest.xml

@ -2,6 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />

1
tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt

@ -31,6 +31,7 @@ object AppEventHandler : ChatEventCallback {
val events: SharedFlow<Pair<Int, ByteArray?>> = _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)

15
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,

8
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") ?: ""

112
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<List<LocalDevice>>(emptyList())
val devices = _devices.asStateFlow()
private val _invitations = MutableStateFlow<List<LocalInvitation>>(emptyList())
val invitations = _invitations.asStateFlow()
private val _delivery = MutableStateFlow<LocalDelivery?>(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<LocalDevice>(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}")
}
}
}

5
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()

79
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<Long?>(null) }
var menuOpen by remember { mutableStateOf(false) }
val devices by LocalDiscovery.devices.collectAsState()
val delivery by LocalDiscovery.delivery.collectAsState()
var selectedDevice by remember { mutableStateOf<Long?>(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 = {

32
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)
}
})
}
}
}
}

11
tools/chatgui-android/app/src/main/res/values-ru/strings.xml

@ -103,6 +103,7 @@
<string name="candidate_self">" (свой узел)"</string>
<string name="invite_link">"Ссылка-приглашение"</string>
<string name="select_node">"Выбор узла…"</string>
<string name="invite_connect_via">Подключим через</string>
<string name="invite_generating">"Генерация ссылки…"</string>
<string name="qr_code">"QR-код"</string>
<string name="invite_build_failed">"Не удалось собрать ссылку"</string>
@ -506,4 +507,14 @@
<string name="attachment_key_denied">Автор отказал в доступе</string>
<string name="attachment_decrypting">Расшифровка…</string>
<string name="attachment_decrypt_failed">Ошибка расшифровки. Нажмите для повтора</string>
<string name="local_invite_devices">Устройства в локальной сети:</string>
<string name="local_invite_send">Отправить на устройство</string>
<string name="local_invite_sending">Отправка приглашения…</string>
<string name="local_invite_delivered">Приглашение доставлено</string>
<string name="local_invite_failed">Не удалось доставить приглашение</string>
<string name="local_invite_received">%1$s прислал приглашение по локальной сети. Подключиться?</string>
<string name="local_network">Локальная сеть</string>
<string name="local_invites_enabled">Разрешить получать приглашения из локальной сети</string>
<string name="local_broadcasts_enabled">Разрешить отправлять широковещательные объявления</string>
<string name="local_invite_forbidden">Приём приглашений запрещён. Разрешите его в настройках устройства получателя.</string>
</resources>

11
tools/chatgui-android/app/src/main/res/values/strings.xml

@ -104,6 +104,7 @@
<string name="candidate_self">" (self)"</string>
<string name="invite_link">"Invite link"</string>
<string name="select_node">"Select node…"</string>
<string name="invite_connect_via">Connect via</string>
<string name="invite_generating">"Generating link…"</string>
<string name="qr_code">"QR code"</string>
<string name="invite_build_failed">"Could not build invite link"</string>
@ -505,4 +506,14 @@
<string name="attachment_key_denied">Access denied by author</string>
<string name="attachment_decrypting">Decrypting…</string>
<string name="attachment_decrypt_failed">Decryption failed. Tap to retry</string>
<string name="local_invite_devices">Devices on the local network:</string>
<string name="local_invite_send">Send to device</string>
<string name="local_invite_sending">Sending invitation…</string>
<string name="local_invite_delivered">Invitation delivered</string>
<string name="local_invite_failed">Could not deliver invitation</string>
<string name="local_invite_received">%1$s sent an invitation over the local network. Connect?</string>
<string name="local_network">Local network</string>
<string name="local_invites_enabled">Receive invitations from the local network</string>
<string name="local_broadcasts_enabled">Broadcast presence on the local network</string>
<string name="local_invite_forbidden">Receiving invitations is forbidden. Enable it in the recipient device\'s settings.</string>
</resources>

32
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;

6
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

106
tools/chatgui/src/invitedialog.cpp

@ -14,14 +14,19 @@
#include <QClipboard>
#include <QApplication>
#include <QSignalBlocker>
#include <QPointer>
#include <QtEndian>
#include <cstring>
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<ShareInviteDialog> 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<int>::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<ShareInviteDialog*>(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<ShareInviteDialog*>(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<Device> devices;
int count = qFromBigEndian<quint16>(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<quint64>(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<quint64>(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();
}

10
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
};

17
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;

45
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 <QVBoxLayout>
#include <QHBoxLayout>
#include <QGridLayout>
@ -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();
}

8
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;

16
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<chat_setting_arg*>(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 */
{

22
tools/chatgui/tests/test_gui_language.cpp

@ -6,6 +6,7 @@
#include <QtTest>
#include <QComboBox>
#include <QCheckBox>
#include <QDialogButtonBox>
#include <QPushButton>
#include <QFile>
@ -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<QCheckBox*>("localInvitesEnabled");
auto* broadcasts = page.findChild<QCheckBox*>("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"));

87
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 <QtTest>
#include <QComboBox>
#include <QPushButton>
#include <QLabel>
#include <QDataStream>
#include <QtEndian>
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<UTUN_INSTANCE*>(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<chat_local_send_request*>(arg); sends++; }
u_free(arg);
}
static QByteArray devices(std::initializer_list<quint64> 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<QComboBox*>("localDevices");
auto* send = dialog.findChild<QPushButton*>("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<quint64>(sent.request_id + 1, response); response[16] = 1;
delivery_cb(response, sizeof(response)); QVERIFY(!send->isEnabled());
qToBigEndian<quint64>(sent.request_id, response);
delivery_cb(response, sizeof(response)); QVERIFY(send->isEnabled());
QTest::mouseClick(send, Qt::LeftButton); QCOMPARE(sends, 2);
qToBigEndian<quint64>(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<QLabel*>())
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"

17
tools/chatgui/translations/chatgui_en.ts

@ -3,6 +3,19 @@
<TS version="2.1" language="en_US" sourcelanguage="en_US">
<context>
<name>ChatGui</name>
<message><source>Local network</source><translation>Local network</translation></message>
<message><source>Receive invitations from the local network</source><translation>Receive invitations from the local network</translation></message>
<message><source>Broadcast presence on the local network</source><translation>Broadcast presence on the local network</translation></message>
<message><source>Receiving invitations is forbidden. Enable it in the recipient device's settings.</source><translation>Receiving invitations is forbidden. Enable it in the recipient device's settings.</translation></message>
<message><source>Failed to save network settings. Check the configuration file.</source><translation>Failed to save network settings. Check the configuration file.</translation></message>
<message><source>Devices on the local network:</source><translation>Devices on the local network:</translation></message>
<message><source>Send to device</source><translation>Send to device</translation></message>
<message><source>Sending invitation…</source><translation>Sending invitation…</translation></message>
<message><source>Invitation delivered</source><translation>Invitation delivered</translation></message>
<message><source>Could not deliver invitation</source><translation>Could not deliver invitation</translation></message>
<message><source>%1 sent an invitation over the local network. Connect?</source><translation>%1 sent an invitation over the local network. Connect?</translation></message>
<message><source>Encrypt attachment; approve each recipient's key request</source><translation>Encrypt attachment; approve each recipient's key request</translation></message>
<message><source>Encrypt attachment</source><translation>Encrypt attachment</translation></message>
<message><source>Encrypted attachment</source><translation>Encrypted attachment</translation></message>
@ -1018,8 +1031,8 @@ Buffer drops: %7 · Underruns: %8</translation>
</message>
<message>
<location filename="../src/invitedialog.cpp" line="-19"/>
<source>Entry node:</source>
<translation>Entry node:</translation>
<source>Connect via</source>
<translation>Connect via</translation>
</message>
<message>
<location filename="../src/mainwindow.cpp" line="+36"/>

17
tools/chatgui/translations/chatgui_ru.ts

@ -3,6 +3,19 @@
<TS version="2.1" language="ru_RU" sourcelanguage="en_US">
<context>
<name>ChatGui</name>
<message><source>Local network</source><translation>Локальная сеть</translation></message>
<message><source>Receive invitations from the local network</source><translation>Разрешить получать приглашения из локальной сети</translation></message>
<message><source>Broadcast presence on the local network</source><translation>Разрешить отправлять широковещательные объявления</translation></message>
<message><source>Receiving invitations is forbidden. Enable it in the recipient device's settings.</source><translation>Приём приглашений запрещён. Разрешите его в настройках устройства получателя.</translation></message>
<message><source>Failed to save network settings. Check the configuration file.</source><translation>Не удалось сохранить настройки сети. Проверьте файл конфигурации.</translation></message>
<message><source>Devices on the local network:</source><translation>Устройства в локальной сети:</translation></message>
<message><source>Send to device</source><translation>Отправить на устройство</translation></message>
<message><source>Sending invitation…</source><translation>Отправка приглашения…</translation></message>
<message><source>Invitation delivered</source><translation>Приглашение доставлено</translation></message>
<message><source>Could not deliver invitation</source><translation>Не удалось доставить приглашение</translation></message>
<message><source>%1 sent an invitation over the local network. Connect?</source><translation>%1 прислал приглашение по локальной сети. Подключиться?</translation></message>
<message><source>Encrypt attachment; approve each recipient's key request</source><translation>Зашифровать вложение; подтверждать запрос ключа от каждого получателя</translation></message>
<message><source>Encrypt attachment</source><translation>Зашифровать вложение</translation></message>
<message><source>Encrypted attachment</source><translation>Зашифрованное вложение</translation></message>
@ -1021,8 +1034,8 @@ Buffer drops: %7 · Underruns: %8</source>
</message>
<message>
<location filename="../src/invitedialog.cpp" line="-19"/>
<source>Entry node:</source>
<translation>Узел входа:</translation>
<source>Connect via</source>
<translation>Подключим через</translation>
</message>
<message>
<location filename="../src/mainwindow.cpp" line="+36"/>

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

16
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;
}

Loading…
Cancel
Save