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

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