diff --git a/tools/chatgui/src/accountlist.cpp b/tools/chatgui/src/accountlist.cpp index e7e5e8a90..2f63dfa2d 100644 --- a/tools/chatgui/src/accountlist.cpp +++ b/tools/chatgui/src/accountlist.cpp @@ -666,7 +666,11 @@ void AccountList::onMemberContextMenu(const QPoint& pos) { connect(this, &QObject::destroyed, &menu, &QMenu::close); auto* rename = menu.addAction(QCoreApplication::translate("ChatGui", "Rename")); rename->setEnabled(gui_bridge_is_uasync_ready()); - if (menu.exec(view->viewport()->mapToGlobal(pos)) == rename && alive && channel == m_channelId) renameGroup(group); + auto* finish = hasPendingDevicePairing() ? menu.addAction(QCoreApplication::translate("ChatGui", "Finish pairing")) : nullptr; + auto* chosen = menu.exec(view->viewport()->mapToGlobal(pos)); + if (!chosen || !alive || channel != m_channelId || !ownGroupNode(group)) return; + if (chosen == rename) renameGroup(group); + else if (finish && chosen == finish) finishDevicePairing(this); return; } const quint64 nodeId = index.data(MemberListModel::NodeIdRole).toULongLong(); @@ -695,6 +699,8 @@ void AccountList::onMemberContextMenu(const QPoint& pos) { QAction* filesAction = menu.addAction(QCoreApplication::translate("ChatGui", "Member files")); QAction* myDeviceAction = canLinkMyDevice(nodeId) ? menu.addAction(QCoreApplication::translate("ChatGui", "This is my device")) : nullptr; if (myDeviceAction) myDeviceAction->setEnabled(gui_bridge_is_uasync_ready()); + QAction* finishAction = self && hasPendingDevicePairing() ? + menu.addAction(QCoreApplication::translate("ChatGui", "Finish pairing")) : nullptr; QAction* chosen = menu.exec(view->viewport()->mapToGlobal(pos)); if (!alive || !chosen) return; const auto current = m_model->member(nodeId); @@ -703,7 +709,7 @@ void AccountList::onMemberContextMenu(const QPoint& pos) { (unsigned long long)nodeId); return; } - if (current->is_self && chosen != editAction && chosen != filesAction) { + if (current->is_self && chosen != editAction && chosen != filesAction && chosen != finishAction) { DEBUG_DEBUG(DEBUG_CATEGORY_CONNECTION, "member menu action cancelled: target became self node=%016llx", (unsigned long long)nodeId); return; } @@ -712,6 +718,7 @@ void AccountList::onMemberContextMenu(const QPoint& pos) { return; } const QString name = QString::fromUtf8(current->name); + if (finishAction && chosen == finishAction && current->is_self) { finishDevicePairing(this); return; } if (myDeviceAction && chosen == myDeviceAction) { confirmMyDeviceLink(nodeId); return; } if (chosen == inviteAction) { emit inviteRequested(channel, nodeId, name); return; } if (chosen == radioCallAction) { emit radioCallRequested(channel, nodeId); return; } diff --git a/tools/chatgui/src/devgroupdialog.cpp b/tools/chatgui/src/devgroupdialog.cpp index 9b3e84ad4..380fca441 100644 --- a/tools/chatgui/src/devgroupdialog.cpp +++ b/tools/chatgui/src/devgroupdialog.cpp @@ -29,21 +29,30 @@ static QHash> renameRequests; static quint64 nextRequest = 0; static QPointer invitationWindow; static quint64 statusRequest = 0, lastAutomaticSnapshot = 0; -static QHash acceptRequests; -static QHash> pinDialogs; -static QSet dismissedInvites, awaitingPins, attemptedPins; +static QJsonObject invitationSnapshot; static void refreshInvitations(); -static void automaticInvitations(const QJsonObject& snapshot); static void devgroupResult(const uint8_t* data, int len); +static bool pendingState(int state) { return state == 1 || state == 2 || state == 3 || state == 8 || state == 9 || state == 10; } class DevgroupDialog : public QDialog { public: static QString tr(const char* text) { return QCoreApplication::translate("DevgroupDialog", text); } void refresh() { send(CHAT_DEVGROUP_STATUS); } - DevgroupDialog(QWidget* parent, const QString& channel, quint64 peer) : QDialog(parent), channel(channel), peer(peer) { - setAttribute(Qt::WA_DeleteOnClose); setModal(true); setWindowTitle(tr("Linking devices")); resize(420, 140); + DevgroupDialog(QWidget* parent, const QString& channel, quint64 peer, const QString& id = {}) + : QDialog(parent), channel(channel), outgoingId(id), peer(peer) { + setAttribute(Qt::WA_DeleteOnClose); setObjectName("devgroupPairingDialog"); setModal(true); + setWindowTitle(tr("Linking devices")); resize(480, 280); auto* layout = new QVBoxLayout(this); status = new QLabel(tr("Waiting for PIN"), this); status->setWordWrap(true); + status->setTextFormat(Qt::PlainText); status->setTextInteractionFlags(Qt::TextSelectableByMouse); layout->addWidget(status); + pin = new QLineEdit(this); pin->setObjectName("devgroupPinInput"); pin->setMaxLength(4); + pin->setValidator(new QRegularExpressionValidator(QRegularExpression("[0-9]{0,4}"), pin)); layout->addWidget(pin); + submit = new QPushButton(tr("Check PIN"), this); submit->setObjectName("devgroupCheckPin"); layout->addWidget(submit); + connect(pin, &QLineEdit::textChanged, this, [this] { submit->setEnabled(input && !accepting && pin->text().size() == 4); }); + connect(submit, &QPushButton::clicked, this, [this] { + if (!input || accepting || pin->text().size() != 4) return; + send(CHAT_DEVGROUP_ACCEPT); pin->clear(); + }); auto* close = new QDialogButtonBox(QDialogButtonBox::Close, this); layout->addWidget(close); connect(close, &QDialogButtonBox::rejected, this, &QDialog::close); auto* timer = new QTimer(this); timer->setInterval(1000); @@ -51,25 +60,46 @@ public: if (!gui_bridge_is_uasync_ready()) { pending.clear(); status->setText(tr("Core unavailable")); } else if (!pending.values().contains(CHAT_DEVGROUP_STATUS)) refresh(); }); - timer->start(); send(CHAT_DEVGROUP_INVITE); + timer->start(); + if (id.isEmpty()) { pin->hide(); submit->hide(); send(CHAT_DEVGROUP_INVITE); } + else update(invitationSnapshot); + } + void update(const QJsonObject& snapshot) { + for (const auto value : snapshot["invitations"].toArray()) { + const auto invitation = value.toObject(); if (invitation["id"].toString() != outgoingId) continue; + const int state = invitation["state"].toInt(), nextInput = invitation["input"].toInt(); + const bool outgoing = invitation["outgoing"].toInt() != 0; + const QString shownPin = invitation["pin"].toString(); + const QStringList states = {{}, tr("Waiting for PIN"), tr("Checking PIN"), tr("Transferring keys"), tr("Joined"), + tr("Rejected"), tr("Expired"), tr("Result unknown"), tr("Waiting for return PIN"), tr("Checking PIN"), tr("Transferring keys")}; + QString text = invitation["peer"].toString() + "\n\n"; + if (!outgoing) text += tr("Linking joins this device to your personal device subgroup and gives it the shared subgroup keys.") + + "\n\n" + tr("If you are not linking your own personal devices, simply close this window.") + "\n\n"; + if (!shownPin.isEmpty()) text += (outgoing ? tr("PIN: %1 — enter it on the other device within 2 minutes.") : + tr("Return PIN: %1 — enter it on the device that started linking." )).arg(shownPin) + "\n\n"; + if (nextInput) text += tr(nextInput == 1 ? "PIN shown on the inviting device" : "Enter the return PIN shown on the other device") + "\n"; + if (invitation["pin_error"].toInt()) text += tr("PIN was not accepted. Try again.") + "\n"; + status->setText(text + states.value(state)); + if (input != nextInput) pin->clear(); + input = nextInput; pin->setVisible(input); submit->setVisible(input); + pin->setEnabled(!accepting); submit->setEnabled(input && !accepting && pin->text().size() == 4); + return; + } + input = 0; pin->hide(); submit->hide(); status->setText(tr("Expired")); } void result(const uint8_t* data, int len) { if (len < 9) return; quint64 id = 0; for (int i = 0; i < 8; ++i) id |= quint64(data[i]) << (8*i); if (!pending.contains(id)) return; const int action = pending.take(id); - if (!data[8]) { status->setText(tr("Operation failed. Check connection and invitation status.")); return; } + if (action == CHAT_DEVGROUP_ACCEPT) accepting = false; + if (!data[8]) { status->setText(tr("Operation failed. Check connection and invitation status.")); pin->setEnabled(true); return; } const auto json = QJsonDocument::fromJson(QByteArray(reinterpret_cast(data+9), len-9)).object(); if (action == CHAT_DEVGROUP_INVITE) { - outgoingId = json["id"].toString(); pinText = tr("PIN: %1 — enter it on the other device within 2 minutes.").arg(json["pin"].toString()); - status->setText(pinText); return; - } - const QStringList states = {{}, tr("Waiting for PIN"), tr("Checking PIN"), tr("Transferring keys"), tr("Joined"), - tr("Rejected"), tr("Expired"), tr("Result unknown")}; - for (const auto value : json["invitations"].toArray()) { - const auto invitation = value.toObject(); const int state = invitation["state"].toInt(); - if (invitation["id"].toString() == outgoingId) status->setText((state < 4 ? pinText + "\n" : QString()) + states.value(state)); + outgoingId = json["id"].toString(); + status->setText(tr("PIN: %1 — enter it on the other device within 2 minutes.").arg(json["pin"].toString())); refresh(); return; } + update(json); } private: void send(chat_devgroup_action action) { @@ -77,13 +107,23 @@ private: if (!req) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "devgroup UI: allocation failed"); return; } req->inst = gui_bridge_get_inst(); req->request_id = ++nextRequest; req->group = channel.toULongLong(); req->peer = peer; req->action = action; const quint64 id = req->request_id; + if (action == CHAT_DEVGROUP_ACCEPT) { + const auto invitation = outgoingId.toLatin1(), entered = pin->text().toLatin1(); + memcpy(req->text, invitation.constData(), qMin(invitation.size(), qsizetype(32))); memcpy(req->pin, entered.constData(), 4); + } if (gui_bridge_post_uasync_owned_fn(chat_devgroup_trampoline, req) < 0) { - u_free(req); status->setText(tr("Core unavailable")); DEBUG_WARN(DEBUG_CATEGORY_CHAT, "devgroup UI: core unavailable"); return; + memset(req, 0, sizeof(*req)); u_free(req); status->setText(tr("Core unavailable")); + DEBUG_WARN(DEBUG_CATEGORY_CHAT, "devgroup UI: core unavailable"); return; + } + if (action == CHAT_DEVGROUP_ACCEPT) { + accepting = true; pin->setEnabled(false); submit->setEnabled(false); + DEBUG_INFO(DEBUG_CATEGORY_CHAT, "devgroup UI: PIN submitted invite=%s step=%d", qPrintable(outgoingId), input); } pending.insert(id, action); } - QString channel, outgoingId, pinText; quint64 peer; - QLabel* status; + QString channel, outgoingId; quint64 peer; + QLabel* status; QLineEdit* pin; QPushButton* submit; + int input = 0; bool accepting = false; QHash pending; }; static void devgroupResult(const uint8_t* data, int len) { @@ -112,13 +152,13 @@ static void devgroupResult(const uint8_t* data, int len) { } else DEBUG_INFO(DEBUG_CATEGORY_CHAT, "devgroup UI: own device disconnected"); return; } - const bool automatic = id == statusRequest || acceptRequests.contains(id); + const bool automatic = id == statusRequest; if (id == statusRequest) statusRequest = 0; - if (acceptRequests.contains(id)) awaitingPins.remove(acceptRequests.take(id)); if (automatic) { if (data[8] && id >= lastAutomaticSnapshot) { lastAutomaticSnapshot = id; - automaticInvitations(QJsonDocument::fromJson(QByteArray(reinterpret_cast(data+9), len-9)).object()); + invitationSnapshot = QJsonDocument::fromJson(QByteArray(reinterpret_cast(data+9), len-9)).object(); + if (active) active->update(invitationSnapshot); } else if (!data[8]) DEBUG_WARN(DEBUG_CATEGORY_CHAT, "devgroup UI: automatic PIN/status request refused"); } @@ -137,64 +177,40 @@ static void refreshInvitations() { } statusRequest = id; } -static void automaticInvitations(const QJsonObject& snapshot) { - if (!invitationWindow) return; - QSet waiting; - for (const auto value : snapshot["invitations"].toArray()) { - const auto entry = value.toObject(); const QString id = entry["id"].toString(); - if (entry["outgoing"].toInt() || entry["state"].toInt() != 1) continue; - waiting.insert(id); - if (pinDialogs.value(id) || dismissedInvites.contains(id) || awaitingPins.contains(id)) continue; - auto* dialog = new QInputDialog(invitationWindow); pinDialogs.insert(id, dialog); - dialog->setAttribute(Qt::WA_DeleteOnClose); dialog->setObjectName("devgroupIncomingPin"); - dialog->setWindowTitle(DevgroupDialog::tr("Linking devices")); - dialog->setLabelText(entry["peer"].toString() + "\n\n" + DevgroupDialog::tr( - "Linking joins this device to your personal device subgroup and gives it the shared subgroup keys.") + "\n\n" + - DevgroupDialog::tr("If you are not linking your own personal devices, simply close this window.") + "\n\n" + - DevgroupDialog::tr(attemptedPins.contains(id) ? - "PIN was not accepted. Try again." : "PIN shown on the inviting device")); - for (auto* label : dialog->findChildren()) { label->setTextFormat(Qt::PlainText); label->setWordWrap(true); } - dialog->resize(480, 260); - dialog->setInputMode(QInputDialog::TextInput); dialog->setWindowModality(Qt::ApplicationModal); - if (auto* edit = dialog->findChild()) { - edit->setMaxLength(4); edit->setValidator(new QRegularExpressionValidator(QRegularExpression("[0-9]{0,4}"), edit)); - } - QObject::connect(dialog, &QInputDialog::textValueChanged, dialog, [dialog](const QString& text) { - if (auto* box = dialog->findChild()) box->button(QDialogButtonBox::Ok)->setEnabled(text.size() == 4); - }); - QObject::connect(dialog, &QDialog::finished, dialog, [dialog, id](int result) { - pinDialogs.remove(id); - if (result != QDialog::Accepted) { dismissedInvites.insert(id); return; } - auto* req = static_cast(u_calloc(1, sizeof(chat_devgroup_request))); - if (!req) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "devgroup UI: PIN request allocation failed"); return; } - const auto pin = dialog->textValue().toLatin1(), invite = id.toLatin1(); - if (pin.size() != 4 || invite.size() != 32) { u_free(req); DEBUG_WARN(DEBUG_CATEGORY_CHAT, "devgroup UI: invalid PIN request"); return; } - req->inst = gui_bridge_get_inst(); req->action = CHAT_DEVGROUP_ACCEPT; req->request_id = ++nextRequest; - memcpy(req->text, invite.constData(), 32); memcpy(req->pin, pin.constData(), 4); - const quint64 request = req->request_id; - if (gui_bridge_post_uasync_owned_fn(chat_devgroup_trampoline, req) < 0) { - memset(req, 0, sizeof(*req)); u_free(req); DEBUG_WARN(DEBUG_CATEGORY_CHAT, "devgroup UI: PIN enqueue failed"); return; - } - awaitingPins.insert(id); attemptedPins.insert(id); acceptRequests.insert(request, id); - DEBUG_INFO(DEBUG_CATEGORY_CHAT, "devgroup UI: PIN submitted invite=%s", qPrintable(id)); - }); - dialog->open(); - if (auto* box = dialog->findChild()) box->button(QDialogButtonBox::Ok)->setEnabled(false); - DEBUG_INFO(DEBUG_CATEGORY_CHAT, "devgroup UI: incoming PIN dialog opened invite=%s", qPrintable(id)); +bool hasPendingDevicePairing() { + if (!gui_bridge_is_uasync_ready()) return false; + for (const auto value : invitationSnapshot["invitations"].toArray()) if (pendingState(value.toObject()["state"].toInt())) return true; + return false; +} +void finishDevicePairing(QWidget* parent) { + QStringList labels, ids; + for (const auto value : invitationSnapshot["invitations"].toArray()) { + const auto entry = value.toObject(); if (!pendingState(entry["state"].toInt())) continue; + ids << entry["id"].toString(); labels << entry["peer"].toString() + " / " + entry["id"].toString().left(8); } - for (const auto& id : pinDialogs.keys()) if (!waiting.contains(id)) { - auto dialog = pinDialogs.take(id); if (dialog) { dialog->disconnect(); dialog->close(); } + if (ids.isEmpty() || !gui_bridge_is_uasync_ready()) { DEBUG_WARN(DEBUG_CATEGORY_CHAT, "devgroup UI: no pending pairing"); return; } + QString id = ids.first(); + if (ids.size() > 1) { + bool ok = false; + const QString choice = QInputDialog::getItem(parent, DevgroupDialog::tr("Linking devices"), + DevgroupDialog::tr("Select device"), labels, 0, false, &ok); + if (!ok || !labels.contains(choice)) return; + id = ids.at(labels.indexOf(choice)); } + if (active) active->close(); + active = new DevgroupDialog(parent, {}, 0, id); active->show(); active->refresh(); + DEBUG_INFO(DEBUG_CATEGORY_CHAT, "devgroup UI: pairing opened from self menu invite=%s", qPrintable(id)); } void initDevgroupUi(QWidget* window) { if (invitationWindow) return; - statusRequest = 0; lastAutomaticSnapshot = 0; acceptRequests.clear(); dismissedInvites.clear(); awaitingPins.clear(); attemptedPins.clear(); + statusRequest = 0; lastAutomaticSnapshot = 0; invitationSnapshot = {}; invitationWindow = window; gui_bridge_set_devgroup_result_cb(devgroupResult); auto* timer = new QTimer(window); timer->setInterval(1000); QObject::connect(timer, &QTimer::timeout, window, [] { if (!gui_bridge_is_uasync_ready()) { - statusRequest = 0; lastAutomaticSnapshot = 0; acceptRequests.clear(); awaitingPins.clear(); attemptedPins.clear(); dismissedInvites.clear(); - automaticInvitations(QJsonObject{{"invitations", QJsonArray{}}}); return; + statusRequest = 0; lastAutomaticSnapshot = 0; invitationSnapshot = {}; + if (active) active->update(invitationSnapshot); + return; } refreshInvitations(); }); diff --git a/tools/chatgui/src/devgroupdialog.h b/tools/chatgui/src/devgroupdialog.h index ec04f1853..54644829d 100644 --- a/tools/chatgui/src/devgroupdialog.h +++ b/tools/chatgui/src/devgroupdialog.h @@ -5,8 +5,11 @@ #include class QWidget; class QPushButton; -/* Один обработчик входящих приглашений на главное окно; диалог появляется независимо от текущего экрана. */ +/* Получать снимки приглашений на главное окно без автоматического открытия диалога. */ void initDevgroupUi(QWidget* window); +/* Есть незавершённое приглашение; показать выбор приглашения и текущий шаг после явного действия в меню self. */ +bool hasPendingDevicePairing(); +void finishDevicePairing(QWidget* parent); /* Сразу создать приглашение выбранному устройству и показать PIN. */ void inviteMyDevice(QWidget* parent, const QString& channel, quint64 peer); /* Отключить self; кнопка блокируется до результата, при отказе восстанавливается и показывается ошибка. */