chat: invite-ссылки с выбором достижимого узла + tcp_io фикс half-close FIN

- Общий модуль invite_build: сборка utun://-ссылок, авто-выбор лучшего
  достижимого узла, список кандидатов (вынесено из android jni_bridge)
- chatgui: диалог invite с выбором узла (комбобокс), события INVITE_CANDIDATES /
  INVITE_LINK_READY через gui_bridge
- headless/chatcli: invite <ch_id> [node_id], команда invite_nodes
- tcp_io: error_cb отличает грациозный EPOLLHUP (SO_ERROR==0) от реальной ошибки —
  при приостановленном чтении FIN не роняет недослейные данные (фикс flaky half-close)
- run_test.sh: half-close echo на выделенном порту 19190 + отдельный DNAT (был недостижим)
This commit is contained in:
evgeny committed 2026-09-07 13:59:15 +03:00
1 parent a17a85a383
commit 1345f44e10
23 files changed
+872 -539

No files matched your search

+12 -4
View File
@@ -14,7 +14,8 @@
# members <ch_id> list members with full state
# messages <ch_id> [count] read last messages
# send <ch_id> <text> send text message
# invite <ch_id> create invite link
# invite <ch_id> [node_id] create invite link (auto best node by default)
# invite_nodes <ch_id> list reachable nodes for invite
# connect <utun://...> join channel via invite link
# create <name> create new channel
# invite_to <ch_id> <node_id> <pubkey_hex> <ip:port> [proto]
@@ -97,8 +98,14 @@ def cmd_messages(ch_id, count=None):
def cmd_send(ch_id, text):
_req("send", ch=ch_id, content_type="text", data=text)
def cmd_invite(ch_id):
_req("invite", ch=ch_id)
def cmd_invite(ch_id, node_id=None):
params = {"ch": ch_id}
if node_id:
params["node_id"] = node_id
_req("invite", **params)
def cmd_invite_nodes(ch_id):
_req("invite_nodes", ch=ch_id)
def cmd_connect(link):
_req("connect", link=link)
@@ -166,7 +173,8 @@ def main():
elif cmd == "members": cmd_members(*args[1:2] if len(args) > 1 else (_die("usage: members <ch_id>"),))
elif cmd in ("messages","msgs"): cmd_messages(*args[1:3] if len(args) > 1 else (_die("usage: messages <ch_id> [count]"),))
elif cmd == "send": cmd_send(*args[1:3] if len(args) > 2 else (_die("usage: send <ch_id> <text>"),))
elif cmd == "invite": cmd_invite(*args[1:2] if len(args) > 1 else (_die("usage: invite <ch_id>"),))
elif cmd == "invite": cmd_invite(*args[1:3] if len(args) > 1 else (_die("usage: invite <ch_id> [node_id]"),))
elif cmd == "invite_nodes": cmd_invite_nodes(*args[1:2] if len(args) > 1 else (_die("usage: invite_nodes <ch_id>"),))
elif cmd in ("connect","join"): cmd_connect(*args[1:2] if len(args) > 1 else (_die("usage: connect <utun://...>"),))
elif cmd == "create": cmd_create(*args[1:2] if len(args) > 1 else (_die("usage: create <name>"),))
elif cmd == "invite_to": cmd_invite_to(*args[1:6] if len(args) > 4 else (_die("usage: invite_to <ch_id> <node_id> <pubkey_hex> <ip:port> [proto]"),))
+11 -2
View File
@@ -49,10 +49,19 @@ send <ch_id> <text>
Отправить текстовое сообщение в канал.
Пример: chatcli send 12345 "hello world"
invite <ch_id>
invite <ch_id> [node_id]
Создать invite-ссылку для канала (формат utun://...).
Ссылка включает pubkey, адреса сервера и ID канала.
Без node_id — автоматический выбор лучшего достижимого узла.
С node_id — ссылка ведёт на указанный узел (hex, например 0x1234).
Ссылка включает pubkey, адреса выбранного узла и ID канала.
Пример: chatcli invite 12345
chatcli invite 12345 0x1a2b3c
invite_nodes <ch_id>
Список достижимых узлов канала, пригодных для invite (включая свой узел).
Ответ содержит auto_node_id — узел, выбранный авто-логикой,
и список nodes (node_id, имя).
Пример: chatcli invite_nodes 12345
connect <utun://...>
Подключиться к каналу по invite-ссылке.
@@ -19,6 +19,7 @@
#include "../../../lib/mem.h"
#include "../../../lib/sqlite3.h"
#include "../../../src/chat/chat_core.h"
#include "../../../src/chat/invite_build.h"
#include "../../../src/chat/chat_setting.h"
#include "../../../src/utun_instance.h"
#include "../../../src/transport_layer/etcp.h"
@@ -229,202 +230,11 @@ void utun_bridge_join_channel(uint64_t channel_id, uint64_t node_id,
chat_sync_connect_from_invite(channel_id, real_node_id, pubkey_bin, addrs_data, addr_count, addrs_data_len, NULL);
}
/* Collect listening addresses from local sockets (for self-node fallback) */
static int bridge_collect_invite_addrs(struct InviteAddrC* out, int max_cnt) {
int cnt = 0;
struct ETCP_SOCKET* s = g_cc.inst->etcp_sockets;
while (s) {
if (s->is_tcp) { s = s->next; continue; }
struct sockaddr_storage* sa = s->interface_addr.ss_family ? &s->interface_addr : &s->local_addr;
if (sa && sa->ss_family == AF_INET && cnt < max_cnt) {
struct sockaddr_in* sin = (struct sockaddr_in*)sa;
out[cnt].family = 4; out[cnt].socketId = s->sock_id; out[cnt].proto = INVITE_PROTO_UDP;
memcpy(out[cnt].address, &sin->sin_addr, 4);
out[cnt].port = ntohs(sin->sin_port); cnt++;
} else if (sa && sa->ss_family == AF_INET6 && cnt < max_cnt) {
struct sockaddr_in6* sin6 = (struct sockaddr_in6*)sa;
out[cnt].family = 6; out[cnt].socketId = s->sock_id; out[cnt].proto = INVITE_PROTO_UDP;
memcpy(out[cnt].address, &sin6->sin6_addr, 16);
out[cnt].port = ntohs(sin6->sin6_port); cnt++;
}
s = s->next;
}
{ struct ETCP_SOCKET* s2 = g_cc.inst->etcp_sockets;
while (s2) {
if (!s2->is_tcp) { s2 = s2->next; continue; }
struct sockaddr_storage* sa = s2->interface_addr.ss_family ? &s2->interface_addr : &s2->local_addr;
if (sa && sa->ss_family == AF_INET && cnt < max_cnt) {
struct sockaddr_in* sin = (struct sockaddr_in*)sa;
out[cnt].family = 4; out[cnt].socketId = s2->sock_id; out[cnt].proto = INVITE_PROTO_TCP;
memcpy(out[cnt].address, &sin->sin_addr, 4);
out[cnt].port = ntohs(sin->sin_port); cnt++;
} else if (sa && sa->ss_family == AF_INET6 && cnt < max_cnt) {
struct sockaddr_in6* sin6 = (struct sockaddr_in6*)sa;
out[cnt].family = 6; out[cnt].socketId = s2->sock_id; out[cnt].proto = INVITE_PROTO_TCP;
memcpy(out[cnt].address, &sin6->sin6_addr, 16);
out[cnt].port = ntohs(sin6->sin6_port); cnt++;
}
s2 = s2->next; }
}
return cnt;
}
/* Collect addresses from node_addresses table for a remote node.
If dir_only=1 → only ADDR_TYPE_DIRECT, otherwise all addr_types */
static int bridge_collect_invite_addrs_from_db(sqlite3* db, uint64_t node_id,
struct InviteAddrC* out, int max_cnt, int dir_only) {
int cnt = 0;
char sql[256];
if (dir_only)
snprintf(sql, sizeof(sql),
"SELECT family,socket_id,protocol,address,port FROM node_addresses"
" WHERE node_id=? AND addr_type=%d", ADDR_TYPE_DIRECT);
else
snprintf(sql, sizeof(sql),
"SELECT family,socket_id,protocol,address,port FROM node_addresses"
" WHERE node_id=?");
sqlite3_stmt* st = NULL;
if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) return 0;
sqlite3_bind_int64(st, 1, (sqlite3_int64)node_id);
while (sqlite3_step(st) == SQLITE_ROW && cnt < max_cnt) {
int family = sqlite3_column_int(st, 0);
out[cnt].family = family;
out[cnt].socketId = (uint8_t)sqlite3_column_int(st, 1);
out[cnt].proto = (uint8_t)sqlite3_column_int(st, 2);
const uint8_t* addr = sqlite3_column_blob(st, 3);
int addr_len = sqlite3_column_bytes(st, 3);
int ip_len = (family == 6) ? 16 : 4;
if (addr && addr_len >= ip_len) memcpy(out[cnt].address, addr, (size_t)ip_len);
else { memset(out[cnt].address, 0, sizeof(out[cnt].address)); }
out[cnt].port = (uint16_t)sqlite3_column_int(st, 4);
cnt++;
}
sqlite3_finalize(st);
return cnt;
}
char* utun_bridge_encode_invite_link(uint64_t channel_id, const char* password) {
if (!chat_core_is_initialized() || !g_cc.inst || !g_cc.db) {
bridge_log(BLEV_ERROR, "encode_invite_link: not initialized inst=%p db=%p",
(void*)g_cc.inst, g_cc.inst ? (void*)g_cc.db : NULL);
return NULL;
}
uint64_t my_nid = g_cc.my_node_id;
sqlite3* db = g_cc.db;
struct TOPO_GROUPS* groups = g_cc.inst->topo_groups;
/* ── Фаза 1: поиск лучшего узла в канале ── */
char ch_str[32]; snprintf(ch_str, sizeof(ch_str), "%llu", (unsigned long long)channel_id);
char peers_tbl[80]; peers_table_name(ch_str, peers_tbl, sizeof(peers_tbl));
uint64_t best_nid = 0;
int best_priority = 4; /* 1=regular, 2=self, 3=super, 4=invalid */
int best_has_both_public = 0;
sqlite3_stmt* pst = NULL;
char sql[512];
snprintf(sql, sizeof(sql),
"SELECT node_id, node_type FROM \"%s\"", peers_tbl);
if (sqlite3_prepare_v2(db, sql, -1, &pst, NULL) == SQLITE_OK) {
while (sqlite3_step(pst) == SQLITE_ROW) {
uint64_t nid = (uint64_t)sqlite3_column_int64(pst, 0);
int ntype = sqlite3_column_int(pst, 1);
/* alive = BGP visible */
struct TOPO_GROUP* grp = groups ? topo_groups_find(groups, channel_id) : NULL;
if (!grp) continue;
struct TOPO_GROUP_NODE* nq = topo_node_find_by_id(grp, nid);
if (!nq || !(nq->conn_presence & (NCONN_DIRECT | NCONN_INDIRECT | NCONN_BGP)))
continue;
/* public v4+v6? */
int has_v4 = 0, has_v6 = 0;
sqlite3_stmt* ast = NULL;
snprintf(sql, sizeof(sql),
"SELECT family FROM node_addresses WHERE node_id=? AND addr_type=%d",
ADDR_TYPE_DIRECT);
if (sqlite3_prepare_v2(db, sql, -1, &ast, NULL) == SQLITE_OK) {
sqlite3_bind_int64(ast, 1, (sqlite3_int64)nid);
while (sqlite3_step(ast) == SQLITE_ROW) {
if (sqlite3_column_int(ast, 0) == 4) has_v4 = 1;
if (sqlite3_column_int(ast, 0) == 6) has_v6 = 1;
}
sqlite3_finalize(ast);
}
int has_both = (has_v4 && has_v6) ? 1 : 0;
/* priority */
int priority;
if (nid == my_nid) priority = 2; /* self */
else if (ntype == 4) priority = 3; /* supernode */
else priority = 1; /* regular */
/* pick best (lower priority, or same → later) */
if (priority <= best_priority) {
best_nid = nid; best_priority = priority;
best_has_both_public = has_both;
}
}
sqlite3_finalize(pst);
}
/* ── Фаза 2: fallback ── */
uint64_t target_nid = best_nid ? best_nid : my_nid;
int use_self = (target_nid == my_nid) ? 1 : 0;
bridge_log(BLEV_INFO, "encode_invite_link: ch=%llu best=0x%016llx prio=%d self=%d both=%d tot_peer_rows=%d",
(unsigned long long)channel_id, (unsigned long long)target_nid,
best_priority, use_self, best_has_both_public,
best_nid ? 1 : 0);
/* ── Фаза 3: pubkey ── */
struct InviteDataC data; memset(&data, 0, sizeof(data));
data.channelId = channel_id; data.nodeId = target_nid;
if (use_self) {
memcpy(data.pubkey, g_cc.inst->my_keys.public_key, INVITE_PUBKEY_SIZE);
} else {
sqlite3_stmt* pkst = NULL;
snprintf(sql, sizeof(sql), "SELECT x25519_pubkey FROM nodes WHERE node_id=?");
if (sqlite3_prepare_v2(db, sql, -1, &pkst, NULL) == SQLITE_OK) {
sqlite3_bind_int64(pkst, 1, (sqlite3_int64)target_nid);
if (sqlite3_step(pkst) == SQLITE_ROW) {
const void* pk = sqlite3_column_blob(pkst, 0);
int pklen = sqlite3_column_bytes(pkst, 0);
if (pk && pklen >= 32) memcpy(data.pubkey, pk, INVITE_PUBKEY_SIZE);
}
sqlite3_finalize(pkst);
}
if (data.pubkey[0] == 0) {
bridge_log(BLEV_ERROR, "encode_invite_link: no pubkey for selected node=0x%016llx",
(unsigned long long)target_nid);
return NULL;
}
}
/* ── Фаза 4: адреса ── */
if (use_self) {
data.addrCount = (uint8_t)bridge_collect_invite_addrs(data.addrs, INVITE_ADDR_MAX);
} else {
int dir_only = best_has_both_public ? 1 : 0;
data.addrCount = (uint8_t)bridge_collect_invite_addrs_from_db(
db, target_nid, data.addrs, INVITE_ADDR_MAX, dir_only);
}
if (data.addrCount == 0) {
bridge_log(BLEV_ERROR, "encode_invite_link: no addrs for node=0x%016llx",
(unsigned long long)target_nid);
return NULL;
}
bridge_log(BLEV_INFO, "encode_invite_link: node=0x%016llx addrs=%d dir_only=%d",
(unsigned long long)target_nid, data.addrCount,
(!use_self && best_has_both_public) ? 1 : 0);
/* ── Фаза 5: encode ── */
char out[512];
int r = invite_link_encode(&data, password, out, sizeof(out));
if (r < 0) {
bridge_log(BLEV_ERROR, "encode_invite_link: encode failed r=%d", r);
char out[1024];
if (chat_invite_build_link(channel_id, 0, password, out, sizeof(out)) < 0) {
bridge_log(BLEV_ERROR, "encode_invite_link: chat_invite_build_link failed ch=%llu",
(unsigned long long)channel_id);
return NULL;
}
return u_strdup(out);
+150 -17
View File
@@ -1,15 +1,29 @@
#include "invitedialog.h"
#include "qrcode_utils.h"
#include "../db/db_manager.h"
#include "../transport/gui_bridge.h"
#include "debug_ui.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QLineEdit>
#include <QPushButton>
#include <QLabel>
#include <QComboBox>
#include <QClipboard>
#include <QApplication>
#include <QSignalBlocker>
#include <cstring>
ShareInviteDialog::ShareInviteDialog(const QString& link, QWidget* parent)
: QDialog(parent), m_link(link)
extern "C" {
#include "../../../src/chat/invite_build.h"
#include "../../../lib/mem.h"
}
ShareInviteDialog::ShareInviteDialog(const QString& channelId, DbManager* db, QWidget* parent)
: QDialog(parent)
, m_channelId(channelId)
, m_db(db)
{
setWindowTitle(QString::fromUtf8("Пригласительная ссылка"));
setMinimumWidth(420);
@@ -18,33 +32,152 @@ ShareInviteDialog::ShareInviteDialog(const QString& link, QWidget* parent)
auto *layout = new QVBoxLayout(this);
layout->setSpacing(12);
auto *linkEdit = new QLineEdit(m_link, this);
linkEdit->setReadOnly(true);
linkEdit->setStyleSheet("padding: 6px; font-family: monospace;");
layout->addWidget(linkEdit);
auto *nodeLabel = new QLabel(QString::fromUtf8("Узел входа:"), this);
nodeLabel->setStyleSheet("font-size: 12px; color: palette(window-text);");
layout->addWidget(nodeLabel);
m_nodeCombo = new QComboBox(this);
m_nodeCombo->setStyleSheet(
"QComboBox { border: 1px solid palette(mid); border-radius: 4px;"
" padding: 6px 10px; font-size: 13px; background: palette(base); }");
layout->addWidget(m_nodeCombo);
m_linkEdit = new QLineEdit(this);
m_linkEdit->setReadOnly(true);
m_linkEdit->setPlaceholderText(QString::fromUtf8("Генерация ссылки..."));
m_linkEdit->setStyleSheet("padding: 6px; font-family: monospace;");
layout->addWidget(m_linkEdit);
auto *btnLayout = new QHBoxLayout();
auto *copyBtn = new QPushButton(QString::fromUtf8("Копировать"), this);
connect(copyBtn, &QPushButton::clicked, this, [this, linkEdit]() {
connect(copyBtn, &QPushButton::clicked, this, [this]() {
QClipboard* cb = QApplication::clipboard();
cb->setText(m_link, QClipboard::Clipboard);
cb->setText(m_linkEdit->text(), QClipboard::Clipboard);
if (cb->supportsSelection())
cb->setText(m_link, QClipboard::Selection);
linkEdit->setFocus();
cb->setText(m_linkEdit->text(), QClipboard::Selection);
m_linkEdit->setFocus();
});
btnLayout->addWidget(copyBtn);
btnLayout->addStretch();
layout->addLayout(btnLayout);
auto *qrLabel = new QLabel(this);
QImage qrImage = generateQRCode(m_link);
if (!qrImage.isNull())
qrLabel->setPixmap(QPixmap::fromImage(qrImage).scaled(
180, 180, Qt::KeepAspectRatio, Qt::SmoothTransformation));
qrLabel->setAlignment(Qt::AlignCenter);
layout->addWidget(qrLabel, 0, Qt::AlignCenter);
m_qrLabel = new QLabel(this);
m_qrLabel->setAlignment(Qt::AlignCenter);
layout->addWidget(m_qrLabel, 0, Qt::AlignCenter);
auto *closeBtn = new QPushButton("OK", this);
connect(closeBtn, &QPushButton::clicked, this, &QDialog::accept);
layout->addWidget(closeBtn, 0, Qt::AlignRight);
connect(m_nodeCombo, QOverload<int>::of(&QComboBox::currentIndexChanged),
this, &ShareInviteDialog::onNodeSelected);
/* колбэки результатов (по образцу JoinDialog) */
gui_bridge_set_invite_link_ready_cb([](const char* chId, int chIdLen, const char* link) {
ShareInviteDialog* dlg = qobject_cast<ShareInviteDialog*>(QApplication::activeModalWidget());
if (dlg) dlg->onLinkReady(chId, chIdLen, link);
});
gui_bridge_set_invite_candidates_cb([](const char* chId, int chIdLen, const uint8_t* data, int len) {
ShareInviteDialog* dlg = qobject_cast<ShareInviteDialog*>(QApplication::activeModalWidget());
if (dlg) dlg->onCandidates(chId, chIdLen, data, len);
});
/* запросить кандидатов; линк строится после их прихода */
QByteArray chBytes = m_channelId.toUtf8();
gui_bridge_post_uasync_fn(chat_invite_candidates_trampoline, u_strdup(chBytes.constData()));
}
ShareInviteDialog::~ShareInviteDialog() {
gui_bridge_set_invite_link_ready_cb(nullptr);
gui_bridge_set_invite_candidates_cb(nullptr);
}
void ShareInviteDialog::onNodeSelected(int index) {
if (index < 0) return;
qulonglong nodeId = m_nodeCombo->itemData(index).toULongLong();
requestLink((uint64_t)nodeId);
}
void ShareInviteDialog::requestLink(uint64_t targetNodeId) {
struct chat_invite_build_req* req = (struct chat_invite_build_req*)u_malloc(sizeof(*req));
if (!req) {
GUI_ERROR("invite: requestLink — alloc failed");
return;
}
memset(req, 0, sizeof(*req));
QByteArray chBytes = m_channelId.toUtf8();
strncpy(req->ch_id, chBytes.constData(), sizeof(req->ch_id) - 1);
req->target_node_id = targetNodeId;
m_linkEdit->setText(QString());
m_linkEdit->setPlaceholderText(QString::fromUtf8("Генерация ссылки..."));
m_qrLabel->clear();
gui_bridge_post_uasync_fn(chat_invite_build_link_trampoline, req);
}
void ShareInviteDialog::setLink(const QString& link) {
m_linkEdit->setText(link);
QImage qrImage = generateQRCode(link);
if (!qrImage.isNull())
m_qrLabel->setPixmap(QPixmap::fromImage(qrImage).scaled(
180, 180, Qt::KeepAspectRatio, Qt::SmoothTransformation));
}
void ShareInviteDialog::onCandidates(const char* chId, int chIdLen, const uint8_t* data, int len) {
QString cid = QString::fromUtf8(chId, chIdLen);
if (cid != m_channelId) return;
if (!data || len < 10) return;
uint64_t autoId = 0;
memcpy(&autoId, data, 8);
uint16_t count = 0;
memcpy(&count, data + 8, 2);
if (autoId == 0) {
m_linkEdit->setText(QString());
m_linkEdit->setPlaceholderText(QString::fromUtf8("Не удалось определить узел (см. лог)"));
return;
}
auto nodeLabel = [this](uint64_t nid) {
QString name = m_db ? m_db->getDisplayName(nid) : QString();
if (name.isEmpty())
name = QString("0x%1").arg(nid, 16, 16, QChar('0'));
return name;
};
/* блокируем сигналы, чтобы не перезапрашивать линк при добавлении пунктов */
QSignalBlocker blocker(m_nodeCombo);
m_nodeCombo->clear();
/* пункт 0 — узел, найденный авто-выбором */
m_nodeCombo->addItem(nodeLabel(autoId) + QString::fromUtf8(" (auto)"),
QVariant::fromValue((qulonglong)autoId));
/* остальные узлы (пропуская авто), свой помечаем (self) */
int off = 10;
for (int i = 0; i < (int)count && off + 8 <= len; i++) {
uint64_t nid = 0;
memcpy(&nid, data + off, 8);
off += 8;
if (nid == autoId) continue;
QString label = nodeLabel(nid);
if (m_db && nid == m_db->myNodeId())
label += QString::fromUtf8(" (self)");
m_nodeCombo->addItem(label, QVariant::fromValue((qulonglong)nid));
}
/* строим линк для авто-узла (сигнал заблокирован) */
requestLink(autoId);
}
void ShareInviteDialog::onLinkReady(const char* chId, int chIdLen, const char* link) {
QString cid = QString::fromUtf8(chId, chIdLen);
if (cid != m_channelId) return;
if (!link || link[0] == '\0') {
m_linkEdit->setText(QString());
m_linkEdit->setPlaceholderText(QString::fromUtf8("Не удалось собрать ссылку (см. лог)"));
m_qrLabel->clear();
return;
}
setLink(QString::fromUtf8(link));
}
+21 -2
View File
@@ -2,12 +2,31 @@
#include <QDialog>
#include <QString>
#include <stdint.h>
class DbManager;
class QComboBox;
class QLineEdit;
class QLabel;
class ShareInviteDialog : public QDialog {
Q_OBJECT
public:
explicit ShareInviteDialog(const QString& link, QWidget* parent = nullptr);
explicit ShareInviteDialog(const QString& channelId, DbManager* db, QWidget* parent = nullptr);
~ShareInviteDialog();
private slots:
void onNodeSelected(int index);
private:
QString m_link;
void requestLink(uint64_t targetNodeId);
void setLink(const QString& link);
void onCandidates(const char* chId, int chIdLen, const uint8_t* data, int len);
void onLinkReady(const char* chId, int chIdLen, const char* link);
QString m_channelId;
DbManager* m_db;
QComboBox* m_nodeCombo;
QLineEdit* m_linkEdit;
QLabel* m_qrLabel;
};
+1 -18
View File
@@ -430,24 +430,7 @@ void MainWindow::onTrayActivated(QSystemTrayIcon::ActivationReason reason) {
void MainWindow::showShareInviteDialog(const QString& channelId) {
if (!m_db || !m_db->isOpen()) return;
QByteArray pubkey;
if (m_node && m_node->isRunning())
pubkey = QByteArray::fromHex(m_node->pubKeyHex().toLatin1());
if (pubkey.size() != 32) {
QMessageBox::warning(this, "Error", "Failed to get node public key.");
return;
}
QList<NodeAddr> addrs = m_node->getInviteAddresses(m_db);
QString link = encodeInviteLink(channelId.toULongLong(), pubkey, addrs);
if (link.isEmpty()) {
QMessageBox::warning(this, "Error", "Failed to create invite link.");
return;
}
ShareInviteDialog dlg(link, this);
ShareInviteDialog dlg(channelId, m_db, this);
dlg.exec();
}
+24 -66
View File
@@ -488,40 +488,6 @@ void MessageList::refresh() {
m_readDebounce->start();
}
void MessageList::addMessage(const QString& channelId, quint64 authorNodeId,
const QByteArray& content, qint64 timestamp) {
if (channelId != m_currentChannelId) return;
AccountRow acc = m_db ? m_db->getAccount(authorNodeId) : AccountRow();
QString author = m_db ? authorName(authorNodeId) : QString("node_%1").arg(authorNodeId);
QString letter = acc.avatarLetter.isEmpty() ? author.mid(0, 1) : acc.avatarLetter;
QColor color(acc.avatarColor);
auto avatar = makeAvatar(color, letter);
auto time = QDateTime::fromMSecsSinceEpoch(timestamp).toString("HH:mm");
/* dedup */
for (int r = 0; r < m_model->rowCount(); r++) {
auto* it = m_model->item(r);
if (it->data(MsgTimeRole).toString() == time
&& it->data(MsgAuthorRole).toString() == author) return;
}
auto *item = new QStandardItem();
setMsg(item, author, QString::fromUtf8(content), time, avatar, false,
false, QString(), QString(), QVariantList{});
bool isSelf = m_db && (authorNodeId == m_db->myNodeId());
item->setData(QVariant::fromValue(authorNodeId), MsgAuthorNodeIdRole);
item->setData(isSelf ? 1 : 0, MsgIsOutgoingRole);
setVoiceMessageRoles(item, content, QString(),
m_db ? m_db->dbPath() + "/media" : QString(), m_currentChannelId, QByteArray());
m_model->appendRow(item);
if (m_model->rowCount() > 50)
m_model->removeRow(0);
m_view->scrollToBottom();
}
void MessageList::saveChannelState() {
if (m_currentChannelId.isEmpty() || m_model->rowCount() == 0) return;
@@ -679,44 +645,36 @@ void MessageList::updateMessageProgress(const QString& chId, int64_t msgId,
}
void MessageList::onAttachmentDownloaded(const QString& chId, int64_t msgId) {
QString ct;
QString filePath;
if (chId != m_currentChannelId || !m_db || !m_db->isOpen()) return;
MessageRow m = m_db->getMessageById(chId, msgId);
if (m.id == 0) return;
int row = -1;
bool wasPending = false;
for (int r = 0; r < m_model->rowCount(); r++) {
QStandardItem* item = m_model->item(r);
if (!item) continue;
if (item->data(MsgIdRole).toLongLong() == msgId) {
QStandardItem* it = m_model->item(r);
if (it && it->data(MsgIdRole).toLongLong() == msgId) {
row = r;
item->setData(2, MsgFileDownloadStateRole);
item->setData(0, MsgMediaBlocksDoneRole);
ct = item->data(MsgContentTypeRole).toString();
filePath = item->data(MsgVoiceFileRole).toString();
wasPending = it->data(MsgVoiceDownloadPendingRole).toBool();
break;
}
}
if (row >= 0 && chId == m_currentChannelId) {
bool wasPending = false;
for (int r = 0; r < m_model->rowCount(); r++) {
QStandardItem* it = m_model->item(r);
if (it && it->data(MsgIdRole).toLongLong() == msgId) {
wasPending = it->data(MsgVoiceDownloadPendingRole).toBool();
if (wasPending) it->setData(false, MsgVoiceDownloadPendingRole);
break;
}
}
refresh(); /* reload to get updated local_attrs with file path */
/* auto-play only if user clicked play to initiate download */
if (ct == "audio/opus" && wasPending) {
QTimer::singleShot(500, this, [this, msgId]() {
for (int r = 0; r < m_model->rowCount(); r++) {
QStandardItem* it = m_model->item(r);
if (it && it->data(MsgIdRole).toLongLong() == msgId) {
QString fp = it->data(MsgVoiceFileRole).toString();
if (!fp.isEmpty() && QFile::exists(fp))
VoicePlayer::playOpusFile(fp);
break;
}
}
if (row < 0) return;
QStandardItem* item = m_model->item(row);
setVoiceMessageRoles(item, m.data, m.contentType,
m_db->dbPath() + "/media", chId, m.localAttrs);
item->setData(0, MsgMediaBlocksDoneRole);
item->setData(false, MsgVoiceDownloadPendingRole);
if (m.contentType == "audio/opus" && wasPending) {
QString fp = item->data(MsgVoiceFileRole).toString();
if (!fp.isEmpty() && QFile::exists(fp)) {
QTimer::singleShot(500, this, [this, fp]() {
MessageDelegate::stopAllAutoPlay();
float actualDur = VoicePlayer::playOpusFile(fp);
MessageDelegate::startAutoPlay(fp, actualDur);
});
}
}
-2
View File
@@ -24,8 +24,6 @@ public:
void reloadLocalNicks();
void saveChannelState();
static void cleanupAnimations();
void addMessage(const QString& channelId, quint64 authorNodeId,
const QByteArray& content, qint64 timestamp);
void updateMessageProgress(const QString& chId, int64_t msgId, int blocksDone, int numBlocks);
void onAttachmentDownloaded(const QString& chId, int64_t msgId);
+12 -9
View File
@@ -32,10 +32,19 @@ bool SoundManager::init() {
return true;
}
void SoundManager::clearCurrentPcm() {
if (m_currentPcmSound) { ma_sound_uninit(m_currentPcmSound); delete m_currentPcmSound; m_currentPcmSound = nullptr; }
if (m_currentPcmBuffer) {
ma_audio_buffer* buf = (ma_audio_buffer*)m_currentPcmBuffer;
ma_audio_buffer_uninit(buf); delete buf; m_currentPcmBuffer = nullptr;
}
m_currentPcmSampleRate = 0;
}
void SoundManager::shutdown() {
if (!m_initialized) return;
stopCurrentPcm();
clearCurrentPcm();
for (auto it = m_sounds.begin(); it != m_sounds.end(); ++it) {
if (it->sound) { ma_sound_uninit(it->sound); delete it->sound; }
@@ -45,11 +54,6 @@ void SoundManager::shutdown() {
DEBUG_DEBUG(DEBUG_CATEGORY_GENERAL, "SoundManager: before ma_engine_uninit engine=%p", (void*)m_engine);
if (m_engine) { ma_engine_uninit(m_engine); delete m_engine; m_engine = nullptr; }
if (m_currentPcmSound) { delete m_currentPcmSound; m_currentPcmSound = nullptr; }
if (m_currentPcmBuffer) {
ma_audio_buffer* buf = (ma_audio_buffer*)m_currentPcmBuffer;
ma_audio_buffer_uninit(buf); delete buf; m_currentPcmBuffer = nullptr;
}
DEBUG_DEBUG(DEBUG_CATEGORY_GENERAL, "SoundManager: after ma_engine_uninit");
m_initialized = false;
}
@@ -152,7 +156,7 @@ void SoundManager::playRawPcm(const int16_t* pcm, int sampleCount, int sampleRat
DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "SoundManager::playRawPcm: not initialized");
return;
}
if (m_currentPcmSound) ma_sound_stop(m_currentPcmSound);
clearCurrentPcm();
m_currentPcmSampleRate = sampleRate;
DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "SoundManager::playRawPcm: %d samples %dHz %dch", sampleCount, sampleRate, channels);
@@ -189,8 +193,7 @@ void SoundManager::resumeCurrentPcm() {
}
void SoundManager::stopCurrentPcm() {
if (m_currentPcmSound) ma_sound_stop(m_currentPcmSound);
m_currentPcmSampleRate = 0;
clearCurrentPcm();
}
unsigned long long SoundManager::currentPcmCursor() const {
+2
View File
@@ -58,6 +58,8 @@ private:
SoundManager() = default;
~SoundManager();
void clearCurrentPcm();
struct LoadedSound {
QByteArray mp3Data;
ma_decoder* decoder = nullptr;
+10
View File
@@ -36,6 +36,8 @@ struct TOPO_GROUP_NODE;
#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][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 */
/* Размеры элементов пакета GUI_EVT_MEMBER_DETAIL (должны совпадать с chat_status.c) */
#define MEMBER_DETAIL_LINK_SIZE 63
@@ -147,6 +149,14 @@ void gui_bridge_set_bgp_info_cb(gui_bgp_info_fn cb);
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);
/* Callback: готова invite-ссылка (link = пустая строка при ошибке, детали в логе) */
typedef void (*gui_invite_link_ready_fn)(const char* ch_id, int ch_id_len, const char* link);
void gui_bridge_set_invite_link_ready_cb(gui_invite_link_ready_fn cb);
/* Callback: список достижимых узлов-кандидатов для invite — data: [auto_node_id:8][count:2][node_id:8]* */
typedef void (*gui_invite_candidates_fn)(const char* ch_id, int ch_id_len, const uint8_t* data, int len);
void gui_bridge_set_invite_candidates_cb(gui_invite_candidates_fn cb);
/* uTun nodeinfo callback — registered via utun_add_nodeinfo_cbk, called from uasync thread */
void gui_nodeinfo_cb_impl(struct TOPO_GROUP* group, struct TOPO_GROUP_NODE* nq, void* arg);
@@ -46,6 +46,8 @@ 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;
static struct UASYNC* g_ua = nullptr;
/* ── GuiBridgeReceiver implementation ── */
@@ -210,6 +212,29 @@ void GuiBridgeReceiver::processPost(int eventType, QByteArray data) {
}
}
break;
case GUI_EVT_INVITE_LINK_READY:
if (dlen >= 3) {
uint8_t chLen = d[0];
uint16_t linkLen = 0; memcpy(&linkLen, d + 1 + chLen, 2);
if (dlen >= 1 + chLen + 2 + linkLen) {
if (linkLen == 0) {
DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "gui_bridge: INVITE_LINK_READY ch=%.*s — empty link (build failed)",
chLen, (const char*)d + 1);
if (g_invite_link_ready_cb) g_invite_link_ready_cb((const char*)d + 1, chLen, "");
} else {
if (g_invite_link_ready_cb) g_invite_link_ready_cb((const char*)d + 1, chLen, (const char*)(d + 1 + chLen + 2));
}
}
}
break;
case GUI_EVT_INVITE_CANDIDATES:
if (dlen >= 2) {
uint8_t chLen = d[0];
if (dlen >= 1 + chLen + 10) {
if (g_invite_candidates_cb) g_invite_candidates_cb((const char*)d + 1, chLen, d + 1 + chLen, dlen - 1 - chLen);
}
}
break;
default:
DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "gui_bridge: unknown event type %d", eventType);
break;
@@ -335,6 +360,14 @@ void gui_bridge_set_admin_key_received_cb(gui_admin_key_received_fn cb) {
g_admin_key_received_cb = cb;
}
void gui_bridge_set_invite_link_ready_cb(gui_invite_link_ready_fn cb) {
g_invite_link_ready_cb = cb;
}
void gui_bridge_set_invite_candidates_cb(gui_invite_candidates_fn cb) {
g_invite_candidates_cb = cb;
}
} /* extern "C" */
/* ── uTun nodeinfo callback (called from uasync thread) ── */
-115
View File
@@ -2,14 +2,9 @@
#include "utun_node.h"
#include <cinttypes>
#include <QDir>
#include <QMap>
#include <QPair>
#include <csignal>
#include "../../lib/socket_compat.h"
#include "../db/db_manager.h"
#include "../src/invite_link.h"
// === C headers ===
extern "C" {
#include "utun_instance.h"
@@ -139,116 +134,6 @@ bool UtunNode::ntpEnabled() const {
return m_instance ? m_instance->ntp.enabled : false;
}
QList<NodeAddr> UtunNode::getInviteAddresses(DbManager* db) {
(void)db;
QList<NodeAddr> addrs;
QMap<QPair<QByteArray, quint16>, uint8_t> addrProto; // key=(addr,port) → proto bits
auto add_addr = [&](const QByteArray& addr, quint16 port, int family,
int sockId, uint8_t proto) {
QPair<QByteArray, quint16> key(addr, port);
uint8_t existing = addrProto.value(key, 0);
addrProto[key] = existing | proto;
// Store sockId/family for serialization (overwrite is fine — same port)
};
auto append_from_addr_proto = [&](const QByteArray& addr, quint16 port,
int family, int sockId, uint8_t proto) {
NodeAddr a;
a.family = family;
a.address = addr;
a.port = port;
a.socketId = sockId;
a.protocol = proto;
addrs.append(a);
};
if (m_instance) {
/* 1) UDP from active ETCP links */
struct ll_entry* entry = m_instance->connections->head;
while (entry) {
struct conn_queue_entry* ce = (struct conn_queue_entry*)entry->data;
struct ETCP_CONN* conn = ce->conn;
if (conn && conn->initialized && conn->links_up > 0) {
struct ETCP_LINK* link = conn->links;
while (link) {
if (!link->is_tcp && link->link_state == 3 && link->nat_type == NAT_TYPE_DIRECT) {
struct sockaddr_storage* sa = &link->remote_addr;
if (sa->ss_family == AF_INET) {
struct sockaddr_in* sin = (struct sockaddr_in*)sa;
QByteArray addr((const char*)&sin->sin_addr, 4);
add_addr(addr, ntohs(sin->sin_port), 4,
link->conn ? (int)link->conn->sock_id : 0, INVITE_PROTO_UDP);
} else {
struct sockaddr_in6* sin6 = (struct sockaddr_in6*)sa;
QByteArray addr((const char*)&sin6->sin6_addr, 16);
add_addr(addr, ntohs(sin6->sin6_port), 6,
link->conn ? (int)link->conn->sock_id : 0, INVITE_PROTO_UDP);
}
}
link = link->next;
}
}
entry = entry->next;
}
/* 2) UDP from local ETCP sockets (fallback) */
{
struct ETCP_SOCKET* sock = m_instance->etcp_sockets;
while (sock) {
if (sock->is_tcp) { sock = sock->next; continue; }
struct sockaddr_storage* sa = NULL;
if (sock->nat_addr.ss_family != 0)
sa = &sock->nat_addr;
else if (sock->interface_addr.ss_family != 0)
sa = &sock->interface_addr;
if (sa) {
if (sa->ss_family == AF_INET) {
struct sockaddr_in* sin = (struct sockaddr_in*)sa;
QByteArray addr((const char*)&sin->sin_addr, 4);
add_addr(addr, ntohs(sin->sin_port), 4, (int)sock->sock_id, INVITE_PROTO_UDP);
} else {
struct sockaddr_in6* sin6 = (struct sockaddr_in6*)sa;
QByteArray addr((const char*)&sin6->sin6_addr, 16);
add_addr(addr, ntohs(sin6->sin6_port), 6, (int)sock->sock_id, INVITE_PROTO_UDP);
}
}
sock = sock->next;
}
}
/* 3) TCP from local TCP sockets */
{
struct ETCP_SOCKET* s = m_instance->etcp_sockets;
while (s) {
if (!s->is_tcp) { s = s->next; continue; }
struct sockaddr_storage* sa = &s->interface_addr;
if (sa->ss_family == AF_INET) {
struct sockaddr_in* sin = (struct sockaddr_in*)sa;
QByteArray addr((const char*)&sin->sin_addr, 4);
add_addr(addr, ntohs(sin->sin_port), 4, (int)s->sock_id, INVITE_PROTO_TCP);
} else if (sa->ss_family == AF_INET6) {
struct sockaddr_in6* sin6 = (struct sockaddr_in6*)sa;
QByteArray addr((const char*)&sin6->sin6_addr, 16);
add_addr(addr, ntohs(sin6->sin6_port), 6, (int)s->sock_id, INVITE_PROTO_TCP);
}
s = s->next;
}
}
/* 4) Dedup by (addr,port); emit one entry per proto (UDP и TCP раздельно, без BOTH) */
for (auto it = addrProto.begin(); it != addrProto.end(); ++it) {
QByteArray addr = it.key().first;
quint16 port = it.key().second;
uint8_t proto = it.value();
int family = (addr.size() == 16) ? 6 : 4;
if (proto & INVITE_PROTO_UDP) append_from_addr_proto(addr, port, family, 0, INVITE_PROTO_UDP);
if (proto & INVITE_PROTO_TCP) append_from_addr_proto(addr, port, family, 0, INVITE_PROTO_TCP);
}
}
return addrs;
}
void UtunNode::recvCallback(struct ETCP_CONN* conn, struct ll_entry* entry) {
if (!g_currentNode || !entry || !entry->dgram || entry->len < ROUTER_SVC_HDR_SIZE) return;
uint64_t src;
-4
View File
@@ -5,14 +5,11 @@
#include <QObject>
#include <QByteArray>
#include <QString>
#include <QList>
#include <atomic>
#include <cstdint>
#include <thread>
struct UTUN_INSTANCE;
class DbManager;
struct NodeAddr;
class UtunNode : public QObject {
Q_OBJECT
@@ -37,7 +34,6 @@ public:
QString nodeIdHex() const;
QString pubKeyHex() const;
QList<NodeAddr> getInviteAddresses(DbManager* db);
int64_t ntpOffsetUs() const;
bool ntpSynced() const;
bool ntpEnabled() const;