1
0
forked from BRT/arc

Add DesktopSelectionWindow with UI logic for selecting or creating desktops. Update NetworkService to handle desktop selection flow. Improve desktop synchronization and settings management. Enhance mock server to support desktop device data in tests.

This commit is contained in:
slava 2026-05-28 19:12:27 +05:00
parent 942d6137e9
commit b8da23fa6c
9 changed files with 291 additions and 57 deletions

View File

@ -482,7 +482,11 @@ void GetLastTransactionsScript::doStart() {
static const QRegularExpression reDateOrToday(
QString("^(?:\\d{2}\\.\\d{2}\\.\\d{4}|%1|%2)$")
.arg(QString::fromUtf8("Сегодня"), QString::fromUtf8("Вчера")));
static const QRegularExpression reAmountLine(R"(^-?\d+\.\d{2}$)");
// Сумма: либо с десятичной частью ("-5577.95", "109,50"), либо целая
// ("109" — P2P-зачисления показываются без копеек). Целую часть
// ограничиваем 7 цифрами, чтобы не спутать с 1216-значным номером
// карты, если он окажется на отдельной строке без пробелов/дефисов.
static const QRegularExpression reAmountLine(R"(^-?(?:\d+[.,]\d{1,2}|\d{1,7})$)");
QList<VypiskaItem> items;
for (const Node &n : xmlScreenParser.findAllNodes()) {
if (n.className != "android.view.View") continue;
@ -498,12 +502,14 @@ void GetLastTransactionsScript::doStart() {
VypiskaItem it;
bool ok = false;
// Ищем строку вида "-?\d+\.\d{2}" — устойчиво и к 5-, и к 6-строчному
// DCWallet, и к P2P. Карты с пробелами ("9762 0002 ...") и текстовые
// поля не подпадают под паттерн.
// Ищем строку-сумму — устойчиво и к 5-, и к 6-строчному DCWallet,
// и к P2P. Карты с пробелами ("9762 0002 ...") и дефисами, а также
// текстовые поля не подпадают под паттерн.
for (const QString &line : lines) {
if (!reAmountLine.match(line.trimmed()).hasMatch()) continue;
it.amount = line.trimmed().toDouble(&ok);
const QString t = line.trimmed();
if (!reAmountLine.match(t).hasMatch()) continue;
QString v = t; v.replace(',', '.');
it.amount = v.toDouble(&ok);
if (ok) break;
}
if (!ok) continue;

View File

@ -370,69 +370,126 @@ void NetworkService::loginAndSetup() {
qDebug() << "[loginAndSetup] profile info: country=" << country << "currency=" << currency;
}
// Шаг 2: получаем актуальный desktop с сервера
QString desktopId = SettingsDAO::get("desktop_id");
// Всегда проверяем список десктопов — desktop_id мог устареть или быть удалён
// Шаг 2: получаем список десктопов с сервера
const QString savedDesktopId = SettingsDAO::get("desktop_id");
const QJsonValue existingDesktops = getJson(m_apiBase + m_pathDesktopCreate, true);
if (!existingDesktops.isUndefined() && !existingDesktops.isNull() && existingDesktops.isArray()) {
const QJsonArray desktopsArray = existingDesktops.toArray();
QString foundId;
QString foundProfileId;
for (const QJsonValue &dVal : desktopsArray) {
const QJsonObject d = dVal.toObject();
if (d["hidden"].toBool(false)) continue;
foundId = d["id"].toString();
foundProfileId = d["profile_id"].toString();
if (!foundId.isEmpty()) break;
}
if (!foundId.isEmpty() && foundId != desktopId) {
// Нашли актуальный десктоп, отличается от сохранённого — обновляем
qDebug() << "[loginAndSetup] desktop_id updated:" << desktopId << "->" << foundId;
desktopId = foundId;
SettingsDAO::set("desktop_id", desktopId);
SettingsDAO::set("profile_id", foundProfileId);
} else if (!foundId.isEmpty()) {
qDebug() << "[loginAndSetup] desktop_id confirmed:" << desktopId;
}
}
if (desktopId.isEmpty()) {
// Десктопов на сервере нет — создаём новый
QJsonObject desktopBody;
desktopBody["blue_token"] = QJsonValue::Null;
desktopBody["name"] = QJsonValue::Null;
desktopBody["contact"] = QJsonValue::Null;
const QSettings settings("config.ini", QSettings::IniFormat);
desktopBody["app_version"] = settings.value("common/version").toString();
const QJsonValue desktopResult = postJson(m_apiBase + m_pathDesktopCreate, desktopBody, true);
if (desktopResult.isUndefined() || desktopResult.isNull()) {
if (existingDesktops.isUndefined()) {
// Сеть/сервер недоступны
if (savedDesktopId.isEmpty()) {
emit finishedWithResult(-1);
emit finished();
return;
}
const QJsonObject desktopData = desktopResult.toObject();
desktopId = desktopData["id"].toString();
SettingsDAO::set("desktop_id", desktopId);
SettingsDAO::set("profile_id", desktopData["profile_id"].toString());
// Есть сохранённый выбор — продолжаем с ним (best effort)
qDebug() << "[loginAndSetup] desktop list unavailable, using saved" << savedDesktopId;
finishSetupWithDesktop(savedDesktopId);
return;
}
// Шаг 3: синхронизация устройств с сервера
// Собираем не скрытые десктопы
QJsonArray visibleDesktops;
if (existingDesktops.isArray()) {
for (const QJsonValue &dVal : existingDesktops.toArray()) {
const QJsonObject d = dVal.toObject();
if (d["hidden"].toBool(false)) continue;
visibleDesktops.append(d);
}
}
// 1) Сохранённый выбор всё ещё валиден — используем молча (как раньше)
if (!savedDesktopId.isEmpty()) {
for (const QJsonValue &dVal : visibleDesktops) {
const QJsonObject d = dVal.toObject();
if (d["id"].toString() == savedDesktopId) {
SettingsDAO::set("profile_id", d["profile_id"].toString());
SettingsDAO::set("desktop_unique_name", d["unique_name"].toString());
qDebug() << "[loginAndSetup] desktop_id confirmed:" << savedDesktopId;
finishSetupWithDesktop(savedDesktopId);
return;
}
}
}
// 2) Валидного сохранённого выбора нет
if (visibleDesktops.isEmpty()) {
// Десктопов на сервере нет — создаём автоматически и запускаемся
const QString newId = createDesktopOnServer();
if (newId.isEmpty()) {
emit finishedWithResult(-1);
emit finished();
return;
}
finishSetupWithDesktop(newId);
return;
}
// 3) Есть из чего выбирать — спрашиваем пользователя.
// НЕ эмитим finished(): поток ждёт continueWithDesktop() из UI.
m_desktops = visibleDesktops;
qDebug() << "[loginAndSetup] desktop selection required, count:" << visibleDesktops.size();
emit desktopSelectionRequired(visibleDesktops);
}
QString NetworkService::createDesktopOnServer() {
QJsonObject desktopBody;
desktopBody["blue_token"] = QJsonValue::Null;
desktopBody["name"] = QJsonValue::Null;
desktopBody["contact"] = QJsonValue::Null;
const QSettings settings("config.ini", QSettings::IniFormat);
desktopBody["app_version"] = settings.value("common/version").toString();
const QJsonValue desktopResult = postJson(m_apiBase + m_pathDesktopCreate, desktopBody, true);
if (desktopResult.isUndefined() || desktopResult.isNull()) {
qWarning() << "[createDesktopOnServer] failed to create desktop";
return QString();
}
const QJsonObject desktopData = desktopResult.toObject();
const QString id = desktopData["id"].toString();
SettingsDAO::set("desktop_id", id);
SettingsDAO::set("profile_id", desktopData["profile_id"].toString());
SettingsDAO::set("desktop_unique_name", desktopData["unique_name"].toString());
qDebug() << "[createDesktopOnServer] created desktop" << id;
return id;
}
void NetworkService::continueWithDesktop(const QString &desktopId) {
QString resolvedId = desktopId;
if (resolvedId.isEmpty()) {
// Пользователь выбрал «Создать новый»
resolvedId = createDesktopOnServer();
if (resolvedId.isEmpty()) {
emit finishedWithResult(-1);
emit finished();
return;
}
} else {
SettingsDAO::set("desktop_id", resolvedId);
for (const QJsonValue &dVal : m_desktops) {
const QJsonObject d = dVal.toObject();
if (d["id"].toString() == resolvedId) {
SettingsDAO::set("profile_id", d["profile_id"].toString());
SettingsDAO::set("desktop_unique_name", d["unique_name"].toString());
break;
}
}
qDebug() << "[continueWithDesktop] selected desktop" << resolvedId;
}
finishSetupWithDesktop(resolvedId);
}
void NetworkService::finishSetupWithDesktop(const QString &desktopId) {
// Шаг 3: синхронизация устройств с сервера (scoped по desktopId)
syncDevicesFromServer(desktopId);
// Шаг 4: синхронизация банковских профилей и материалов с сервера
syncBankProfilesFromServer();
// Шаг 5: убран (bank_participator_name_ru приходит в задаче)
// Шаг 6: синхронизация статусов профилей/материалов с учётом онлайн-устройств
// Шаг 5: синхронизация статусов профилей/материалов с учётом онлайн-устройств
syncCurrentProfile();
// Шаг 7: отправляем OFFLINE статус всех устройств (DeviceScreener обновит на ONLINE при обнаружении)
// Шаг 6: отправляем OFFLINE статус всех устройств (DeviceScreener обновит на ONLINE при обнаружении)
{
const QList<DeviceInfo> allDevices = DeviceDAO::getAllDevices(false);
for (const DeviceInfo &d : allDevices) {
@ -443,7 +500,7 @@ void NetworkService::loginAndSetup() {
body["battery"] = d.battery;
body["update_time"] = QDateTime::currentSecsSinceEpoch();
patchJson(m_apiBase + m_pathDevice + d.apiId, body, true);
qDebug() << "[loginAndSetup] sent OFFLINE for device" << d.id << d.apiId;
qDebug() << "[finishSetupWithDesktop] sent OFFLINE for device" << d.id << d.apiId;
}
}

View File

@ -71,6 +71,10 @@ public slots:
void updateTransactionData();
void loginAndSetup();
// Продолжение loginAndSetup после выбора десктопа пользователем.
// Пустой desktopId — выбран пункт «Создать новый». Вызывать через
// QueuedConnection: слот исполняется в потоке NetworkService.
void continueWithDesktop(const QString &desktopId);
// true — GET current_profile прошёл; false — таймаут/сеть. Используется
// health-счётчиком в runPeriodicSyncTick (правило #9).
bool syncCurrentProfile();
@ -121,6 +125,10 @@ public slots:
signals:
void finished();
void finishedWithResult(int value);
// Эмитится из loginAndSetup, когда на сервере есть десктопы, но локального
// выбора нет — UI показывает окно выбора. После выбора UI вызывает
// continueWithDesktop(). До этого finished() НЕ эмитится (поток ждёт).
void desktopSelectionRequired(const QJsonArray &desktops);
void tokenRefreshFailed();
void tasksReceived(QJsonArray tasks);
// Эмитится при HTTP-ошибке PATCH-запроса (например, 409 'материал заблокирован').
@ -208,6 +216,17 @@ private:
void syncBankProfilesFromServer();
void syncBanksIfEmpty();
// POST /api/v1/desktop/ — создаёт новый десктоп, сохраняет desktop_id и
// profile_id в настройки. Возвращает id или пустую строку при ошибке.
QString createDesktopOnServer();
// Завершающая часть loginAndSetup: синхронизация устройств/профилей/статусов
// для выбранного desktopId и эмит finishedWithResult(1)+finished().
void finishSetupWithDesktop(const QString &desktopId);
// Не скрытые десктопы с сервера, сохранённые между loginAndSetup и
// continueWithDesktop (для поиска profile_id по выбранному id).
QJsonArray m_desktops;
public:
// Возвращает (лениво создавая) shared mutex для пары (deviceId, bankProfileId).
// Используется через QMutexLocker для сериализации PATCH-ов по одному профилю.

View File

@ -272,11 +272,18 @@ def auth_refresh():
# ---------------------------------------------------------------- desktop
@app.get("/api/v1/desktop/")
def desktop_list():
if os.environ.get("MOCK_EMPTY_DESKTOPS"):
return ok([])
return ok([{
"id": _desktop_id,
"profile_id": _profile_id,
"hidden": False,
"name": "mock-desktop",
"unique_name": "desktop_" + _desktop_id.replace("-", "")[:8],
"devices": [
{"id": str(uuid.uuid4()), "name": "Infinix X6525D-da7b22b87a0da954"},
{"id": str(uuid.uuid4()), "name": "samsung SM-A155F-1a2b3c4d5e6f7a8b"},
],
}])
@ -284,7 +291,11 @@ def desktop_list():
def desktop_create():
body = request.get_json(silent=True) or {}
log("POST /desktop/", body)
return ok({"id": _desktop_id, "profile_id": _profile_id})
return ok({
"id": _desktop_id,
"profile_id": _profile_id,
"unique_name": "desktop_" + _desktop_id.replace("-", "")[:8],
})
# ---------------------------------------------------------------- device

View File

@ -17,6 +17,7 @@
#include "event/EventWindow.h"
#include "log/FileLogWindow.h"
#include "dao/GeneralLogDAO.h"
#include "dao/SettingsDAO.h"
#include "net/NetworkService.h"
#include "db/DeviceInfo.h"
#include "device/DeviceWidget.h"
@ -32,7 +33,10 @@
MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) {
const QSettings settings("config.ini", QSettings::IniFormat);
const QString version = settings.value("common/version").toString();
setWindowTitle(version.isEmpty() ? "ARC" : QString("ARC v%1").arg(version));
QString title = version.isEmpty() ? "ARC" : QString("ARC v%1").arg(version);
const QString desktopName = SettingsDAO::get("desktop_unique_name");
if (!desktopName.isEmpty()) title += QString(" [%1]").arg(desktopName);
setWindowTitle(title);
// Инициализируем таймер обновления
timer = new QTimer(this);

View File

@ -0,0 +1,69 @@
#include "DesktopSelectionWindow.h"
#include <QCloseEvent>
#include <QJsonObject>
#include <QLabel>
#include <QPushButton>
#include <QStringList>
#include <QVBoxLayout>
namespace {
// Имена устройств десктопа (поле devices ответа /api/v1/desktop/).
// Отрезаем суффикс после последнего тире: "Infinix X6525D-da7b22..." -> "Infinix X6525D".
QStringList deviceNamesOf(const QJsonObject &d) {
QStringList names;
for (const QJsonValue &devVal : d["devices"].toArray()) {
const QJsonObject dev = devVal.toObject();
QString name = dev["name"].toString();
if (name.isEmpty()) name = dev["unique_name"].toString();
const int dash = name.lastIndexOf('-');
if (dash > 0) name = name.left(dash);
if (!name.isEmpty()) names << name;
}
return names;
}
} // namespace
DesktopSelectionWindow::DesktopSelectionWindow(const QJsonArray &desktops, QWidget *parent)
: QWidget(parent, Qt::Window) {
setWindowTitle("Выбор десктопа");
setMinimumWidth(360);
auto *layout = new QVBoxLayout(this);
layout->addWidget(new QLabel("Выберите десктоп для синхронизации:"));
for (const QJsonValue &dVal : desktops) {
const QJsonObject d = dVal.toObject();
const QString id = d["id"].toString();
QString title = d["unique_name"].toString();
if (title.isEmpty()) title = id;
const QStringList devices = deviceNamesOf(d);
QString text = title;
if (!devices.isEmpty()) text += "\n" + devices.join("\n");
auto *btn = new QPushButton(text);
btn->setStyleSheet("text-align: left; padding: 8px;");
connect(btn, &QPushButton::clicked, this, [this, id]() {
m_chosen = true;
emit desktopChosen(id);
close();
});
layout->addWidget(btn);
}
auto *createBtn = new QPushButton("Создать новый");
createBtn->setStyleSheet("padding: 8px;");
connect(createBtn, &QPushButton::clicked, this, [this]() {
m_chosen = true;
emit desktopChosen(QString());
close();
});
layout->addWidget(createBtn);
}
void DesktopSelectionWindow::closeEvent(QCloseEvent *event) {
// Закрытие окна без выбора (крестик) трактуем как отмену.
if (!m_chosen) emit cancelled();
QWidget::closeEvent(event);
}

View File

@ -0,0 +1,27 @@
#pragma once
#include <QWidget>
#include <QJsonArray>
class QCloseEvent;
// Окно выбора десктопа при старте. Показывается, когда на сервере есть десктопы,
// но локального выбора нет (см. NetworkService::desktopSelectionRequired).
// Десктопы выводятся кнопками: название и под ним список устройств.
class DesktopSelectionWindow final : public QWidget {
Q_OBJECT
public:
explicit DesktopSelectionWindow(const QJsonArray &desktops, QWidget *parent = nullptr);
signals:
// Пустой desktopId — выбрана кнопка «Создать новый».
void desktopChosen(const QString &desktopId);
void cancelled();
protected:
void closeEvent(QCloseEvent *event) override;
private:
bool m_chosen = false;
};

View File

@ -2,7 +2,9 @@
#include <QVBoxLayout>
#include <QLabel>
#include <QThread>
#include <QApplication>
#include "DesktopSelectionWindow.h"
#include "net/NetworkService.h"
LoaderWindow::LoaderWindow(QWidget *parent) : QWidget(parent) {
@ -42,6 +44,24 @@ LoaderWindow::LoaderWindow(QWidget *parent) : QWidget(parent) {
emit loadingFinished(m_checkResult);
}
}, Qt::QueuedConnection);
// На сервере есть десктопы, но локального выбора нет — показываем окно выбора.
// loginAndSetup продолжится в continueWithDesktop() после выбора пользователя.
connect(networkService, &NetworkService::desktopSelectionRequired, this,
[this, networkService](const QJsonArray &desktops) {
auto *dialog = new DesktopSelectionWindow(desktops);
dialog->setAttribute(Qt::WA_DeleteOnClose);
// Контекст this (UI-поток): слот не должен исполняться в воркер-потоке
// networkService — любые UI-операции вне main thread на macOS падают.
connect(dialog, &DesktopSelectionWindow::desktopChosen, this,
[networkService](const QString &id) {
QMetaObject::invokeMethod(networkService, "continueWithDesktop",
Qt::QueuedConnection, Q_ARG(QString, id));
});
connect(dialog, &DesktopSelectionWindow::cancelled, qApp, &QApplication::quit);
dialog->show();
}, Qt::QueuedConnection);
thread->start();
}

View File

@ -9,6 +9,7 @@
#include <QThread>
#include <QSettings>
#include "DesktopSelectionWindow.h"
#include "LoaderDialog.h"
#include "dao/SettingsDAO.h"
#include "net/NetworkService.h"
@ -73,6 +74,26 @@ RegistrationWindow::RegistrationWindow(QWidget *parent): QWidget(parent) {
}
}, Qt::QueuedConnection);
});
// На сервере есть десктопы, но локального выбора нет — показываем окно выбора.
// loginAndSetup продолжится в continueWithDesktop() после выбора пользователя.
connect(networkService, &NetworkService::desktopSelectionRequired, this,
[this, networkService, loader](const QJsonArray &desktops) {
loader->close();
auto *dialog = new DesktopSelectionWindow(desktops, this);
dialog->setAttribute(Qt::WA_DeleteOnClose);
// Контекст this (UI-поток): loader->show() обязан выполняться
// в главном потоке, иначе создание NSWindow вне main thread → краш.
connect(dialog, &DesktopSelectionWindow::desktopChosen, this,
[networkService, loader](const QString &id) {
loader->show();
QMetaObject::invokeMethod(networkService, "continueWithDesktop",
Qt::QueuedConnection, Q_ARG(QString, id));
});
connect(dialog, &DesktopSelectionWindow::cancelled, qApp, &QApplication::quit);
dialog->show();
}, Qt::QueuedConnection);
thread->start();
});
}