diff --git a/src/chat/chat_core.h b/src/chat/chat_core.h index fa96adb1..6f62b9ff 100644 --- a/src/chat/chat_core.h +++ b/src/chat/chat_core.h @@ -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 ── diff --git a/src/chat/chat_event.c b/src/chat/chat_event.c index 654cd8dd..64492fc9 100644 --- a/src/chat/chat_event.c +++ b/src/chat/chat_event.c @@ -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", diff --git a/src/chat/chat_event.h b/src/chat/chat_event.h index 66fc4785..e333c9cc 100644 --- a/src/chat/chat_event.h +++ b/src/chat/chat_event.h @@ -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 = ошибка */ diff --git a/src/chat/chat_member.h b/src/chat/chat_member.h index 92b275bc..53907841 100644 --- a/src/chat/chat_member.h +++ b/src/chat/chat_member.h @@ -31,6 +31,8 @@ #include #include +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 diff --git a/src/chat/chat_status.c b/src/chat/chat_status.c index a2dc21ea..f9907df6 100644 --- a/src/chat/chat_status.c +++ b/src/chat/chat_status.c @@ -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 @@ -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); } diff --git a/tests/test_services.c b/tests/test_services.c index 3d86ebc1..42e59c4e 100644 --- a/tests/test_services.c +++ b/tests/test_services.c @@ -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)); diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt index a453e5bf..262f5f06 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt @@ -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() - 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() diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt index 2696db25..bf55619e 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt @@ -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 diff --git a/tools/chatgui/CMakeLists.txt b/tools/chatgui/CMakeLists.txt index 09b9ae24..df9e890d 100644 --- a/tools/chatgui/CMakeLists.txt +++ b/tools/chatgui/CMakeLists.txt @@ -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}) diff --git a/tools/chatgui/src/accountlist.cpp b/tools/chatgui/src/accountlist.cpp index 2197a3f0..7fcfdda9 100644 --- a/tools/chatgui/src/accountlist.cpp +++ b/tools/chatgui/src/accountlist.cpp @@ -15,6 +15,8 @@ #include #include #include +#include +#include #include #include #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 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 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 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("● %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("
") + lines.join("\n") + "
"); } -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(); } diff --git a/tools/chatgui/src/accountlist.h b/tools/chatgui/src/accountlist.h index 480b586d..4a594c27 100644 --- a/tools/chatgui/src/accountlist.h +++ b/tools/chatgui/src/accountlist.h @@ -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 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 m_detailHops; // доп. путь; сбрасывается при следующем member-событии QVector m_snapLinks; QVector m_snapSocks; }; diff --git a/tools/chatgui/src/mainwindow.cpp b/tools/chatgui/src/mainwindow.cpp index 12a81bad..9624f41d 100644 --- a/tools/chatgui/src/mainwindow.cpp +++ b/tools/chatgui/src/mainwindow.cpp @@ -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(); diff --git a/tools/chatgui/src/memberlistmodel.cpp b/tools/chatgui/src/memberlistmodel.cpp index 3c9784a6..c35a4681 100644 --- a/tools/chatgui/src/memberlistmodel.cpp +++ b/tools/chatgui/src/memberlistmodel.cpp @@ -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 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; } diff --git a/tools/chatgui/src/memberlistmodel.h b/tools/chatgui/src/memberlistmodel.h index 802a5f0c..0778bfea 100644 --- a/tools/chatgui/src/memberlistmodel.h +++ b/tools/chatgui/src/memberlistmodel.h @@ -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 #include #include +#include +#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 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; diff --git a/tools/chatgui/tests/test_member_detail.cpp b/tools/chatgui/tests/test_member_detail.cpp new file mode 100644 index 00000000..40f71e9a --- /dev/null +++ b/tools/chatgui/tests/test_member_detail.cpp @@ -0,0 +1,140 @@ +/* Настоящие Qt-карточка и модель; очередь ядра задерживает ответы для проверки смены выбора. */ +#include "../src/accountlist.h" +#include "../src/memberlistmodel.h" +#include "../transport/gui_bridge.h" +#include +#include +#include +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 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(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(bytes.data())); + return bytes; +} + +static QByteArray reply(const chat_member_detail_req& req, const QList& hops = {}, int result = 0) { + QByteArray bytes(CHAT_MEMBER_DETAIL_HEADER_SIZE + hops.size() * 8, 0); + auto* data = reinterpret_cast(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(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(bytes.constData()), bytes.size()); +} + +class MemberDetailTest : public QObject { + Q_OBJECT + static QString body(AccountList& p) { return p.findChild("memberDetailText")->toPlainText(); } + static QString header(AccountList& p) { return p.findChild("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(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(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(reordered.constData()), reordered.size()); + QVERIFY(header(panel).contains("peer7")); + QCOMPARE(panel.findChild()->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(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" diff --git a/tools/chatgui/tests/test_utun_node.cpp b/tools/chatgui/tests/test_utun_node.cpp index daec5d7e..ec5eb034 100644 --- a/tools/chatgui/tests/test_utun_node.cpp +++ b/tools/chatgui/tests/test_utun_node.cpp @@ -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("settings"); QTest::addColumn("withTun"); diff --git a/tools/chatgui/transport/gui_bridge.h b/tools/chatgui/transport/gui_bridge.h index cd000219..cfcb1b1c 100644 --- a/tools/chatgui/transport/gui_bridge.h +++ b/tools/chatgui/transport/gui_bridge.h @@ -11,6 +11,7 @@ extern "C" { #include #include +#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); diff --git a/tools/chatgui/transport/gui_bridge_impl.cpp b/tools/chatgui/transport/gui_bridge_impl.cpp index a6717d3a..57a54061 100644 --- a/tools/chatgui/transport/gui_bridge_impl.cpp +++ b/tools/chatgui/transport/gui_bridge_impl.cpp @@ -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"