Use channel member snapshots for desktop participant details

This commit is contained in:
evgeny committed 2026-10-25 20:04:59 +01:00
1 parent c832a5d7a5
commit 539f09004c
18 files changed
+586 -444

No files matched your search

+1 -2
View File
@@ -255,8 +255,7 @@ struct chat_node_arg { struct UTUN_INSTANCE* inst; uint64_t node_id; };
// Сбор полных метрик одного соединения (arg = u_malloc struct chat_node_arg)
void chat_core_collect_conn_metrics_trampoline(void* arg);
// Сбор live-снапшота для панели деталей участника (arg = u_malloc struct chat_node_arg)
void chat_core_collect_member_detail_trampoline(void* arg);
/* Диагностика карточки участника: запрос/codec находятся в chat_member.h. */
// ── Headless CLI API ──
+1 -1
View File
@@ -40,7 +40,7 @@ void chat_event_post(struct UTUN_INSTANCE* inst, int type, const uint8_t* data,
[20]="CONN_LIST", [21]="CONN_METRICS",
[22]="MEMBER_DETAIL", [23]="MEMBER_LIST",
[24]="MEMBER_UPDATED", [25]="MEMBER_REMOVED",
[26]="BGP_INFO", [27]="CONNECTING_NODES",
[27]="CONNECTING_NODES",
[28]="ADMIN_KEY_RECEIVED", [29]="INVITE_LINK_READY",
[30]="INVITE_CANDIDATES", [31]="CHANNEL_DELETED",
[32]="DM_MSG_RECEIVED", [33]="DM_CONV_UPDATED",
+1 -2
View File
@@ -44,11 +44,10 @@ extern "C" {
#define CHAT_EVT_LOCAL_SOCKETS 19 /* JSON: [{"si":id,"nm":"...","ad":"...","po":port,"pt":"TCP|UDP","st":0|1|2,"lk":n},...] */
#define CHAT_EVT_CONN_LIST 20 /* [count:2][entry:46B]* — список ETCP-соединений */
#define CHAT_EVT_CONN_METRICS 21 /* текст — полный дамп метрик выбранного соединения */
#define CHAT_EVT_MEMBER_DETAIL 22 /* [node_id:8][flags:1][own_tcp_active:2][links...][socks...] — live-снапшот для панели деталей участника */
#define CHAT_EVT_MEMBER_DETAIL 22 /* Адресованный ответ диагностики; wire описан в chat_member.h. */
#define CHAT_EVT_MEMBER_LIST 23 /* [ch_id_len:1][ch_id:var][count:2][member:CHAT_MEMBER_DISPLAY_SIZE]* */
#define CHAT_EVT_MEMBER_UPDATED 24 /* [ch_id_len:1][ch_id:var][count:1][member:CHAT_MEMBER_DISPLAY_SIZE] */
#define CHAT_EVT_MEMBER_REMOVED 25 /* [ch_id_len:1][ch_id:var][node_id:8] — мембер удалён из канала */
#define CHAT_EVT_BGP_INFO 26 /* текст: "rtt:XXms hop:X via bgp: [XXXX] name1 [XXXX] name2" */
#define CHAT_EVT_CONNECTING_NODES 27 /* [ch_id_len:1][ch_id:var][count:2][node_id:8]* */
#define CHAT_EVT_ADMIN_KEY_RECEIVED 28 /* [ch_id_len:1][ch_id:var][from_node_id:8] — получен канальный ключ (стал совладельцем) */
#define CHAT_EVT_INVITE_LINK_READY 29 /* [ch_id_len:1][ch_id:var][request_id:8][link_len:2][link:var] — пустой link = ошибка */
+50
View File
@@ -31,6 +31,8 @@
#include <stdint.h>
#include <stddef.h>
struct UTUN_INSTANCE;
#ifdef __cplusplus
extern "C" {
#endif
@@ -97,6 +99,54 @@ void chat_core_toggle_member_radio_mute_trampoline(void* arg);
void chat_member_init(struct UTUN_INSTANCE* inst);
void chat_member_destroy(struct UTUN_INSTANCE* inst);
/* Дополнительная диагностика карточки; online/RTT берутся только из chat_member_display.
* Запрос выделяет GUI через u_calloc и передаёт в uasync; trampoline освобождает его.
* Ответ CHAT_EVT_MEMBER_DETAIL всегда повторяет request_id/group_id/node_id, включая ошибки. */
struct chat_member_detail_req {
struct UTUN_INSTANCE* inst; /* ядро, в uasync которого выполняется сбор */
uint64_t group_id; /* конкретная CHAT-группа */
uint64_t node_id; /* выбранный участник */
uint64_t request_id; /* ненулевой ID запроса, не повторяется при смене выбора */
};
/* Wire LE: request:8, group:8, node:8, result:1, transport_flags:1, own_tcp:2,
* hop_count:1, link_count:1, sock_count:1; далее hops:8*, links:64*, socks:48*.
* transport_flags: bit0=conn_present, bit1=links_up, bit2=initialized; не доступность в группе.
* hops — список выбранного пути от назначения к первому посреднику; у offline/self пустой.
* Ошибка содержит только заголовок.
* link: tcp:1,state:1,status:1,ready:1,server:1,local_family:1,local_addr:16,local_port:2,
* remote_family:1,remote_addr:16,remote_port:2,socket_name:20,reality:1.
* sock: id:4,port:2,links_up:2,links_down:2,ifname:38. Строки завершаются NUL. */
#define CHAT_MEMBER_DETAIL_HEADER_SIZE 31
#define CHAT_MEMBER_DETAIL_MAX_HOPS 16
#define CHAT_MEMBER_DETAIL_MAX_LINKS 16
#define CHAT_MEMBER_DETAIL_MAX_SOCKS 16
#define CHAT_MEMBER_DETAIL_LINK_SIZE 64
#define CHAT_MEMBER_DETAIL_SOCK_SIZE 48
#define CHAT_MEMBER_DETAIL_MAX_SIZE (CHAT_MEMBER_DETAIL_HEADER_SIZE + 8 * CHAT_MEMBER_DETAIL_MAX_HOPS + \
CHAT_MEMBER_DETAIL_LINK_SIZE * CHAT_MEMBER_DETAIL_MAX_LINKS + CHAT_MEMBER_DETAIL_SOCK_SIZE * CHAT_MEMBER_DETAIL_MAX_SOCKS)
enum chat_member_detail_result {
CHAT_MEMBER_DETAIL_OK = 0,
CHAT_MEMBER_DETAIL_UNAVAILABLE = 1, /* чат/группа остановлены */
CHAT_MEMBER_DETAIL_NOT_MEMBER = 2, /* запись участника отсутствует */
CHAT_MEMBER_DETAIL_INVALID = 3, /* неверные аргументы запроса */
CHAT_MEMBER_DETAIL_ERROR = 4 /* ошибка чтения данных участника */
};
/* Проверенный view заимствует массивы из входного пакета; живёт не дольше data. */
struct chat_member_detail_view {
uint64_t request_id, group_id, node_id; /* адресат ответа */
uint8_t result, transport_flags; /* результат и состояние физического транспорта */
uint16_t own_tcp_active; /* TCP-соединения self */
uint8_t hop_count, link_count, sock_count; /* длины массивов */
const uint8_t *hops, *links, *socks; /* неизменяемые wire-массивы */
};
/* Проверить весь пакет до применения: 0=успех, -1=ошибка с логом; out при ошибке не меняется. */
int chat_core_parse_member_detail(const uint8_t* data, size_t len, struct chat_member_detail_view* out);
void chat_core_collect_member_detail_trampoline(void* arg);
#ifdef __cplusplus
}
#endif
+96 -86
View File
@@ -6,6 +6,8 @@
#include "chat_core_priv.h"
#include "chat_event.h"
#include "chat_member.h"
#include "../member_files/protocol.h"
#include "../utun_instance.h"
#include "../ntp_time.h"
@@ -17,6 +19,7 @@
#include "../transport_layer/stcp_link.h"
#include "../transport_layer/pkt_normalizer.h"
#include "../routing_layer/topo_node.h"
#include "../routing_layer/topo_node_sqlite.h"
#include "../routing_layer/topo_group.h"
#include <string.h>
@@ -361,20 +364,49 @@ void chat_core_collect_conn_metrics_trampoline(void* arg) {
u_free(arg);
}
/* ── Live-снапшот для панели деталей участника ──
* packet: [node_id:8][flags:1][own_tcp_active:2][link_count:1](link:63B)*[sock_count:1](sock:48B)*
* flags: bit0=conn_present bit1=conn_up(links_up) bit2=conn_initialized
* link: [is_tcp:1][link_state:1][link_status:1][tcp_ready:1][is_server:1]
* [local_family:1][local_addr:16][local_port:2]
* [remote_family:1][remote_addr:16][remote_port:2]
* [sock_name:20]
* sock: [sock_id:4][port:2][links_up:2][links_down:2][ifname:38]
* Собирается в worker-потоке — читает живые структуры UTUN_INSTANCE безопасно. */
/* Диагностика выбранного участника собирается целиком в uasync; wire описан в chat_member.h. */
#if CHAT_MEMBER_DETAIL_MAX_HOPS != MAX_HOPS
#error "member detail hop limit must match routing"
#endif
#define MEMBER_DETAIL_MAX_LINKS 16
#define MEMBER_DETAIL_MAX_SOCKS 16
#define MEMBER_DETAIL_LINK_SIZE 64
#define MEMBER_DETAIL_SOCK_SIZE 48
/* Проверяем размеры, адресата и все строки до изменения состояния GUI. */
int chat_core_parse_member_detail(const uint8_t* data, size_t len, struct chat_member_detail_view* out) {
if (!data || !out || len < CHAT_MEMBER_DETAIL_HEADER_SIZE) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "member detail: missing header bytes=%zu", len); return -1;
}
struct chat_member_detail_view v = {0};
v.request_id = mf_get64(data); v.group_id = mf_get64(data + 8); v.node_id = mf_get64(data + 16);
v.result = data[24]; v.transport_flags = data[25];
v.own_tcp_active = (uint16_t)data[26] | ((uint16_t)data[27] << 8);
v.hop_count = data[28]; v.link_count = data[29]; v.sock_count = data[30];
size_t expected = CHAT_MEMBER_DETAIL_HEADER_SIZE + (size_t)v.hop_count * 8 +
(size_t)v.link_count * CHAT_MEMBER_DETAIL_LINK_SIZE + (size_t)v.sock_count * CHAT_MEMBER_DETAIL_SOCK_SIZE;
if (!v.request_id || !v.group_id || !v.node_id || v.result > CHAT_MEMBER_DETAIL_ERROR || v.transport_flags > 7 ||
v.hop_count > CHAT_MEMBER_DETAIL_MAX_HOPS || v.link_count > CHAT_MEMBER_DETAIL_MAX_LINKS ||
v.sock_count > CHAT_MEMBER_DETAIL_MAX_SOCKS || expected != len ||
(v.result && (v.transport_flags || v.own_tcp_active || v.hop_count || v.link_count || v.sock_count))) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "member detail: invalid packet request=%llu group=%016llx node=%016llx bytes=%zu expected=%zu",
(unsigned long long)v.request_id, (unsigned long long)v.group_id, (unsigned long long)v.node_id, len, expected);
return -1;
}
v.hops = data + CHAT_MEMBER_DETAIL_HEADER_SIZE; v.links = v.hops + v.hop_count * 8;
v.socks = v.links + v.link_count * CHAT_MEMBER_DETAIL_LINK_SIZE;
for (int i = 0; i < v.hop_count; i++) if (!mf_get64(v.hops + i * 8)) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "member detail: zero hop index=%d", i); return -1;
}
for (int i = 0; i < v.link_count; i++) {
const uint8_t* link = v.links + i * CHAT_MEMBER_DETAIL_LINK_SIZE;
if (!memchr(link + 43, 0, 20) || link[0] > 1 || link[3] > 1 || link[4] > 1 || link[63] > 1 ||
(link[5] && link[5] != 4 && link[5] != 6) || (link[24] && link[24] != 4 && link[24] != 6)) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "member detail: invalid link index=%d", i); return -1;
}
}
for (int i = 0; i < v.sock_count; i++) if (!memchr(v.socks + i * CHAT_MEMBER_DETAIL_SOCK_SIZE + 10, 0, 38)) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "member detail: unterminated interface index=%d", i); return -1;
}
*out = v;
return 0;
}
/* Повторяет Qt: name.lastIndexOf("_v") → name.mid(3, idx - 3) */
static void sock_ifname(const char* name, char* out, size_t out_sz) {
@@ -391,8 +423,35 @@ static void sock_ifname(const char* name, char* out, size_t out_sz) {
out[len] = '\0';
}
static void collect_member_detail(struct UTUN_INSTANCE* inst, uint64_t node_id) {
if (!CC(inst) || !CC(inst)->initialized || !inst) return;
static void collect_member_detail(const struct chat_member_detail_req* req) {
struct UTUN_INSTANCE* inst = req->inst;
uint64_t node_id = req->node_id;
uint8_t buf[CHAT_MEMBER_DETAIL_MAX_SIZE] = {0};
mf_put64(buf, req->request_id); mf_put64(buf + 8, req->group_id); mf_put64(buf + 16, node_id);
struct TOPO_GROUP* group = inst && inst->topo_groups ? topo_groups_find(inst->topo_groups, req->group_id) : NULL;
if (!req->request_id || !req->group_id || !node_id) buf[24] = CHAT_MEMBER_DETAIL_INVALID;
else if (!inst || !CC(inst) || !CC(inst)->initialized || !group || group->group_type != TOPO_GROUP_TYPE_CHAT)
buf[24] = CHAT_MEMBER_DETAIL_UNAVAILABLE;
if (!buf[24]) {
char table[80], sql[160]; peers_table_name(group->channel_id, table, sizeof(table));
snprintf(sql, sizeof(sql), "SELECT 1 FROM \"%s\" WHERE node_id=?", table);
sqlite3_stmt* st = NULL;
int rc = sqlite3_prepare_v2(CC(inst)->db, sql, -1, &st, NULL);
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)node_id);
if (rc == SQLITE_OK) rc = sqlite3_step(st);
if (rc == SQLITE_DONE) buf[24] = CHAT_MEMBER_DETAIL_NOT_MEMBER;
else if (rc != SQLITE_ROW) {
buf[24] = CHAT_MEMBER_DETAIL_ERROR;
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "member detail: query request=%llu rc=%d: %s",
(unsigned long long)req->request_id, rc, sqlite3_errmsg(CC(inst)->db));
}
sqlite3_finalize(st);
}
if (buf[24]) {
DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "member detail: rejected request=%llu group=%016llx node=%016llx result=%u",
(unsigned long long)req->request_id, (unsigned long long)req->group_id, (unsigned long long)node_id, buf[24]);
chat_event_post(inst, CHAT_EVT_MEMBER_DETAIL, buf, CHAT_MEMBER_DETAIL_HEADER_SIZE); return;
}
uint8_t flags = 0;
struct ETCP_CONN* conn = instance_find_conn(inst, node_id);
@@ -401,17 +460,6 @@ static void collect_member_detail(struct UTUN_INSTANCE* inst, uint64_t node_id)
if (conn->links_up) flags |= 2;
if (conn->initialized) flags |= 4;
}
/* bit 3 (0x08): узел присутствует в BGP/topo_group — live-онлайн (self всегда онлайн) */
{
int bgp_present = (node_id == inst->node_id) ? 1 : 0;
struct ll_queue* gl = inst->topo_groups ? inst->topo_groups->group_list : NULL;
for (struct ll_entry* ge = gl ? gl->head : NULL; !bgp_present && ge; ge = ge->next) {
struct TOPO_GROUP* group = (struct TOPO_GROUP*)ge->data;
if (group && topo_node_find_by_id(group, node_id)) bgp_present = 1;
}
if (bgp_present) flags |= 8;
}
uint16_t own_tcp_active = 0;
if (node_id == inst->node_id) {
if (inst->connections) {
@@ -432,23 +480,20 @@ static void collect_member_detail(struct UTUN_INSTANCE* inst, uint64_t node_id)
}
uint8_t link_count = 0;
for (struct ETCP_LINK* lk = conn ? conn->links : NULL; lk && link_count < MEMBER_DETAIL_MAX_LINKS; lk = lk->next)
for (struct ETCP_LINK* lk = conn ? conn->links : NULL; lk && link_count < CHAT_MEMBER_DETAIL_MAX_LINKS; lk = lk->next)
link_count++;
uint8_t sock_count = 0;
for (struct ETCP_SOCKET* s = inst->etcp_sockets; s && sock_count < MEMBER_DETAIL_MAX_SOCKS; s = s->next)
for (struct ETCP_SOCKET* s = inst->etcp_sockets; s && sock_count < CHAT_MEMBER_DETAIL_MAX_SOCKS; s = s->next)
sock_count++;
size_t buf_sz = 12 + (size_t)link_count * MEMBER_DETAIL_LINK_SIZE
+ 1 + (size_t)sock_count * MEMBER_DETAIL_SOCK_SIZE;
uint8_t* buf = (uint8_t*)u_malloc(buf_sz);
if (!buf) { DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "collect_member_detail: buf alloc failed"); return; }
uint8_t* p = buf;
memcpy(p, &node_id, 8); p += 8;
*p++ = flags;
memcpy(p, &own_tcp_active, 2); p += 2;
*p++ = link_count;
struct TOPO_GROUP_NODE* node = topo_node_find_by_id(group, node_id);
uint8_t hop_count = 0;
uint64_t* hops = topo_node_best_hop_list(node, &hop_count, NULL);
buf[25] = flags; buf[26] = (uint8_t)own_tcp_active; buf[27] = (uint8_t)(own_tcp_active >> 8);
buf[28] = hop_count; buf[29] = link_count; buf[30] = sock_count;
uint8_t* p = buf + CHAT_MEMBER_DETAIL_HEADER_SIZE;
for (int i = 0; i < hop_count; i++, p += 8) mf_put64(p, hops[i]);
for (struct ETCP_LINK* lk = conn ? conn->links : NULL; lk && link_count; lk = lk->next, link_count--) {
*p++ = lk->is_tcp ? 1 : 0;
@@ -473,7 +518,7 @@ static void collect_member_detail(struct UTUN_INSTANCE* inst, uint64_t node_id)
lf = 6; memcpy(la, &((const struct sockaddr_in6*)lsa)->sin6_addr, 16); lp = ntohs(((const struct sockaddr_in6*)lsa)->sin6_port);
}
}
*p++ = lf; memcpy(p, la, 16); p += 16; memcpy(p, &lp, 2); p += 2;
*p++ = lf; memcpy(p, la, 16); p += 16; *p++ = (uint8_t)lp; *p++ = (uint8_t)(lp >> 8);
}
{ uint8_t rf = 0; uint8_t ra[16] = {0}; uint16_t rp = 0;
@@ -483,7 +528,7 @@ static void collect_member_detail(struct UTUN_INSTANCE* inst, uint64_t node_id)
} else if (sa->ss_family == AF_INET6) {
rf = 6; memcpy(ra, &((const struct sockaddr_in6*)sa)->sin6_addr, 16); rp = ntohs(((const struct sockaddr_in6*)sa)->sin6_port);
}
*p++ = rf; memcpy(p, ra, 16); p += 16; memcpy(p, &rp, 2); p += 2;
*p++ = rf; memcpy(p, ra, 16); p += 16; *p++ = (uint8_t)rp; *p++ = (uint8_t)(rp >> 8);
}
{ char sn[20] = {0};
@@ -493,7 +538,6 @@ static void collect_member_detail(struct UTUN_INSTANCE* inst, uint64_t node_id)
*p++ = lk->reality_set ? 1 : 0;
}
*p++ = sock_count;
for (struct ETCP_SOCKET* s = inst->etcp_sockets; s && sock_count; s = s->next, sock_count--) {
uint32_t sid = s->sock_id;
uint16_t sp = 0;
@@ -518,56 +562,22 @@ static void collect_member_detail(struct UTUN_INSTANCE* inst, uint64_t node_id)
}
}
char ifname[38] = {0}; sock_ifname(s->name, ifname, sizeof(ifname));
memcpy(p, &sid, 4); p += 4;
memcpy(p, &sp, 2); p += 2;
memcpy(p, &lu, 2); p += 2;
memcpy(p, &ld, 2); p += 2;
for (int i = 0; i < 4; i++) *p++ = (uint8_t)(sid >> (8 * i));
*p++ = (uint8_t)sp; *p++ = (uint8_t)(sp >> 8);
*p++ = (uint8_t)lu; *p++ = (uint8_t)(lu >> 8);
*p++ = (uint8_t)ld; *p++ = (uint8_t)(ld >> 8);
memcpy(p, ifname, 38); p += 38;
}
DEBUG_DEBUG(DEBUG_CATEGORY_CHAT_SYNC, "member detail: request=%llu group=%016llx node=%016llx hops=%u links=%u socks=%u bytes=%zu",
(unsigned long long)req->request_id, (unsigned long long)req->group_id, (unsigned long long)node_id,
buf[28], buf[29], buf[30], (size_t)(p - buf));
chat_event_post(inst, CHAT_EVT_MEMBER_DETAIL, buf, (int)(p - buf));
u_free(buf);
/* summary text: direct RTT or BGP */
if (conn && conn->links_up) {
uint16_t rtt = conn->links ? conn->links->rtt_last : 0;
if (rtt > 0) {
char tbuf[32];
int off = snprintf(tbuf, sizeof(tbuf), "direct, rtt:%ums", rtt / 10);
chat_event_post(inst, CHAT_EVT_BGP_INFO, (const uint8_t*)tbuf, off);
}
} else if (conn && !conn->links_up && node_id != inst->node_id) {
struct ll_queue* gl = inst->topo_groups ? inst->topo_groups->group_list : NULL;
for (struct ll_entry* ge = gl ? gl->head : NULL; ge; ge = ge->next) {
struct TOPO_GROUP* group = (struct TOPO_GROUP*)ge->data;
if (!group) continue;
struct TOPO_GROUP_NODE* nq = topo_node_find_by_id(group, node_id);
if (!nq) continue;
uint8_t hc = 0;
uint64_t* hops = topo_node_best_hop_list(nq, &hc, NULL);
if (hc > 0 && hops) {
char tbuf[512] = {0};
uint16_t cr = topo_get_chain_rtt(nq);
int off = snprintf(tbuf, sizeof(tbuf), "rtt:%ums hop:%u via bgp:", cr / 10, hc);
for (uint8_t hi = 0; hi < hc && off < (int)sizeof(tbuf) - 24; hi++) {
char hn[32] = {0};
get_node_name(inst, hops[hi], hn, sizeof(hn));
uint16_t hid = (uint16_t)(hops[hi] & 0xFFFF);
off += snprintf(tbuf + off, sizeof(tbuf) - (size_t)off,
" [%04X]%s", hid, hn[0] ? " " : "");
if (hn[0]) off += snprintf(tbuf + off, sizeof(tbuf) - (size_t)off, "%s", hn);
}
chat_event_post(inst, CHAT_EVT_BGP_INFO, (const uint8_t*)tbuf, off);
}
break;
}
}
}
void chat_core_collect_member_detail_trampoline(void* arg) {
struct chat_node_arg* a = (struct chat_node_arg*)arg;
if (!a) return;
uint64_t node_id = a->node_id;
collect_member_detail(a->inst, node_id);
u_free(arg);
struct chat_member_detail_req* req = arg;
if (!req) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "member detail: null request"); return; }
collect_member_detail(req);
u_free(req);
}
+55
View File
@@ -15,6 +15,7 @@
#include "chat/chat_core.h"
#include "chat/chat_member.h"
#include "chat/chat_event.h"
#include "member_files/protocol.h"
#include "chat/db_sync.h"
#include "chat/member_sync.h"
#include "media_async/media_async.h"
@@ -62,6 +63,30 @@ static struct TOPO_GROUP* chat_group(struct UTUN_INSTANCE* inst) {
return NULL;
}
static int interrupt_detail_query(void* arg) { (void)arg; return 1; }
static uint8_t detail_reply[CHAT_MEMBER_DETAIL_MAX_SIZE];
static int detail_reply_size, detail_unscoped_events;
static void capture_member_detail(struct UTUN_INSTANCE* inst, int type, const uint8_t* data, int len) {
(void)inst;
if (type == 26) detail_unscoped_events++;
if (type != CHAT_EVT_MEMBER_DETAIL) return;
assert(len >= CHAT_MEMBER_DETAIL_HEADER_SIZE && len <= (int)sizeof(detail_reply));
memcpy(detail_reply, data, (size_t)len); detail_reply_size = len;
}
static struct chat_member_detail_view member_diagnostics(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t node, uint64_t id) {
struct chat_member_detail_req* req = u_calloc(1, sizeof(*req)); assert(req);
req->inst = inst; req->group_id = group; req->node_id = node; req->request_id = id;
detail_reply_size = 0;
chat_core_collect_member_detail_trampoline(req);
struct chat_member_detail_view view = {0};
assert(!chat_core_parse_member_detail(detail_reply, (size_t)detail_reply_size, &view));
assert(view.request_id == id && view.group_id == group && view.node_id == node);
return view;
}
/* Снимок привязан к группе; RTT адреса и факт наличия пути не заменяют живой маршрут. */
static void check_member_snapshots(struct UTUN_INSTANCE* inst, char channels[2][64]) {
struct TOPO_GROUP* groups[2]; struct TOPO_GROUP_NODE* nodes[2];
@@ -98,11 +123,40 @@ static void check_member_snapshots(struct UTUN_INSTANCE* inst, char channels[2][
assert(snapshot.conn_up == nodes[i]->conn_up && snapshot.conn_presence == nodes[i]->conn_presence);
assert(!!(snapshot.flags & CHAT_MEMBER_FLAG_PHONE_BUSY) == i);
}
chat_event_handler_fn saved_handler = inst->chat_event_handler;
inst->chat_event_handler = capture_member_detail;
for (int i = 0; i < 2; i++) {
struct chat_member_detail_view view = member_diagnostics(inst, groups[i]->group_id, 7, 100 + i);
assert(view.result == CHAT_MEMBER_DETAIL_OK && view.hop_count == i + 1);
assert(mf_get64(view.hops) == 7 && mf_get64(view.hops + i * 8) == (uint64_t)(i ? 8 : 7));
}
assert(member_diagnostics(inst, groups[0]->group_id, 9, 103).result == CHAT_MEMBER_DETAIL_NOT_MEMBER);
assert(member_diagnostics(inst, 1, 7, 104).result == CHAT_MEMBER_DETAIL_UNAVAILABLE);
sqlite3_progress_handler(inst->topo_sqlite_db, 1, interrupt_detail_query, NULL);
struct chat_member_detail_view failed = member_diagnostics(inst, groups[0]->group_id, 7, 108);
sqlite3_progress_handler(inst->topo_sqlite_db, 0, NULL, NULL);
assert(failed.result == CHAT_MEMBER_DETAIL_ERROR);
member_diagnostics(inst, groups[1]->group_id, 7, 105);
struct chat_member_detail_view untouched = { .request_id = 999 };
assert(chat_core_parse_member_detail(detail_reply, 0, &untouched) < 0 && untouched.request_id == 999);
assert(chat_core_parse_member_detail(detail_reply, (size_t)detail_reply_size - 1, &untouched) < 0);
uint8_t saved_count = detail_reply[28]; detail_reply[28] = CHAT_MEMBER_DETAIL_MAX_HOPS + 1;
assert(chat_core_parse_member_detail(detail_reply, (size_t)detail_reply_size, &untouched) < 0);
detail_reply[28] = saved_count;
struct chat_member_detail_view valid;
assert(!chat_core_parse_member_detail(detail_reply, (size_t)detail_reply_size, &valid));
if (valid.sock_count) {
memset((uint8_t*)valid.socks + 10, 'x', 38);
assert(chat_core_parse_member_detail(detail_reply, (size_t)detail_reply_size, &untouched) < 0);
}
assert(untouched.request_id == 999 && !detail_unscoped_events);
direct.links_up = 0;
uint8_t wire[CHAT_MEMBER_DISPLAY_SIZE]; struct chat_member_display snapshot;
assert(!chat_core_get_single_member(inst, channels[0], 7, wire)); chat_core_deserialize_member(wire, &snapshot);
assert(!snapshot.online && snapshot.rtt == UINT16_MAX && !(snapshot.flags & CHAT_MEMBER_FLAG_PHONE_KNOWN));
assert(!chat_core_member_online(inst, channels[0], 7) && chat_core_member_online(inst, channels[1], 7));
assert(member_diagnostics(inst, groups[0]->group_id, 7, 106).hop_count == 0);
assert(member_diagnostics(inst, groups[1]->group_id, 7, 107).hop_count == 2);
assert(!topo_group_add_path(nodes[0], &relay, relay_hops, 2, 20));
assert(!chat_core_get_single_member(inst, channels[0], 7, wire)); chat_core_deserialize_member(wire, &snapshot);
assert(snapshot.online && snapshot.rtt == 90 && snapshot.intermediaries == 1);
@@ -112,6 +166,7 @@ static void check_member_snapshots(struct UTUN_INSTANCE* inst, char channels[2][
relay.rtt_last = 65530;
assert(!chat_core_get_single_member(inst, channels[0], 7, wire)); chat_core_deserialize_member(wire, &snapshot);
assert(snapshot.rtt == UINT16_MAX); /* насыщение, без переполнения и подстановки node_RTT */
inst->chat_event_handler = saved_handler;
for (int i = 0; i < 2; i++) {
topo_nodeq_clear_paths(nodes[i]); queue_remove_data(groups[i]->nodes, &nodes[i]->ll); queue_entry_free(&nodes[i]->ll);
assert(!topo_node_sqlite_member_del(inst->topo_sqlite_db, channels[i], 7));
@@ -208,59 +208,6 @@ fun MemberWire.toChatMember(previous: ChatMember = ChatMember(nodeId, name)): Ch
fun cleanSocketName(raw: String): String =
raw.removePrefix("as_").replace(Regex("_v[46]_(tcp|udp)$"), " $1")
/* ── MEMBER_DETAIL (event 22) link format: 64 bytes per link ── */
private const val MEMBER_DETAIL_LINK_SIZE = 64
private fun addrFromBytes(family: Int, bytes: ByteArray): String {
if (family == 4 && bytes.size >= 4)
return "${bytes[0].toInt() and 0xFF}.${bytes[1].toInt() and 0xFF}.${bytes[2].toInt() and 0xFF}.${bytes[3].toInt() and 0xFF}"
if (family == 6 && bytes.size >= 16)
return bytes.take(16).joinToString(":") { "%02x".format(it) }
return "?"
}
fun parseMemberDetail(data: ByteArray): MemberLinks? {
if (data.size < 12) return null
val buf = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN)
buf.getLong()
val flags = buf.get().toInt() and 0xFF
buf.getShort()
val linkCount = buf.get().toInt() and 0xFF
val links = mutableListOf<LinkStatus>()
var offset = 12
for (i in 0 until linkCount) {
if (offset + MEMBER_DETAIL_LINK_SIZE > data.size) break
val lbuf = ByteBuffer.wrap(data, offset, MEMBER_DETAIL_LINK_SIZE).order(ByteOrder.LITTLE_ENDIAN)
val isTcp = lbuf.get().toInt() and 0xFF
val state = lbuf.get().toInt() and 0xFF
val status = lbuf.get().toInt() and 0xFF
val tcpReady = lbuf.get().toInt() and 0xFF
val isServer = lbuf.get().toInt() and 0xFF
val lf = lbuf.get().toInt() and 0xFF; val la = ByteArray(16); lbuf.get(la)
val lp = lbuf.getShort().toInt() and 0xFFFF
val rf = lbuf.get().toInt() and 0xFF; val ra = ByteArray(16); lbuf.get(ra)
val rp = lbuf.getShort().toInt() and 0xFFFF
val snBytes = ByteArray(20); lbuf.get(snBytes)
val sn = String(snBytes).trimEnd('\u0000').trimEnd()
val isReality = lbuf.get().toInt() != 0
val effState = if (isTcp != 0 && tcpReady != 0) 3 else state
val effStatus = if (isTcp != 0 && tcpReady != 0) 1 else status
links.add(LinkStatus(
localFamily = lf, localAddr = addrFromBytes(lf, la), localPort = lp,
remoteFamily = rf, remoteAddr = addrFromBytes(rf, ra), remotePort = rp,
linkState = effState, linkStatus = effStatus, isServer = isServer != 0,
socketName = sn, protocol = if (isReality) "TCP+REALITY" else if (isTcp != 0) "TCP" else "UDP"
))
offset += MEMBER_DETAIL_LINK_SIZE
}
return MemberLinks(
connected = (flags and 1) != 0, initialized = (flags and 2) != 0,
linksUp = if ((flags and 4) != 0) 1 else 0, links = links
)
}
class ChatRepository {
private val myNodeId: Long = NativeLib.getMyNodeId()
@@ -715,15 +715,6 @@ class ChatViewModel : ViewModel() {
memberDetailJob?.cancel(); memberDetailTarget = null; _selectedMember.value = null
} else refreshSelectedMemberDetail(channel, node)
}
22 -> { /* MEMBER_DETAIL: бинарный снапшот links+sockets */
if (data == null || data.size < 12) return
val ml = parseMemberDetail(data) ?: return
val buf = java.nio.ByteBuffer.wrap(data).order(java.nio.ByteOrder.LITTLE_ENDIAN)
val nodeId = buf.getLong()
if (_selectedMember.value?.nodeId == nodeId) {
_memberLinks.value = ml
}
}
27 -> { /* CONNECTING_NODES: [ch_id_len:1][ch_id:var][count:2][node_id:8]* */
if (data == null || data.size < 3) return
val cl = data[0].toInt() and 0xFF
+6
View File
@@ -611,6 +611,12 @@ target_link_libraries(test_radio_member_mute PRIVATE ${QT_CORE} utun_voice utun
add_test(NAME test_radio_member_mute COMMAND test_radio_member_mute)
set(RADIO_MEMBER_TEST_SOURCES src/memberlistmodel.cpp src/avatars/avatarcache.cpp src/avatars/avatarmanager.cpp
src/avatars/avatarcrop.cpp src/avatars/avatargallery.cpp src/imageviewer_window.cpp db/db_manager.cpp)
add_executable(test_member_detail tests/test_member_detail.cpp src/accountlist.cpp src/accountdelegate.cpp
src/flagpainter.cpp src/memberpropsdialog.cpp src/renamedialog.cpp ${RADIO_MEMBER_TEST_SOURCES})
target_include_directories(test_member_detail PRIVATE ${UTUN_INCLUDE_DIRS})
target_link_libraries(test_member_detail PRIVATE ${QT_LIBS} ${QT_TEST_LIB} utun_voice utun pthread)
add_test(NAME test_member_detail COMMAND test_member_detail)
set_tests_properties(test_member_detail PROPERTIES TIMEOUT 20 ENVIRONMENT "QT_QPA_PLATFORM=offscreen")
add_executable(test_radio_mute_bridge tests/test_radio_mute_bridge.cpp transport/utun_node.cpp transport/gui_bridge_impl.cpp transport/stream_bridge.cpp
src/accountdelegate.cpp src/flagpainter.cpp ${RADIO_MEMBER_TEST_SOURCES})
target_include_directories(test_radio_mute_bridge PRIVATE ${UTUN_INCLUDE_DIRS})
+141 -170
View File
@@ -15,6 +15,8 @@
#include <QFont>
#include <QSet>
#include <QMessageBox>
#include <QShowEvent>
#include <QHideEvent>
#include <algorithm>
#include <cstring>
#include "../../../lib/platform_compat.h"
@@ -23,16 +25,17 @@ extern "C" {
#include "../lib/socket_compat.h"
#include "../lib/debug_config.h"
#include "chat/chat_core.h"
#include "routing_layer/topo_node.h"
#include "member_files/protocol.h"
#include "../../lib/mem.h"
}
static AccountList* g_detailTarget = nullptr;
static quint64 g_nextDetailRequest = 0; // только GUI-поток, не сбрасывается при пересоздании панели
static void memberDetailCb(const uint8_t* data, int len) {
if (!g_detailTarget) return;
if (len < 13) return;
uint64_t nodeId; memcpy(&nodeId, data, 8);
g_detailTarget->onMemberDetailData(nodeId, data, len);
g_detailTarget->onMemberDetailData(data, len);
}
QString AccountList::addrStr(const void* addr, int family) {
@@ -124,11 +127,13 @@ AccountList::AccountList(DbManager* db, QWidget *parent)
detailLayout->setSpacing(0);
m_detailHeader = new QLabel(QCoreApplication::translate("ChatGui", "Select a member"), m_detailPanel);
m_detailHeader->setObjectName("memberDetailHeader");
m_detailHeader->setWordWrap(true);
m_detailHeader->setStyleSheet("font-weight: bold; padding: 4px 8px; background: palette(window);");
detailLayout->addWidget(m_detailHeader);
m_detailText = new QTextEdit(m_detailPanel);
m_detailText->setObjectName("memberDetailText");
m_detailText->setReadOnly(true);
m_detailText->setFont(QFont("monospace", 9));
m_detailText->setStyleSheet("QTextEdit { border: none; padding: 4px; background: palette(base); }");
@@ -150,15 +155,11 @@ AccountList::AccountList(DbManager* db, QWidget *parent)
connect(m_listView, &QWidget::customContextMenuRequested,
this, &AccountList::onMemberContextMenu);
m_detailClock.start();
m_detailTimer = new QTimer(this);
m_detailTimer->setInterval(5000);
m_detailTimer->setInterval(1000);
connect(m_detailTimer, &QTimer::timeout, this, &AccountList::onDetailTimerTick);
m_linkTimer = new QTimer(this);
m_linkTimer->setInterval(1000);
connect(m_linkTimer, &QTimer::timeout, this, [this]{
if (m_detailNodeId != 0) requestMemberDetail();
});
connect(m_model, &MemberListModel::snapshotChanged, this, &AccountList::onSnapshotChanged);
g_detailTarget = this;
gui_bridge_set_radio_mute_result_cb([](const uint8_t* data, int len) {
@@ -168,25 +169,17 @@ AccountList::AccountList(DbManager* db, QWidget *parent)
});
gui_bridge_set_member_detail_cb(memberDetailCb);
static bool nodeinfoRegistered = false;
if (!nodeinfoRegistered) {
gui_bridge_set_nodeinfo_update_cb([](const uint8_t* data, int len) {
if (g_detailTarget) g_detailTarget->onNodeinfoUpdate(data, len);
});
gui_bridge_set_bgp_info_cb([](const uint8_t* data, int len) {
if (!g_detailTarget) return;
g_detailTarget->m_bgpText = QString::fromUtf8((const char*)data, len);
g_detailTarget->updateDetailDisplay();
});
nodeinfoRegistered = true;
}
}
AccountList::~AccountList() {
if (g_detailTarget == this) { g_detailTarget = nullptr; gui_bridge_set_radio_mute_result_cb(nullptr); }
clearMemberDetail();
if (g_detailTarget == this) {
g_detailTarget = nullptr; gui_bridge_set_radio_mute_result_cb(nullptr); gui_bridge_set_member_detail_cb(nullptr);
}
}
void AccountList::setChannel(const QString& channelId) {
if (m_channelId != channelId) clearMemberDetail();
m_channelId = channelId;
bool isOwner = false;
@@ -198,11 +191,6 @@ void AccountList::setChannel(const QString& channelId) {
}
m_model->setChannel(channelId, isOwner);
m_detailTimer->stop();
m_linkTimer->stop();
m_detailNodeId = 0;
m_detailHeader->setText(QCoreApplication::translate("ChatGui", "Select a member"));
m_detailText->clear();
m_header->setText(isOwner ? QCoreApplication::translate("ChatGui", "Members 🔑") : QCoreApplication::translate("ChatGui", "Members"));
m_header->setToolTip(isOwner ? QCoreApplication::translate("ChatGui", "Channel owner (has private key)") : QString());
@@ -216,68 +204,71 @@ void AccountList::onMemberClicked(const QModelIndex& index) {
if (nodeId) showMemberDetail(nodeId);
}
void AccountList::clearMemberDetail() {
if (m_detailNodeId || m_detailRequest) DEBUG_DEBUG(DEBUG_CATEGORY_CONNECTION,
"member detail cleared channel=%s node=%016llx request=%llu", qPrintable(m_channelId),
(unsigned long long)m_detailNodeId, (unsigned long long)m_detailRequest);
m_detailTimer->stop(); m_detailNodeId = 0; m_detailRequest = 0; m_detailDirty = false;
m_detailHops.clear(); m_snapLinks.clear(); m_snapSocks.clear(); m_detailAddrs.clear();
m_detailLastSeen = 0; m_detailCreated = 0;
m_detailHeader->setText(QCoreApplication::translate("ChatGui", "Select a member")); m_detailText->clear();
}
void AccountList::showEvent(QShowEvent* event) {
QWidget::showEvent(event);
if (m_detailNodeId) { m_detailTimer->start(); requestMemberDetail(); }
}
void AccountList::hideEvent(QHideEvent* event) {
m_detailTimer->stop(); m_detailRequest = 0; m_detailHops.clear();
QWidget::hideEvent(event);
}
void AccountList::onSnapshotChanged(quint64 nodeId) {
if (!m_detailNodeId || (nodeId && nodeId != m_detailNodeId)) return;
if (!m_model->member(m_detailNodeId)) { clearMemberDetail(); return; }
m_detailDirty = true; m_detailHops.clear();
for (int row = 0; row < m_model->rowCount(); row++) if (m_model->nodeIdAt(row) == m_detailNodeId) {
m_listView->setCurrentIndex(m_model->index(row)); break;
}
updateDetailDisplay();
requestMemberDetail();
}
void AccountList::showMemberDetail(quint64 nodeId) {
if (!m_db || !m_db->m_db) return;
m_detailTimer->stop();
m_linkTimer->stop();
m_detailNodeId = nodeId;
m_detailName.clear();
m_detailOnline = false;
m_detailLastSeen = 0;
m_detailCreated = 0;
m_detailAddrs.clear();
m_snapConnPresent = false;
m_snapConnUp = false;
m_snapConnInit = false;
m_snapOwnTcpActive = 0;
m_snapChainRtt = 0;
m_snapHopCount = 0;
m_snapBgpNodes = 0;
m_bgpText.clear();
m_snapLinks.clear();
m_snapSocks.clear();
if (!m_model->member(nodeId)) return;
clearMemberDetail(); m_detailNodeId = nodeId;
DEBUG_DEBUG(DEBUG_CATEGORY_CONNECTION, "member detail selected channel=%s node=%016llx", qPrintable(m_channelId),
(unsigned long long)nodeId);
readDetailMetadata(); updateDetailDisplay();
if (isVisible()) { m_detailTimer->start(); requestMemberDetail(); }
}
/* Только статические сведения БД; имя и live-состояние принадлежат модели участников. */
void AccountList::readDetailMetadata() {
m_detailAddrs.clear(); m_detailLastSeen = 0; m_detailCreated = 0;
if (!m_db || !m_db->m_db || !m_detailNodeId) return;
quint64 nodeId = m_detailNodeId;
sqlite3* d = m_db->m_db;
sqlite3_stmt* st = nullptr;
if (sqlite3_prepare_v2(d,
"SELECT name, last_seen_at, created_at FROM nodes WHERE node_id=?",
-1, &st, nullptr) == SQLITE_OK) {
if (sqlite3_prepare_v2(d, "SELECT last_seen_at,created_at FROM nodes WHERE node_id=?", -1, &st, nullptr) == SQLITE_OK) {
sqlite3_bind_int64(st, 1, (sqlite3_int64)nodeId);
if (sqlite3_step(st) == SQLITE_ROW) {
m_detailName = DbManager::colText(st, 0);
m_detailLastSeen = sqlite3_column_int64(st, 1);
m_detailCreated = sqlite3_column_int64(st, 2);
}
int rc = sqlite3_step(st);
if (rc == SQLITE_ROW) { m_detailLastSeen = sqlite3_column_int64(st, 0); m_detailCreated = sqlite3_column_int64(st, 1); }
else if (rc != SQLITE_DONE) DEBUG_ERROR(DEBUG_CATEGORY_CONNECTION, "member detail dates read: %s", sqlite3_errmsg(d));
sqlite3_finalize(st);
}
if (m_detailName.isEmpty())
m_detailName = QString("0x%1").arg(nodeId, 8, 16, QChar('0')).right(4).toUpper();
/* prefer local nickname */
QString tbl = m_db->peersTableName(m_channelId);
sqlite3_stmt* nickSt = nullptr;
QByteArray nickSql = QString(
"SELECT local_nick FROM \"%1\" WHERE node_id=?"
).arg(tbl).toUtf8();
if (sqlite3_prepare_v2(d, nickSql.constData(), -1, &nickSt, nullptr) == SQLITE_OK) {
sqlite3_bind_int64(nickSt, 1, (sqlite3_int64)nodeId);
if (sqlite3_step(nickSt) == SQLITE_ROW) {
QString n = DbManager::colText(nickSt, 0);
if (!n.isEmpty()) m_detailName = n;
}
sqlite3_finalize(nickSt);
}
} else DEBUG_ERROR(DEBUG_CATEGORY_CONNECTION, "member detail dates prepare: %s", sqlite3_errmsg(d));
sqlite3_stmt* na = nullptr;
sqlite3_prepare_v2(d,
if (sqlite3_prepare_v2(d,
"SELECT family, protocol, address, port, socket_id, addr_type, nat_mapping, nat_inbound, nat_source"
" FROM node_addresses WHERE node_id=?",
-1, &na, nullptr);
" FROM node_addresses WHERE node_id=?", -1, &na, nullptr) != SQLITE_OK) {
DEBUG_ERROR(DEBUG_CATEGORY_CONNECTION, "member detail addresses prepare: %s", sqlite3_errmsg(d)); return;
}
if (na) {
sqlite3_bind_int64(na, 1, (sqlite3_int64)nodeId);
while (sqlite3_step(na) == SQLITE_ROW) {
int rc;
while ((rc = sqlite3_step(na)) == SQLITE_ROW) {
DetailAddress a;
a.family = sqlite3_column_int(na, 0);
a.protocol = sqlite3_column_int(na, 1);
@@ -292,6 +283,7 @@ void AccountList::showMemberDetail(quint64 nodeId) {
a.nat_source = sqlite3_column_int(na, 8);
if (!a.addr.isEmpty()) m_detailAddrs.append(a);
}
if (rc != SQLITE_DONE) DEBUG_ERROR(DEBUG_CATEGORY_CONNECTION, "member detail addresses read: %s", sqlite3_errmsg(d));
sqlite3_finalize(na);
}
@@ -304,100 +296,84 @@ void AccountList::showMemberDetail(quint64 nodeId) {
return a.port < b.port;
});
m_detailTimer->start();
m_linkTimer->start();
updateDetailDisplay();
requestMemberDetail();
}
void AccountList::requestMemberDetail() {
if (m_detailNodeId == 0) return;
struct chat_node_arg* arg = (struct chat_node_arg*)u_malloc(sizeof(struct chat_node_arg));
if (!arg) { DEBUG_WARN(DEBUG_CATEGORY_CONNECTION, "AccountList: req alloc failed"); return; }
arg->inst = gui_bridge_get_inst();
arg->node_id = m_detailNodeId;
gui_bridge_post_uasync_fn(chat_core_collect_member_detail_trampoline, arg);
if (!m_detailNodeId || !isVisible() || !gui_bridge_is_uasync_ready()) return;
const qint64 now = m_detailClock.elapsed();
if (m_detailRequest && now - m_detailRequestedAt < 5000) return;
if (m_detailRequest) DEBUG_WARN(DEBUG_CATEGORY_CONNECTION, "member detail timeout channel=%s node=%016llx request=%llu",
qPrintable(m_channelId), (unsigned long long)m_detailNodeId, (unsigned long long)m_detailRequest);
auto* req = (chat_member_detail_req*)u_calloc(1, sizeof(chat_member_detail_req));
if (!req) { DEBUG_ERROR(DEBUG_CATEGORY_CONNECTION, "member detail request allocation failed"); m_detailRequest = 0; return; }
req->inst = gui_bridge_get_inst(); req->group_id = m_channelId.toULongLong(); req->node_id = m_detailNodeId;
if (++g_nextDetailRequest == 0) ++g_nextDetailRequest;
req->request_id = g_nextDetailRequest;
m_detailRequest = req->request_id; m_detailRequestedAt = now; m_detailDirty = false;
DEBUG_TRACE(DEBUG_CATEGORY_CONNECTION, "member detail requested channel=%s node=%016llx request=%llu", qPrintable(m_channelId),
(unsigned long long)m_detailNodeId, (unsigned long long)m_detailRequest);
gui_bridge_post_uasync_fn(chat_core_collect_member_detail_trampoline, req);
}
void AccountList::onMemberDetailData(uint64_t nodeId, const uint8_t* data, int len) {
if (nodeId != m_detailNodeId) return; /* stale response from earlier selection */
if (len < 13) return;
const uint8_t* p = data;
/* skip node_id(8) */
p += 8;
uint8_t flags = *p++;
m_snapConnPresent = (flags & 1) != 0;
m_snapConnUp = (flags & 2) != 0;
m_snapConnInit = (flags & 4) != 0;
m_detailOnline = (flags & 8) != 0;
uint16_t ownTcpActive;
memcpy(&ownTcpActive, p, 2); p += 2;
m_snapOwnTcpActive = ownTcpActive;
uint8_t linkCount = *p++;
m_snapLinks.clear();
for (int i = 0; i < linkCount; i++) {
if (p + MEMBER_DETAIL_LINK_SIZE > data + len) break;
MemberLinkSnap lk;
lk.isTcp = p[0] != 0;
lk.linkState = p[1];
lk.linkStatus = p[2];
lk.tcpReady = p[3] != 0;
lk.isServer = p[4] != 0;
lk.localFamily = p[5];
lk.localAddr = QByteArray((const char*)p + 6, 16);
memcpy(&lk.localPort, p + 22, 2);
lk.family = p[24];
lk.addr = QByteArray((const char*)p + 25, 16);
memcpy(&lk.port, p + 41, 2);
memcpy(lk.sockName, p + 43, 20); lk.sockName[19] = '\0';
lk.isReality = p[63] != 0;
p += MEMBER_DETAIL_LINK_SIZE;
m_snapLinks.append(lk);
void AccountList::onMemberDetailData(const uint8_t* data, int len) {
chat_member_detail_view view{};
if (len < 0 || chat_core_parse_member_detail(data, (size_t)len, &view) < 0) return;
if (!m_detailRequest || view.request_id != m_detailRequest || view.group_id != m_channelId.toULongLong() ||
view.node_id != m_detailNodeId) {
DEBUG_TRACE(DEBUG_CATEGORY_CONNECTION, "member detail ignored stale request=%llu group=%016llx node=%016llx",
(unsigned long long)view.request_id, (unsigned long long)view.group_id, (unsigned long long)view.node_id); return;
}
if (p >= data + len) { updateDetailDisplay(); return; }
uint8_t sockCount = *p++;
m_snapSocks.clear();
for (int i = 0; i < sockCount; i++) {
if (p + MEMBER_DETAIL_SOCK_SIZE > data + len) break;
MemberSockSnap s;
memcpy(&s.sockId, p, 4);
memcpy(&s.port, p + 4, 2);
uint16_t lu = 0, ld = 0; memcpy(&lu, p + 6, 2); memcpy(&ld, p + 8, 2);
s.linksUp = lu; s.linksDown = ld;
s.ifname = QString::fromUtf8((const char*)p + 10).trimmed();
p += MEMBER_DETAIL_SOCK_SIZE;
m_snapSocks.append(s);
m_detailRequest = 0;
if (view.result != CHAT_MEMBER_DETAIL_OK) {
DEBUG_WARN(DEBUG_CATEGORY_CONNECTION, "member detail failed channel=%s node=%016llx request=%llu result=%u",
qPrintable(m_channelId), (unsigned long long)m_detailNodeId, (unsigned long long)view.request_id, view.result);
m_detailHops.clear(); m_snapLinks.clear(); m_snapSocks.clear(); updateDetailDisplay(); return;
}
if (m_detailDirty) { requestMemberDetail(); return; }
QVector<MemberLinkSnap> links;
for (int i = 0; i < view.link_count; i++) {
const uint8_t* p = view.links + i * CHAT_MEMBER_DETAIL_LINK_SIZE;
MemberLinkSnap lk{};
lk.isTcp = p[0] != 0; lk.linkState = p[1]; lk.linkStatus = p[2]; lk.tcpReady = p[3] != 0; lk.isServer = p[4] != 0;
lk.localFamily = p[5]; lk.localAddr = QByteArray((const char*)p + 6, 16);
lk.localPort = (uint16_t)p[22] | ((uint16_t)p[23] << 8);
lk.family = p[24]; lk.addr = QByteArray((const char*)p + 25, 16);
lk.port = (uint16_t)p[41] | ((uint16_t)p[42] << 8);
memcpy(lk.sockName, p + 43, 20); lk.isReality = p[63] != 0; links.append(lk);
}
QVector<MemberSockSnap> socks;
for (int i = 0; i < view.sock_count; i++) {
const uint8_t* p = view.socks + i * CHAT_MEMBER_DETAIL_SOCK_SIZE;
MemberSockSnap s{};
uint32_t id = 0; for (int j = 0; j < 4; j++) id |= (uint32_t)p[j] << (8 * j);
s.sockId = id; s.port = (uint16_t)p[4] | ((uint16_t)p[5] << 8);
s.linksUp = (uint16_t)p[6] | ((uint16_t)p[7] << 8); s.linksDown = (uint16_t)p[8] | ((uint16_t)p[9] << 8);
s.ifname = QString::fromUtf8((const char*)p + 10); socks.append(s);
}
QVector<quint64> hops;
for (int i = 0; i < view.hop_count; i++) hops.append(mf_get64(view.hops + i * 8));
m_snapLinks = links; m_snapSocks = socks; m_detailHops = hops;
readDetailMetadata();
/* resolve interface names from snapshot sockets (by socket_id) */
for (auto& a : m_detailAddrs) {
for (const auto& s : m_snapSocks) {
if (s.sockId == a.socket_id) { a.ifname = s.ifname; break; }
}
}
std::sort(m_detailAddrs.begin(), m_detailAddrs.end(),
[](const DetailAddress& a, const DetailAddress& b) {
if (a.socket_id != b.socket_id) return a.socket_id < b.socket_id;
if (a.addr_type != b.addr_type) return a.addr_type < b.addr_type;
if (a.addr != b.addr) return a.addr < b.addr;
if (a.protocol != b.protocol) return a.protocol < b.protocol;
return a.port < b.port;
});
updateDetailDisplay();
}
void AccountList::updateDetailDisplay() {
bool isSelf = m_db ? (m_detailNodeId == m_db->myNodeId()) : false;
const auto member = m_model->member(m_detailNodeId);
if (!member) return;
const bool isSelf = member->is_self != 0;
QString nodeHex = QString("0x%1").arg(m_detailNodeId, 16, 16, QChar('0'));
/* ── Header ── */
QString dotColor = m_detailOnline ? "#4CAF50" : "#9E9E9E";
QString dotColor = member->online ? "#4CAF50" : "#9E9E9E";
QStringList hdr;
hdr.append(QString("<span style='color:%1;'>●</span> %2 %3")
.arg(dotColor, m_detailName.toHtmlEscaped(), nodeHex));
.arg(dotColor, QString::fromUtf8(member->name).toHtmlEscaped(), nodeHex));
if (m_detailLastSeen > 0)
hdr.append(QCoreApplication::translate("ChatGui", "seen %1").arg(relativeTimeStr(m_detailLastSeen)));
if (m_detailCreated > 0)
@@ -455,11 +431,20 @@ void AccountList::updateDetailDisplay() {
lines.append(QString(" %1").arg(lk.sockName));
}
}
/* summary */
if (!m_bgpText.isEmpty()) {
lines.append(m_bgpText);
} else if (m_snapConnUp) {
lines.append("UP");
QString route = !member->online ? QCoreApplication::translate("ChatGui", "offline") :
member->conn_up & NCONN_DIRECT ? "D" : member->conn_up & NCONN_INDIRECT ? "I" : "B";
if (member->online && member->rtt != 0xffff) route += QString(" · rtt=%1ms").arg(member->rtt / 10.0, 0, 'f', 1);
if (member->online && member->intermediaries) route += QString(" · hop:%1").arg(member->intermediaries);
lines.prepend(route);
if (!m_detailHops.isEmpty() && member->online) {
QStringList hops;
for (auto it = m_detailHops.crbegin(); it != m_detailHops.crend(); ++it) {
QString label = QString("%1").arg(*it, 16, 16, QChar('0'));
const auto hop = m_model->member(*it);
if (hop) label += ' ' + QString::fromUtf8(hop->name).toHtmlEscaped();
hops.append(label);
}
lines.append(QString("BGP: ") + hops.join(" → "));
}
}
@@ -469,20 +454,6 @@ void AccountList::updateDetailDisplay() {
m_detailText->setHtml(QString("<pre style='margin:0;'>") + lines.join("\n") + "</pre>");
}
void AccountList::onNodeinfoUpdate(const uint8_t* data, int len) {
if (len < 16 || m_detailNodeId == 0) return;
uint64_t nodeId; memcpy(&nodeId, data, 8);
if (nodeId != m_detailNodeId) return;
m_snapConnPresent = (data[8] & 1) != 0;
m_snapConnUp = (data[9] & 1) != 0;
memcpy(&m_snapChainRtt, data + 10, 2);
m_snapHopCount = data[12];
memcpy(&m_snapBgpNodes, data + 14, 2);
/* data[13] is reserved/unused */
if (m_snapConnPresent)
updateDetailDisplay();
}
void AccountList::onDetailTimerTick() {
if (m_detailNodeId != 0) requestMemberDetail();
}
+13 -13
View File
@@ -60,8 +60,7 @@ public:
void setChannel(const QString& channelId);
void refresh();
void onMemberDetailData(uint64_t nodeId, const uint8_t* data, int len);
void onNodeinfoUpdate(const uint8_t* data, int len);
void onMemberDetailData(const uint8_t* data, int len);
/* Bridge callbacks (called from GUI thread) */
void onMemberList(const QString& chId, int count, const uint8_t* data, int dataLen);
@@ -84,7 +83,14 @@ private slots:
void onMemberContextMenu(const QPoint& pos);
void onDetailTimerTick();
protected:
void showEvent(QShowEvent* event) override;
void hideEvent(QHideEvent* event) override;
private:
void clearMemberDetail();
void readDetailMetadata();
void onSnapshotChanged(quint64 nodeId);
void showMemberDetail(quint64 nodeId);
void updateDetailDisplay();
void requestMemberDetail();
@@ -99,24 +105,18 @@ private:
QLabel *m_detailHeader;
QTextEdit *m_detailText;
QTimer *m_detailTimer;
QTimer *m_linkTimer;
QString m_channelId;
quint64 m_detailNodeId = 0;
QString m_detailName;
bool m_detailOnline = false;
qint64 m_detailLastSeen = 0;
qint64 m_detailCreated = 0;
QVector<DetailAddress> m_detailAddrs;
bool m_snapConnPresent = false;
bool m_snapConnUp = false;
bool m_snapConnInit = false;
int m_snapOwnTcpActive = 0;
uint16_t m_snapChainRtt = 0;
uint8_t m_snapHopCount = 0;
uint16_t m_snapBgpNodes = 0;
QString m_bgpText;
QElapsedTimer m_detailClock; // монотонный срок запроса
quint64 m_detailRequest = 0; // ожидаемый request_id; 0 = нет запроса
qint64 m_detailRequestedAt = 0;
bool m_detailDirty = false; // снимок участника изменился после отправки запроса
QVector<quint64> m_detailHops; // доп. путь; сбрасывается при следующем member-событии
QVector<MemberLinkSnap> m_snapLinks;
QVector<MemberSockSnap> m_snapSocks;
};
+1 -1
View File
@@ -633,7 +633,7 @@ void MainWindow::setupBridgeCallbacks() {
if (type == 13) {
s_mainWindow->m_cpuLoadPending = false;
s_mainWindow->m_channelList->setCpuLoad(0);
s_mainWindow->m_accountList->model()->setRadioState(0, false, false);
s_mainWindow->m_accountList->setChannel(QString());
s_mainWindow->m_messageList->clearDmCallRoute();
s_mainWindow->m_messageList->clearStreams();
if (s_mainWindow->m_streamPlayer) s_mainWindow->m_streamPlayer->close();
+50 -46
View File
@@ -17,14 +17,14 @@ extern "C" {
static void applyPhoto(MemberListItem& item, const QString& channel) {
auto* manager = AvatarManager::instance(); if (!manager) return;
const QImage image = manager->image(channel, item.nodeId); if (image.isNull()) return;
const QImage image = manager->image(channel, item.snapshot.node_id); if (image.isNull()) return;
const int size = MemberListModel::kAvatarSize;
QPixmap pixmap(size, size); pixmap.fill(Qt::transparent);
QPainter painter(&pixmap); painter.setRenderHint(QPainter::Antialiasing);
painter.setRenderHint(QPainter::SmoothPixmapTransform);
QPainterPath clip; clip.addEllipse(QRectF(0, 0, size, size)); painter.setClipPath(clip);
painter.drawImage(QRect(0, 0, size, size), image); painter.setClipping(false);
const QColor statusColor = item.online ? QColor("#4CAF50") : QColor("#9E9E9E");
const QColor statusColor = item.snapshot.online ? QColor("#4CAF50") : QColor("#9E9E9E");
painter.setPen(QPen(statusColor, 1)); painter.setBrush(statusColor);
painter.drawEllipse(QRect(size - 9, size - 9, 8, 8)); painter.end(); item.avatar = pixmap;
}
@@ -36,7 +36,7 @@ MemberListModel::MemberListModel(QObject* parent)
if (auto* manager = AvatarManager::instance()) connect(manager, &AvatarManager::changed, this,
[this](const QString& channel, quint64 owner) {
if (channel != m_channelId) return;
for (auto it = m_cache.begin(); it != m_cache.end(); ++it) if (it->nodeId == owner) {
for (auto it = m_cache.begin(); it != m_cache.end(); ++it) if (it->snapshot.node_id == owner) {
applyPhoto(*it, channel); emit dataChanged(index(it.key()), index(it.key()), {Qt::DecorationRole});
}
});
@@ -60,61 +60,58 @@ QVariant MemberListModel::data(const QModelIndex& index, int role) const {
if (role == Qt::DecorationRole) return QVariant::fromValue(QIcon(it->avatar));
if (role == Qt::UserRole + 1) {
QString sub;
if (it->rtt >= 0) sub += QString("rtt=%1ms").arg(it->rtt / 10);
if (it->natType >= 0) {
if (it->snapshot.rtt != 0xffff) sub += QString("rtt=%1ms").arg(it->snapshot.rtt / 10);
if (it->snapshot.nat_type != 0xff) {
static const char* nat[] = {"NET","DIR","EIM","DEP","NAT?","CONTACT","EIM?"};
const char* nt = (it->natType <= 6) ? nat[it->natType] : "?";
const char* nt = (it->snapshot.nat_type <= 6) ? nat[it->snapshot.nat_type] : "?";
if (!sub.isEmpty()) sub += " ";
sub += nt;
}
return sub;
}
if (role == Qt::ToolTipRole)
return (it->online ? QCoreApplication::translate("ChatGui",
return (it->snapshot.online ? QCoreApplication::translate("ChatGui",
"online") : QCoreApplication::translate("ChatGui",
"offline")) + " | 0x" +
QString("%1").arg(it->nodeId, 16, 16, QChar('0')) +
((it->flags & CHAT_MEMBER_FLAG_RADIO) ? QCoreApplication::translate("ChatGui", " · Radio enabled") : QString());
if (role == Qt::UserRole + 2) return it->isSelf;
if (role == Qt::UserRole + 3) return it->flags;
if (role == Qt::UserRole + 4) return (bool)it->storage;
if (role == RadioSpeakingRole) return it->isSelf ? m_selfTransmitting : m_radioTalkers.contains(it->nodeId);
if (role == OnlineRole) return it->online;
QString("%1").arg(it->snapshot.node_id, 16, 16, QChar('0')) +
((it->snapshot.flags & CHAT_MEMBER_FLAG_RADIO) ? QCoreApplication::translate("ChatGui", " · Radio enabled") : QString());
if (role == Qt::UserRole + 2) return it->snapshot.is_self;
if (role == Qt::UserRole + 3) return it->snapshot.flags;
if (role == Qt::UserRole + 4) return (bool)it->snapshot.storage;
if (role == RadioSpeakingRole) return it->snapshot.is_self ? m_selfTransmitting : m_radioTalkers.contains(it->snapshot.node_id);
if (role == OnlineRole) return it->snapshot.online;
return {};
}
return {};
}
void MemberListModel::setChannel(const QString& channelId, bool isOwner) {
beginResetModel();
if (m_channelId != channelId) {
beginResetModel();
DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "radio members: channel %s -> %s cleared talkers=%d self_tx=%d",
qPrintable(m_channelId), qPrintable(channelId), int(m_radioTalkers.size()), m_selfTransmitting);
m_radioTalkers.clear(); m_radioActive = false; m_selfTransmitting = false;
m_totalCount = 0; m_cache.clear(); endResetModel();
emit snapshotChanged(0);
}
m_channelId = channelId;
m_isOwner = isOwner;
m_totalCount = 0;
m_cache.clear();
endResetModel();
m_channelId = channelId; m_isOwner = isOwner;
refresh();
}
if (!channelId.isEmpty()) {
void MemberListModel::refresh() {
if (!m_channelId.isEmpty() && gui_bridge_is_uasync_ready()) {
struct chat_member_list_req* req = (struct chat_member_list_req*)u_calloc(1, sizeof(*req));
if (!req) return;
if (!req) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "member list request allocation failed"); return; }
req->inst = gui_bridge_get_inst();
strncpy(req->ch_id, channelId.toUtf8().constData(), sizeof(req->ch_id) - 1);
strncpy(req->ch_id, m_channelId.toUtf8().constData(), sizeof(req->ch_id) - 1);
gui_bridge_post_uasync_fn(chat_core_request_member_list_trampoline, req);
}
}
void MemberListModel::refresh() {
if (!m_channelId.isEmpty()) setChannel(m_channelId, m_isOwner);
}
void MemberListModel::refreshRtt() {
if (m_channelId.isEmpty()) return;
if (m_channelId.isEmpty() || !gui_bridge_is_uasync_ready()) return;
struct chat_member_list_req* req = (struct chat_member_list_req*)u_calloc(1, sizeof(*req));
if (!req) return;
if (!req) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "member RTT request allocation failed"); return; }
req->inst = gui_bridge_get_inst();
strncpy(req->ch_id, m_channelId.toUtf8().constData(), sizeof(req->ch_id) - 1);
gui_bridge_post_uasync_fn(chat_core_request_member_rtt_trampoline, req);
@@ -122,24 +119,25 @@ void MemberListModel::refreshRtt() {
quint64 MemberListModel::nodeIdAt(int row) const {
auto it = m_cache.find(row);
return it != m_cache.end() ? it->nodeId : 0;
return it != m_cache.end() ? it->snapshot.node_id : 0;
}
bool MemberListModel::isOnlineAt(int row) const {
auto it = m_cache.find(row);
return it != m_cache.end() ? it->online : false;
return it != m_cache.end() ? it->snapshot.online : false;
}
std::optional<chat_member_display> MemberListModel::member(quint64 nodeId) const {
for (auto it = m_cache.cbegin(); it != m_cache.cend(); ++it)
if (it->snapshot.node_id == nodeId) return it->snapshot;
return std::nullopt;
}
static void deserialize_member(const uint8_t* src, MemberListItem& item) {
struct chat_member_display member;
chat_core_deserialize_member(src, &member);
item.nodeId = member.node_id; item.isSelf = member.is_self != 0;
item.flags = member.flags; item.rtt = member.rtt == 0xffff ? -1 : member.rtt;
item.natType = member.nat_type == 0xff ? -1 : member.nat_type;
item.storage = member.storage; item.online = member.online != 0;
item.name = QString::fromUtf8(member.name);
chat_core_deserialize_member(src, &item.snapshot);
item.name = QString::fromUtf8(item.snapshot.name);
QColor c = item.online ? QColor("#4CAF50") : QColor("#9E9E9E");
QColor c = item.snapshot.online ? QColor("#4CAF50") : QColor("#9E9E9E");
const int size = MemberListModel::kAvatarSize;
QPixmap pm(size, size); pm.fill(Qt::transparent);
QPainter p(&pm);
@@ -157,7 +155,10 @@ static void deserialize_member(const uint8_t* src, MemberListItem& item) {
void MemberListModel::onMemberList(const QString& chId, int count, const uint8_t* data, int dataLen) {
if (chId != m_channelId) return;
if (count < 0 || count > dataLen / CHAT_MEMBER_DISPLAY_SIZE) return;
if (count < 0 || dataLen < 0 || count != dataLen / CHAT_MEMBER_DISPLAY_SIZE ||
dataLen % CHAT_MEMBER_DISPLAY_SIZE || (count && !data)) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "member list: invalid count=%d bytes=%d", count, dataLen); return;
}
beginResetModel();
m_totalCount = 0;
@@ -168,11 +169,12 @@ void MemberListModel::onMemberList(const QString& chId, int count, const uint8_t
MemberListItem item;
deserialize_member(data + i * CHAT_MEMBER_DISPLAY_SIZE, item);
applyPhoto(item, chId);
if ((item.flags & CHAT_MEMBER_FLAG_DELETED) && !m_isOwner) continue;
if ((item.snapshot.flags & CHAT_MEMBER_FLAG_DELETED) && !m_isOwner) continue;
m_cache[row++] = item;
}
m_totalCount = row;
endResetModel();
emit snapshotChanged(0);
}
void MemberListModel::onMemberUpdated(const QString& chId, const uint8_t* data) {
@@ -185,12 +187,12 @@ void MemberListModel::onMemberUpdated(const QString& chId, const uint8_t* data)
/* find existing row by nodeId */
int row = -1;
for (auto it = m_cache.begin(); it != m_cache.end(); ++it) {
if (it->nodeId == item.nodeId) { row = it.key(); break; }
if (it->snapshot.node_id == item.snapshot.node_id) { row = it.key(); break; }
}
if ((item.flags & CHAT_MEMBER_FLAG_DELETED) && !m_isOwner) {
if ((item.snapshot.flags & CHAT_MEMBER_FLAG_DELETED) && !m_isOwner) {
/* deleted member — remove from non-owner's list */
onMemberRemoved(chId, item.nodeId);
onMemberRemoved(chId, item.snapshot.node_id);
return;
}
@@ -205,6 +207,7 @@ void MemberListModel::onMemberUpdated(const QString& chId, const uint8_t* data)
m_totalCount++;
endInsertRows();
}
emit snapshotChanged(item.snapshot.node_id);
}
void MemberListModel::onMemberRemoved(const QString& chId, uint64_t nodeId) {
@@ -214,13 +217,14 @@ void MemberListModel::onMemberRemoved(const QString& chId, uint64_t nodeId) {
qPrintable(chId), (unsigned long long)nodeId);
for (auto it = m_cache.begin(); it != m_cache.end(); ++it) {
if (it->nodeId == nodeId) {
if (it->snapshot.node_id == nodeId) {
int row = it.key();
beginRemoveRows(QModelIndex(), row, row);
m_cache.remove(row);
for (int next = row + 1; next < m_totalCount; ++next) m_cache[next - 1] = m_cache.take(next);
m_totalCount--;
endRemoveRows();
emit snapshotChanged(nodeId);
return;
}
}
@@ -255,7 +259,7 @@ void MemberListModel::onRadioTalk(quint64 groupId, quint64 nodeId, bool on) {
else m_radioTalkers.remove(nodeId);
DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "radio members: talk ch=%s node=%016llx on=%d talkers=%d",
qPrintable(m_channelId), (unsigned long long)nodeId, on, int(m_radioTalkers.size()));
for (auto it = m_cache.cbegin(); it != m_cache.cend(); ++it) if (it->nodeId == nodeId) {
for (auto it = m_cache.cbegin(); it != m_cache.cend(); ++it) if (it->snapshot.node_id == nodeId) {
emit dataChanged(index(it.key()), index(it.key()), {RadioSpeakingRole});
return;
}
+13 -13
View File
@@ -1,5 +1,5 @@
/* Qt-модель участников канала: ленивый кэш строк, обновления CHAT_EVT_MEMBER_* и RTT.
* События bridge применяются в GUI-потоке; данные строки доступны через Qt roles. */
/* Qt-модель участников выбранного канала: полные снимки CHAT_EVT_MEMBER_* в GUI-потоке.
* Список читает Qt roles, карточка — копию той же записи через member(nodeId). */
#ifndef MEMBERLISTMODEL_H
#define MEMBERLISTMODEL_H
@@ -12,17 +12,13 @@
#include <QPixmap>
#include <QTimer>
#include <stdint.h>
#include <optional>
#include "../../../src/chat/chat_member.h"
struct MemberListItem {
quint64 nodeId = 0;
bool online = false;
bool isSelf = false;
QPixmap avatar;
QString name;
int rtt = -1;
int natType = -1;
uint8_t flags = 0;
uint8_t storage = 0;
chat_member_display snapshot{}; // полная запись выбранного канала; источник всех live-полей
QPixmap avatar; // производное изображение строки
QString name; // UTF-8 имя из snapshot для Qt
};
class MemberListModel : public QAbstractListModel {
@@ -44,6 +40,8 @@ public:
void refreshRtt();
quint64 nodeIdAt(int row) const;
bool isOnlineAt(int row) const;
/* Копия записи: указатели в row-cache нельзя сохранять через reset/обновления. */
std::optional<chat_member_display> member(quint64 nodeId) const;
/* Called from bridge callbacks (GUI thread) */
void onMemberList(const QString& chId, int count, const uint8_t* data, int dataLen);
@@ -54,9 +52,11 @@ public:
void setRadioState(quint64 groupId, bool active, bool transmitting);
void onRadioTalk(quint64 groupId, quint64 nodeId, bool on);
signals:
/* nodeId=0: полный снимок/смена канала; иначе изменился или удалён конкретный участник. */
void snapshotChanged(quint64 nodeId);
private:
void loadBatch(int from, int count);
QPixmap makeAvatar(QColor color, QChar letter) const;
QString m_channelId;
bool m_isOwner = false;
+140
View File
@@ -0,0 +1,140 @@
/* Настоящие Qt-карточка и модель; очередь ядра задерживает ответы для проверки смены выбора. */
#include "../src/accountlist.h"
#include "../src/memberlistmodel.h"
#include "../transport/gui_bridge.h"
#include <QtTest>
#include <QListView>
#include <vector>
extern "C" {
#include "utun_instance.h"
#include "chat/chat_member.h"
#include "member_files/protocol.h"
#include "debug_config.h"
#include "mem.h"
}
static UTUN_INSTANCE instance{};
static std::vector<chat_member_detail_req> requests;
static gui_member_detail_fn detailCallback;
static bool ready = true;
extern "C" UTUN_INSTANCE* gui_bridge_get_inst() { return &instance; }
extern "C" int gui_bridge_is_uasync_ready() { return ready; }
extern "C" void gui_bridge_set_member_detail_cb(gui_member_detail_fn cb) { detailCallback = cb; }
extern "C" void gui_bridge_set_radio_mute_result_cb(gui_dm_messages_fn) {}
extern "C" int gui_bridge_toggle_member_radio_mute(const char*, uint64_t) { return 0; }
extern "C" void gui_bridge_post_uasync_fn(void (*fn)(void*), void* arg) {
if (fn == chat_core_collect_member_detail_trampoline) requests.push_back(*static_cast<chat_member_detail_req*>(arg));
u_free(arg);
}
static QByteArray row(quint64 node, bool online = true, int up = 5, int rtt = 30) {
chat_member_display member{};
member.node_id = node; member.online = online; member.conn_presence = 5; member.conn_up = up;
member.rtt = rtt; member.nat_type = 0xff; member.flags = CHAT_MEMBER_FLAG_PHONE_KNOWN;
snprintf(member.name, sizeof(member.name), "peer%llu", (unsigned long long)node);
QByteArray bytes(CHAT_MEMBER_DISPLAY_SIZE, 0);
chat_core_serialize_member(&member, reinterpret_cast<uint8_t*>(bytes.data()));
return bytes;
}
static QByteArray reply(const chat_member_detail_req& req, const QList<quint64>& hops = {}, int result = 0) {
QByteArray bytes(CHAT_MEMBER_DETAIL_HEADER_SIZE + hops.size() * 8, 0);
auto* data = reinterpret_cast<uint8_t*>(bytes.data());
mf_put64(data, req.request_id); mf_put64(data + 8, req.group_id); mf_put64(data + 16, req.node_id);
data[24] = result; data[25] = result ? 0 : 7; data[28] = hops.size();
for (int i = 0; i < hops.size(); i++) mf_put64(data + CHAT_MEMBER_DETAIL_HEADER_SIZE + i * 8, hops[i]);
return bytes;
}
static void deliver(const QByteArray& bytes) {
if (detailCallback) detailCallback(reinterpret_cast<const uint8_t*>(bytes.constData()), bytes.size());
}
static void fill(AccountList& panel, const QString& channel, const QByteArray& bytes) {
panel.setChannel(channel);
panel.onMemberList(channel, bytes.size() / CHAT_MEMBER_DISPLAY_SIZE,
reinterpret_cast<const uint8_t*>(bytes.constData()), bytes.size());
}
class MemberDetailTest : public QObject {
Q_OBJECT
static QString body(AccountList& p) { return p.findChild<QTextEdit*>("memberDetailText")->toPlainText(); }
static QString header(AccountList& p) { return p.findChild<QLabel*>("memberDetailHeader")->text(); }
static bool select(AccountList& p, int rowNumber) {
return QMetaObject::invokeMethod(&p, "onMemberClicked", Qt::DirectConnection, Q_ARG(QModelIndex, p.model()->index(rowNumber)));
}
private slots:
void init() { requests.clear(); ready = true; debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN); }
void modelOwnsPresenceAndRttAndRefreshPreservesSelection() {
AccountList panel; panel.resize(600, 800); panel.show();
fill(panel, "123", row(7, false, 0, 0xffff) + row(8));
QVERIFY(select(panel, 0)); QCOMPARE(requests.size(), size_t(1));
deliver(reply(requests.back())); // Физический транспорт UP не делает участника онлайн.
QVERIFY(header(panel).contains("#9E9E9E")); QVERIFY(body(panel).contains("offline"));
const auto live = row(7, true, 4, 93);
panel.onMemberUpdated("123", reinterpret_cast<const uint8_t*>(live.constData()));
QVERIFY(header(panel).contains("#4CAF50")); QVERIFY(body(panel).contains("rtt=9.3ms"));
deliver(reply(requests.back(), {7, 9})); QVERIFY(body(panel).contains("0000000000000009"));
const auto other = row(7, false, 0, 0xffff);
panel.onMemberUpdated("456", reinterpret_cast<const uint8_t*>(other.constData()));
QVERIFY(header(panel).contains("#4CAF50"));
panel.refresh(); QCOMPARE(panel.model()->rowCount(), 2); QVERIFY(body(panel).contains("rtt=9.3ms"));
const auto reordered = row(8) + live;
panel.onMemberList("123", 2, reinterpret_cast<const uint8_t*>(reordered.constData()), reordered.size());
QVERIFY(header(panel).contains("peer7"));
QCOMPARE(panel.findChild<QListView*>()->currentIndex().row(), 1);
}
void aToBToADiscardsOldResponsesWithoutClearingNewRequest() {
AccountList panel; panel.show(); fill(panel, "123", row(7) + row(8));
QVERIFY(select(panel, 0)); const auto first = requests.back();
QVERIFY(select(panel, 1)); const auto second = requests.back();
QVERIFY(select(panel, 0)); const auto third = requests.back();
QVERIFY(first.request_id < second.request_id && second.request_id < third.request_id);
deliver(reply(first, {111})); deliver(reply(second, {222}));
QVERIFY(!body(panel).contains("BGP:"));
auto wrongGroup = third; wrongGroup.group_id = 456;
deliver(reply(wrongGroup, {333})); QVERIFY(!body(panel).contains("BGP:"));
deliver(reply(third, {7, 444})); QVERIFY(body(panel).contains("00000000000001bc"));
const auto pending = requests.size();
QVERIFY(QMetaObject::invokeMethod(&panel, "onDetailTimerTick")); QCOMPARE(requests.size(), pending + 1);
const auto oldChannel = requests.back();
fill(panel, "456", row(7)); QVERIFY(select(panel, 0)); const auto current = requests.back();
deliver(reply(oldChannel, {555})); QVERIFY(!body(panel).contains("BGP:"));
deliver(reply(current, {7, 666})); QVERIFY(body(panel).contains("000000000000029a"));
}
void newMemberEventInvalidatesPendingPathEvenWhenFieldsAreEqual() {
AccountList panel; panel.show(); const auto initial = row(7);
fill(panel, "123", initial); QVERIFY(select(panel, 0)); const auto first = requests.back();
panel.onMemberUpdated("123", reinterpret_cast<const uint8_t*>(initial.constData()));
QCOMPARE(requests.size(), size_t(1));
deliver(reply(first, {111})); QCOMPARE(requests.size(), size_t(2)); QVERIFY(!body(panel).contains("BGP:"));
const auto correct = reply(requests.back(), {7, 8});
deliver(correct.left(correct.size() - 1)); QVERIFY(!body(panel).contains("BGP:"));
deliver(correct); QVERIFY(body(panel).contains("0000000000000008"));
panel.onMemberRemoved("123", 7); QVERIFY(!header(panel).contains("peer7"));
deliver(correct); QVERIFY(!body(panel).contains("BGP:"));
}
void errorTimeoutAndHiddenPanelReleaseOnlyTheirOwnRequests() {
AccountList panel; panel.show(); fill(panel, "123", row(7)); QVERIFY(select(panel, 0));
deliver(reply(requests.back(), {}, CHAT_MEMBER_DETAIL_NOT_MEMBER));
QVERIFY(header(panel).contains("#4CAF50")); // Диагностическая ошибка не меняет live-снимок.
QVERIFY(QMetaObject::invokeMethod(&panel, "onDetailTimerTick")); const auto expired = requests.back();
QTRY_VERIFY_WITH_TIMEOUT(requests.back().request_id != expired.request_id, 6500);
const auto current = requests.back(); deliver(reply(expired, {111}));
deliver(reply(current, {7, 8})); QVERIFY(body(panel).contains("BGP:"));
QVERIFY(QMetaObject::invokeMethod(&panel, "onDetailTimerTick")); const auto hidden = requests.back();
panel.hide(); deliver(reply(hidden, {222})); panel.show();
QVERIFY(requests.back().request_id > hidden.request_id);
deliver(reply(hidden, {222})); QVERIFY(!body(panel).contains("00000000000000de"));
ready = false; panel.setChannel(QString()); deliver(reply(requests.back(), {333}));
QCOMPARE(panel.model()->rowCount(), 0); QVERIFY(!body(panel).contains("BGP:"));
}
};
QTEST_MAIN(MemberDetailTest)
#include "test_member_detail.moc"
+10
View File
@@ -23,6 +23,16 @@ private slots:
void initTestCase() { gui_bridge_init(); }
void cleanup() { debug_disable_file_output(); }
void routeChangesStillNotifyPmPresence() {
static int notifications;
notifications = 0;
gui_bridge_set_network_changed_cb([](int type) { if (type == GUI_EVT_NODEINFO_UPDATE) notifications++; });
TOPO_GROUP_NODE peer{}; peer.node_id = 7;
gui_nodeinfo_cb_impl(nullptr, &peer, nullptr);
QTRY_COMPARE(notifications, 1);
gui_bridge_set_network_changed_cb(nullptr);
}
void services_data() {
QTest::addColumn<QByteArray>("settings");
QTest::addColumn<bool>("withTun");
+4 -19
View File
@@ -11,6 +11,7 @@ extern "C" {
#include <stdint.h>
#include <stddef.h>
#include "../../../src/chat/chat_member.h"
struct UASYNC;
struct TOPO_GROUP;
@@ -32,14 +33,13 @@ struct UTUN_INSTANCE;
#define GUI_EVT_NODE_CHANGED 11 /* data: [node_id:8] */
#define GUI_EVT_ATTACHMENT_DOWNLOADED 14 /* data: [ch_id_len:1][ch_id:var][msg_id:8] */
#define GUI_EVT_DOWNLOAD_PROGRESS 15 /* data: [ch_id_len:1][ch_id:var][msg_id:8][blocks_done:4][num_blocks:4] */
#define GUI_EVT_NODEINFO_UPDATE 16 /* data: node_id:8 conn_presence:1 conn_up:1 best_rtt:2(0.1ms,0xFFFF=nodata) */
#define GUI_EVT_NODEINFO_UPDATE 16 /* Сигнал изменения маршрутов для PM presence; без live-полей карточки. */
#define GUI_EVT_CONN_LIST 20 /* data: [count:2][entry:46B]* — binary conn list */
#define GUI_EVT_CONN_METRICS 21 /* data: text dump of selected connection metrics */
#define GUI_EVT_MEMBER_DETAIL 22 /* data: [node_id:8][flags:1][own_tcp_active:2][links...][socks...] — live snapshot for member detail panel */
#define GUI_EVT_MEMBER_DETAIL 22 /* Адресованный ответ диагностики; codec в chat_member.h. */
#define GUI_EVT_MEMBER_LIST 23 /* data: [ch_id_len:1][ch_id:var][count:2][member:CHAT_MEMBER_DISPLAY_SIZE]* */
#define GUI_EVT_MEMBER_UPDATED 24 /* data: [ch_id_len:1][ch_id:var][count:1][member:CHAT_MEMBER_DISPLAY_SIZE] */
#define GUI_EVT_MEMBER_REMOVED 25 /* data: [ch_id_len:1][ch_id:var][node_id:8] — мембер удалён из канала */
#define GUI_EVT_BGP_INFO 26 /* data: text — BGP hop summary for member detail */
#define GUI_EVT_ADMIN_KEY_RECEIVED 28 /* data: [ch_id_len:1][ch_id:var][from_node_id:8] — получен канальный ключ */
#define GUI_EVT_INVITE_LINK_READY 29 /* data: [ch_id_len:1][ch_id:var][request_id:8][link_len:2][link:var] — пустой link = ошибка */
#define GUI_EVT_INVITE_CANDIDATES 30 /* data: [ch_id_len:1][ch_id:var][auto_node_id:8][count:2][node_id:8]* — достижимые узлы для invite */
@@ -57,12 +57,6 @@ struct UTUN_INSTANCE;
#define GUI_EVT_RADIO_TALK 43 /* data: [group_id:8][src_node_id:8][stream_id:2][on:1] — кто-то начал/закончил говорить */
#define GUI_EVT_CALL_CONNECTION 44 /* data: [call_id:8][CONN_TYPE:1][next_hop_node_id:8] */
/* Размеры элементов пакета GUI_EVT_MEMBER_DETAIL (должны совпадать с chat_status.c) */
#define MEMBER_DETAIL_LINK_SIZE 64
#define MEMBER_DETAIL_MAX_LINKS 16
#define MEMBER_DETAIL_MAX_SOCKS 16
#define MEMBER_DETAIL_SOCK_SIZE 48
/* ── API ── */
/* Инициализация моста (вызывается из GUI при старте) */
@@ -134,10 +128,6 @@ void gui_bridge_set_attachment_downloaded_cb(gui_attachment_downloaded_fn cb);
typedef void (*gui_download_progress_fn)(const char* ch_id, int ch_id_len, int64_t msg_id, int blocks_done, int num_blocks);
void gui_bridge_set_download_progress_cb(gui_download_progress_fn cb);
/* Callback для обновления статуса узла (nodeinfo) — data: 12B packed */
typedef void (*gui_nodeinfo_update_fn)(const uint8_t* data, int len);
void gui_bridge_set_nodeinfo_update_cb(gui_nodeinfo_update_fn cb);
/* Callback для входящих приглашений (вызывается из GUI-потока)
data: [ch_id_len:1][ch_id][name_len:1][name][inviter_node_id:8][inviter_name_len:1][inviter_name] */
typedef void (*gui_invite_received_fn)(const uint8_t* data, int len);
@@ -152,7 +142,7 @@ typedef void (*gui_conn_metrics_fn)(const uint8_t* data, int len);
void gui_bridge_set_conn_metrics_cb(gui_conn_metrics_fn cb);
/* Callback для live-снапшота панели деталей участника (вызывается из GUI-потока)
data: [node_id:8][flags:1][own_tcp_active:2][links...][socks...] — см. chat_status.c collect_member_detail */
data: см. CHAT_MEMBER_DETAIL_* в chat_member.h; проверка целого пакета до применения. */
typedef void (*gui_member_detail_fn)(const uint8_t* data, int len);
void gui_bridge_set_member_detail_cb(gui_member_detail_fn cb);
@@ -171,11 +161,6 @@ void gui_bridge_set_member_updated_cb(gui_member_updated_fn cb);
typedef void (*gui_member_removed_fn)(const uint8_t* data, int len);
void gui_bridge_set_member_removed_cb(gui_member_removed_fn cb);
/* Callback для BGP hop info текста (вызывается из GUI-потока)
data: текстовая строка формата "rtt:12ms hop:3 via bgp: [ABCD] name1 [EF12] name2" */
typedef void (*gui_bgp_info_fn)(const uint8_t* data, int len);
void gui_bridge_set_bgp_info_cb(gui_bgp_info_fn cb);
/* Callback: получен канальный приватный ключ (стал совладельцем) — data: [ch_id_len:1][ch_id][from_node_id:8] */
typedef void (*gui_admin_key_received_fn)(const uint8_t* data, int len);
void gui_bridge_set_admin_key_received_cb(gui_admin_key_received_fn cb);
+4 -29
View File
@@ -41,7 +41,6 @@ static gui_status_refresh_fn g_status_refresh_cb = nullptr;
static gui_cpu_load_fn g_cpu_load_cb = nullptr;
static gui_attachment_downloaded_fn g_attachment_downloaded_cb = nullptr;
static gui_download_progress_fn g_download_progress_cb = nullptr;
static gui_nodeinfo_update_fn g_nodeinfo_update_cb = nullptr;
static gui_invite_received_fn g_invite_received_cb = nullptr;
static gui_conn_list_fn g_conn_list_cb = nullptr;
static gui_conn_metrics_fn g_conn_metrics_cb = nullptr;
@@ -49,7 +48,6 @@ static gui_member_detail_fn g_member_detail_cb = nullptr;
static gui_member_list_fn g_member_list_cb = nullptr;
static gui_member_updated_fn g_member_updated_cb = nullptr;
static gui_member_removed_fn g_member_removed_cb = nullptr;
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;
@@ -272,9 +270,7 @@ void GuiBridgeReceiver::processPost(int eventType, QByteArray data) {
}
}
break;
case GUI_EVT_NODEINFO_UPDATE:
if (g_nodeinfo_update_cb) g_nodeinfo_update_cb(d, dlen);
break;
case GUI_EVT_NODEINFO_UPDATE: break; /* network_changed уже уведомлён выше. */
case 18: /* CHAT_EVT_INVITE_RECEIVED */
if (g_invite_received_cb) g_invite_received_cb(d, dlen);
break;
@@ -296,9 +292,7 @@ void GuiBridgeReceiver::processPost(int eventType, QByteArray data) {
case GUI_EVT_MEMBER_REMOVED:
if (g_member_removed_cb) g_member_removed_cb(d, dlen);
break;
case GUI_EVT_BGP_INFO:
if (g_bgp_info_cb) g_bgp_info_cb(d, dlen);
break;
case GUI_EVT_ADMIN_KEY_RECEIVED:
if (dlen >= 2) {
uint8_t chLen = d[0];
@@ -552,9 +546,6 @@ void gui_bridge_set_download_progress_cb(gui_download_progress_fn cb) {
g_download_progress_cb = cb;
}
void gui_bridge_set_nodeinfo_update_cb(gui_nodeinfo_update_fn cb) {
g_nodeinfo_update_cb = cb;
}
void gui_bridge_set_invite_received_cb(gui_invite_received_fn cb) {
g_invite_received_cb = cb;
@@ -584,9 +575,6 @@ void gui_bridge_set_member_removed_cb(gui_member_removed_fn cb) {
g_member_removed_cb = cb;
}
void gui_bridge_set_bgp_info_cb(gui_bgp_info_fn cb) {
g_bgp_info_cb = cb;
}
void gui_bridge_set_admin_key_received_cb(gui_admin_key_received_fn cb) {
g_admin_key_received_cb = cb;
@@ -696,21 +684,8 @@ void gui_bridge_set_attachment_preparation_cb(gui_dm_messages_fn cb) {
/* ── uTun nodeinfo callback (called from uasync thread) ── */
extern "C" void gui_nodeinfo_cb_impl(struct TOPO_GROUP* group, struct TOPO_GROUP_NODE* nq, void* arg) {
(void)arg;
if (!nq) return;
uint16_t best_rtt = topo_get_chain_rtt(nq);
uint8_t hop_count = 0;
topo_node_best_hop_list(nq, &hop_count, NULL);
uint8_t data[16];
memcpy(data, &nq->node_id, 8);
data[8] = nq->conn_presence;
data[9] = nq->conn_up;
memcpy(data + 10, &best_rtt, 2);
data[12] = hop_count;
data[13] = 0;
uint16_t bgp_nodes = group ? (uint16_t)queue_entry_count(group->nodes) : 0;
memcpy(data + 14, &bgp_nodes, 2);
gui_bridge_post(GUI_EVT_NODEINFO_UPDATE, data, sizeof(data));
(void)group; (void)arg;
if (nq) gui_bridge_post(GUI_EVT_NODEINFO_UPDATE, nullptr, 0);
}
#include "gui_bridge_impl.moc"