Open device pairing explicitly from self context menu and show both PIN steps

This commit is contained in:
evgeny committed 2026-11-03 01:31:58 +01:00
1 parent bc2ac140a8
commit 10bafe3ca3
3 files changed
+100 -74

No files matched your search

+9 -2
View File
@@ -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; }
+87 -71
View File
@@ -29,21 +29,30 @@ static QHash<quint64, QPointer<QWidget>> renameRequests;
static quint64 nextRequest = 0;
static QPointer<QWidget> invitationWindow;
static quint64 statusRequest = 0, lastAutomaticSnapshot = 0;
static QHash<quint64, QString> acceptRequests;
static QHash<QString, QPointer<QInputDialog>> pinDialogs;
static QSet<QString> 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<const char*>(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<quint64, int> 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<const char*>(data+9), len-9)).object());
invitationSnapshot = QJsonDocument::fromJson(QByteArray(reinterpret_cast<const char*>(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<QString> 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<QLabel*>()) { label->setTextFormat(Qt::PlainText); label->setWordWrap(true); }
dialog->resize(480, 260);
dialog->setInputMode(QInputDialog::TextInput); dialog->setWindowModality(Qt::ApplicationModal);
if (auto* edit = dialog->findChild<QLineEdit*>()) {
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<QDialogButtonBox*>()) 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<chat_devgroup_request*>(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<QDialogButtonBox*>()) 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();
});
+4 -1
View File
@@ -5,8 +5,11 @@
#include <QString>
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; кнопка блокируется до результата, при отказе восстанавливается и показывается ошибка. */