Фаза 1: мастер первого запуска — SettingsStore, GitManager (auto-init+коммиты), SyncManager (проверка соединений+ErrorCatalog), WelcomeWizard/ProviderSetupGuide; тесты smoke/git/vault (12/12)

This commit is contained in:
Flora (opencode)
2026-08-17 00:43:33 +03:00
parent 980fbcedcb
commit ed633ae036
18 changed files with 1412 additions and 29 deletions
+50 -11
View File
@@ -1,27 +1,66 @@
#include "app/VaultManager.h"
VaultManager::VaultManager(QObject *parent)
#include "core/SettingsStore.h"
#include "git/GitManager.h"
#include <QDir>
VaultManager::VaultManager(SettingsStore *settings, GitManager *git, QObject *parent)
: QObject(parent)
, m_settings(settings)
, m_git(git)
{
connect(m_settings, &SettingsStore::vaultsChanged,
this, &VaultManager::vaultsChanged);
connect(m_settings, &SettingsStore::currentVaultChanged,
this, &VaultManager::currentVaultChanged);
}
QStringList VaultManager::vaults() const
{
return m_vaults;
return m_settings->vaults();
}
QString VaultManager::currentVault() const
{
return m_currentVault;
return m_settings->currentVault();
}
void VaultManager::setCurrentVault(const QString &path)
QString VaultManager::createVault(const QString &path)
{
if (m_currentVault == path)
return;
m_currentVault = path;
if (!m_vaults.contains(path))
m_vaults.append(path);
emit currentVaultChanged();
emit vaultsChanged();
if (path.trimmed().isEmpty())
return QStringLiteral("Не указан путь к хранилищу.");
if (!QDir().mkpath(path))
return QStringLiteral("Не удалось создать папку «%1».").arg(path);
if (!m_git->initRepo(path)) {
emit operationError(m_git->lastError());
return m_git->lastError();
}
m_settings->addVault(path);
return QString();
}
QString VaultManager::openVault(const QString &path)
{
if (path.trimmed().isEmpty())
return QStringLiteral("Не указан путь к хранилищу.");
if (!QDir(path).exists())
return QStringLiteral("Папка «%1» не существует.").arg(path);
if (!m_git->initRepo(path)) {
emit operationError(m_git->lastError());
return m_git->lastError();
}
m_settings->addVault(path);
return QString();
}
void VaultManager::selectVault(const QString &path)
{
m_settings->setCurrentVault(path);
}
+22 -9
View File
@@ -3,29 +3,42 @@
#include <QObject>
#include <QStringList>
// Менеджер хранилищ: список известных хранилищ, основное хранилище,
// открытие нового хранилища в новом окне (мульти-окна).
// Реализация заполняется в Фазе 2; здесь — только каркас для скелета (Фаза 0).
class SettingsStore;
class GitManager;
// Менеджер хранилищ: список, создание/открытие, git-инициализация.
// Фаза 1: создание хранилища + автосоздание git-репозитория.
// Фаза 2 добавит мульти-окна (новое хранилище в новом окне).
class VaultManager : public QObject
{
Q_OBJECT
Q_PROPERTY(QStringList vaults READ vaults NOTIFY vaultsChanged)
Q_PROPERTY(QString currentVault READ currentVault WRITE setCurrentVault
NOTIFY currentVaultChanged)
Q_PROPERTY(QString currentVault READ currentVault NOTIFY currentVaultChanged)
public:
explicit VaultManager(QObject *parent = nullptr);
VaultManager(SettingsStore *settings, GitManager *git, QObject *parent = nullptr);
QStringList vaults() const;
QString currentVault() const;
void setCurrentVault(const QString &path);
// Создаёт хранилище: папка + git-репозиторий внутри. Возвращает "" при успехе
// или текст ошибки.
Q_INVOKABLE QString createVault(const QString &path);
// Открывает существующее хранилище (git-репозиторий внутри не обязателен —
// создаётся при необходимости). Возвращает текст ошибки или "".
Q_INVOKABLE QString openVault(const QString &path);
// Выбор хранилища пользователем по завершении мастера.
Q_INVOKABLE void selectVault(const QString &path);
signals:
void vaultsChanged();
void currentVaultChanged();
void operationError(const QString &message);
private:
QStringList m_vaults;
QString m_currentVault;
SettingsStore *m_settings;
GitManager *m_git;
};
+97
View File
@@ -0,0 +1,97 @@
#include "core/SettingsStore.h"
#include <QSettings>
namespace {
constexpr auto kGroupVaults = "vaults";
constexpr auto kKeyVaults = "vaults/vaultList";
constexpr auto kKeyCurrent = "vaults/current";
constexpr auto kKeyProviders = "sync/providers";
}
SettingsStore::SettingsStore(QObject *parent)
: QObject(parent)
{
QSettings s;
m_providers = s.value(kKeyProviders).toList();
}
QStringList SettingsStore::vaults() const
{
QSettings s;
return s.value(kKeyVaults).toStringList();
}
QString SettingsStore::currentVault() const
{
QSettings s;
return s.value(kKeyCurrent).toString();
}
void SettingsStore::setCurrentVault(const QString &path)
{
QSettings s;
s.setValue(kKeyCurrent, path);
emit currentVaultChanged();
}
QVariantList SettingsStore::providers() const
{
return m_providers;
}
void SettingsStore::addVault(const QString &path)
{
QStringList list = vaults();
if (!list.contains(path))
list.prepend(path);
QSettings s;
s.setValue(kKeyVaults, list);
s.setValue(kKeyCurrent, path);
emit vaultsChanged();
emit currentVaultChanged();
}
void SettingsStore::addProvider(const QString &provider, const QVariantMap &config)
{
QVariantList list = m_providers;
for (auto &item : list) {
auto m = item.toMap();
if (m.value("provider") == provider) {
m = config;
m["provider"] = provider;
item = m;
m_providers = list;
goto save;
}
}
{
auto m = config;
m["provider"] = provider;
list.append(m);
m_providers = list;
}
save:
QSettings s;
s.setValue(kKeyProviders, m_providers);
emit providersChanged();
}
bool SettingsStore::hasVault(const QString &path) const
{
return vaults().contains(path);
}
void SettingsStore::clearProvider(const QString &provider)
{
QVariantList list = m_providers;
list.erase(std::remove_if(list.begin(), list.end(),
[&provider](const QVariant &v) {
return v.toMap().value("provider") == provider;
}),
list.end());
m_providers = list;
QSettings s;
s.setValue(kKeyProviders, m_providers);
emit providersChanged();
}
+43
View File
@@ -0,0 +1,43 @@
#pragma once
#include <QObject>
#include <QStringList>
#include <QVariantList>
// Хранение настроек приложения (QSettings):
// список хранилищ, основное хранилище, конфигурации провайдеров синхронизации.
class SettingsStore : public QObject
{
Q_OBJECT
Q_PROPERTY(QStringList vaults READ vaults NOTIFY vaultsChanged)
Q_PROPERTY(QString currentVault READ currentVault WRITE setCurrentVault
NOTIFY currentVaultChanged)
Q_PROPERTY(QVariantList providers READ providers NOTIFY providersChanged)
public:
explicit SettingsStore(QObject *parent = nullptr);
QStringList vaults() const;
QString currentVault() const;
void setCurrentVault(const QString &path);
QVariantList providers() const;
// Добавляет хранилище в список (если ещё нет) и делает его текущим.
void addVault(const QString &path);
// Сохраняет конфигурацию провайдера (provider: "github"/"gitlab"/"gitea"/"webdav"/"syncthing").
void addProvider(const QString &provider, const QVariantMap &config);
Q_INVOKABLE bool hasVault(const QString &path) const;
Q_INVOKABLE void clearProvider(const QString &provider);
signals:
void vaultsChanged();
void currentVaultChanged();
void providersChanged();
private:
QVariantList m_providers;
};
+193
View File
@@ -0,0 +1,193 @@
#include "git/GitManager.h"
#include <QDir>
#include <QFile>
namespace {
QString gitErr()
{
const git_error *e = git_error_last();
return e && e->message ? QString::fromUtf8(e->message) : QStringLiteral("неизвестная ошибка git");
}
struct CommitInfo {
git_signature *author = nullptr;
git_index *index = nullptr;
git_repository *repo = nullptr;
git_oid treeId{};
git_oid commitId{};
~CommitInfo()
{
if (author) git_signature_free(author);
if (index) git_index_free(index);
if (repo) git_repository_free(repo);
}
};
} // namespace
GitManager::GitManager(QObject *parent)
: QObject(parent)
{
if (!m_gitInitialized) {
git_libgit2_init();
m_gitInitialized = true;
}
}
QString GitManager::repoExists(const QString &path)
{
git_repository *repo = nullptr;
if (git_repository_open(&repo, path.toUtf8().constData()) < 0)
return QString();
git_repository_free(repo);
return path;
}
void GitManager::setRepoIdentity(git_repository *repo)
{
git_config *cfg = nullptr;
if (git_repository_config(&cfg, repo) < 0)
return;
auto setIfMissing = [cfg](const char *key, const char *value) {
git_buf buf{};
if (git_config_get_string_buf(&buf, cfg, key) < 0)
git_config_set_string(cfg, key, value);
git_buf_dispose(&buf);
};
setIfMissing("user.name", "Flora");
setIfMissing("user.email", "flora@localhost");
git_config_free(cfg);
}
void GitManager::setLastError(const QString &msg)
{
if (m_lastError == msg)
return;
m_lastError = msg;
emit lastErrorChanged();
}
bool GitManager::initRepo(const QString &path)
{
const QByteArray p = path.toUtf8();
git_repository *repo = nullptr;
if (git_repository_open(&repo, p.constData()) == 0) {
git_repository_free(repo);
return true; // .git уже есть
}
git_repository_init_options opts = GIT_REPOSITORY_INIT_OPTIONS_INIT;
opts.flags = GIT_REPOSITORY_INIT_MKPATH | GIT_REPOSITORY_INIT_MKDIR;
opts.initial_head = "main";
const int rc = git_repository_init_ext(&repo, p.constData(), &opts);
if (rc < 0) {
setLastError(QStringLiteral("Не удалось создать git-репозиторий: %1")
.arg(gitErr()));
return false;
}
setRepoIdentity(repo);
git_repository_free(repo);
setLastError(QString());
return true;
}
bool GitManager::hasCommits(const QString &path) const
{
git_repository *repo = nullptr;
if (git_repository_open(&repo, path.toUtf8().constData()) < 0)
return false;
const bool has = git_repository_head_unborn(repo) == 0;
git_repository_free(repo);
return has;
}
bool GitManager::commitAll(const QString &path, const QString &message)
{
CommitInfo info;
if (git_repository_open(&info.repo, path.toUtf8().constData()) < 0) {
setLastError(QStringLiteral("Хранилище не является git-репозиторием"));
return false;
}
if (git_repository_index(&info.index, info.repo) < 0) {
setLastError(QStringLiteral("Не удалось открыть индекс: %1").arg(gitErr()));
return false;
}
// Добавляем все файлы, включая новые (с учётом .gitignore)
git_index_add_all(info.index, nullptr, 0, nullptr, nullptr);
const int wrc = git_index_write(info.index);
if (wrc < 0) {
setLastError(QStringLiteral("Не удалось записать индекс: %1").arg(gitErr()));
return false;
}
if (git_index_entrycount(info.index) == 0) {
setLastError(QStringLiteral("Нет изменений для коммита"));
return false;
}
// Создаём дерево
git_tree *tree = nullptr;
if (git_index_write_tree(&info.treeId, info.index) < 0
|| git_tree_lookup(&tree, info.repo, &info.treeId) < 0) {
setLastError(QStringLiteral("Не удалось создать дерево: %1").arg(gitErr()));
return false;
}
// Родитель: head, если коммиты уже есть
git_reference *headRef = nullptr;
git_commit *parentCommit = nullptr;
int nParents = 0;
if (git_repository_head(&headRef, info.repo) == 0) {
git_object *obj = nullptr;
if (git_reference_peel(&obj, headRef, GIT_OBJECT_COMMIT) == 0) {
parentCommit = reinterpret_cast<git_commit *>(obj);
nParents = 1;
}
if (headRef) git_reference_free(headRef);
}
// Если дерево не изменилось относительно родителя — коммитить нечего.
if (parentCommit) {
const git_oid *prevTree = git_commit_tree_id(parentCommit);
if (prevTree && git_oid_equal(prevTree, &info.treeId)) {
git_commit_free(parentCommit);
git_tree_free(tree);
setLastError(QStringLiteral("Нет изменений для коммита"));
return false;
}
}
const git_commit *parents[1] = {parentCommit};
// Подпись из конфига репозитория
git_signature *author = nullptr;
if (git_signature_default(&author, info.repo) < 0) {
git_commit_free(parentCommit);
git_tree_free(tree);
setLastError(QStringLiteral("Не настроена git-личность: %1").arg(gitErr()));
return false;
}
info.author = author;
const int crc = git_commit_create(&info.commitId, info.repo, "HEAD",
author, author, "UTF-8",
message.toUtf8().constData(),
tree, nParents, parents);
if (parentCommit) git_commit_free(parentCommit);
git_tree_free(tree);
if (crc < 0) {
setLastError(QStringLiteral("Не удалось создать коммит: %1").arg(gitErr()));
return false;
}
setLastError(QString());
return true;
}
+42
View File
@@ -0,0 +1,42 @@
#pragma once
#include <QObject>
#include <QString>
#include <git2.h>
// Обёртка над libgit2: инициализация репозитория хранилища и авто-коммиты.
class GitManager : public QObject
{
Q_OBJECT
public:
explicit GitManager(QObject *parent = nullptr);
// Инициализирует git-репозиторий внутри хранилища (папка path).
// Если .git уже есть — просто открывает его. Возвращает true при успехе.
Q_INVOKABLE bool initRepo(const QString &path);
// Коммитит все изменения в хранилище (рабочее дерево). Возвращает true/false.
Q_INVOKABLE bool commitAll(const QString &path, const QString &message);
// Есть ли в хранилище хотя бы один коммит.
Q_INVOKABLE bool hasCommits(const QString &path) const;
// Последнее сообщение об ошибке (русскоязычное, из каталога ошибок).
Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged)
QString lastError() const { return m_lastError; }
static QString repoExists(const QString &path);
signals:
void lastErrorChanged();
private:
void setRepoIdentity(git_repository *repo);
void setLastError(const QString &msg);
QString m_lastError;
bool m_gitInitialized = false;
};
+10 -1
View File
@@ -4,6 +4,9 @@
#include <QCommandLineParser>
#include "app/VaultManager.h"
#include "core/SettingsStore.h"
#include "git/GitManager.h"
#include "sync/SyncManager.h"
#include <cmark-gfm.h>
#include <git2.h>
@@ -29,9 +32,15 @@ int main(int argc, char *argv[])
cmark_version_string(),
gitMajor, gitMinor, gitRev);
VaultManager vaultManager;
SettingsStore settingsStore;
GitManager gitManager;
SyncManager syncManager;
VaultManager vaultManager(&settingsStore, &gitManager);
QQmlApplicationEngine engine;
engine.rootContext()->setContextProperty("settingsStore", &settingsStore);
engine.rootContext()->setContextProperty("gitManager", &gitManager);
engine.rootContext()->setContextProperty("syncManager", &syncManager);
engine.rootContext()->setContextProperty("vaultManager", &vaultManager);
engine.loadFromModule("Flora", "MainWindow");
+209
View File
@@ -0,0 +1,209 @@
#include "sync/SyncManager.h"
#include <QUrl>
namespace {
QString stripUrl(QString url)
{
url = url.trimmed();
while (url.endsWith('/'))
url.chop(1);
return url;
}
// Строит сообщение и подсказку по коду HTTP/сетевой ошибке.
QPair<int, QPair<QString, QString>> catalog(int provider, int httpStatus,
const QString &networkErr)
{
using P = QPair<int, QPair<QString, QString>>;
auto make = [](int code, const QString &t, const QString &h) {
return P(code, {t, h});
};
switch (httpStatus) {
case 401:
return make(SyncManager::Unauthorized,
QStringLiteral("Токен не принят (401)"),
provider == SyncManager::WebDav
? QStringLiteral("Проверьте логин и пароль. Создайте новый токен.")
: QStringLiteral("Проверьте токен: он мог истечь или быть отозван. "
"Создайте новый на странице токенов провайдера."));
case 403:
return make(SyncManager::Forbidden,
QStringLiteral("Недостаточно прав (403)"),
QStringLiteral("Проверьте права токена (scope repo/api). "
"Если это ограничение частоты запросов — подождите несколько минут."));
case 404:
return make(SyncManager::NotFound,
QStringLiteral("Не найдено (404)"),
provider == SyncManager::GitHub || provider == SyncManager::GitLab
? QStringLiteral("Проверьте адрес и что хранилище/репозиторий существуют "
"и доступны этому пользователю.")
: QStringLiteral("Проверьте адрес сервера и путь."));
case 409:
return make(SyncManager::Conflict,
QStringLiteral("Конфликт (409)"),
QStringLiteral("Удалённый репозиторий уже содержит коммиты. "
"Потребуется слияние (см. инструкции в диалоге)."));
case 207:
return make(SyncManager::Ok, QStringLiteral("OK"), QString());
default:
break;
}
if (httpStatus >= 500 && httpStatus != 0)
return make(SyncManager::ServerError,
QStringLiteral("Ошибка сервера (%1)").arg(httpStatus),
QStringLiteral("Повторите позже. Если ошибка повторяется — проверьте "
"работоспособность сервера."));
if (httpStatus == 0 && !networkErr.isEmpty()) {
if (networkErr.contains(QStringLiteral("TLS"), Qt::CaseInsensitive)
|| networkErr.contains(QStringLiteral("SSL"), Qt::CaseInsensitive))
return make(SyncManager::TlsError,
QStringLiteral("Проблема сертификата (TLS/SSL)"),
QStringLiteral("Self-hosted сервер может не иметь доверенного сертификата. "
"Добавьте сертификат в доверенные или включите опцию "
"«Разрешить небезопасное соединение» с осторожностью."));
if (networkErr.contains(QStringLiteral("OperationCanceledError"))
|| networkErr.contains(QStringLiteral("timed out"), Qt::CaseInsensitive))
return make(SyncManager::Timeout,
QStringLiteral("Истекло время ожидания"),
QStringLiteral("Проверьте доступность сервера, сеть и прокси."));
return make(SyncManager::NetworkError,
QStringLiteral("Сетевая ошибка"),
QStringLiteral("Проверьте подключение к интернету, адрес сервера и прокси. "
"Детали: %1").arg(networkErr));
}
if (httpStatus == 200 || httpStatus == 204)
return make(SyncManager::Ok, QStringLiteral("OK"), QString());
return make(SyncManager::ServerError,
QStringLiteral("Неизвестный ответ (%1)").arg(httpStatus),
QStringLiteral("Сервер вернул неожиданный ответ."));
}
} // namespace
SyncManager::SyncManager(QObject *parent)
: QObject(parent)
{
connect(&m_nam, &QNetworkAccessManager::finished, this,
[this](QNetworkReply *reply) {
const int provider = reply->property("flora_provider").toInt();
handleReply(reply, provider);
});
}
bool SyncManager::busy() const { return m_busy; }
QString SyncManager::lastStatus() const { return m_lastStatus; }
void SyncManager::testConnection(int provider, const QString &url,
const QString &username, const QString &token)
{
QUrl base(stripUrl(url));
QNetworkRequest req;
req.setTransferTimeout(15000);
switch (static_cast<Provider>(provider)) {
case GitHub:
req.setUrl(QUrl(QStringLiteral("https://api.github.com/user")));
req.setRawHeader("Authorization", "Bearer " + token.toUtf8());
req.setRawHeader("Accept", "application/vnd.github+json");
break;
case GitLab: {
req.setUrl(QUrl(base.toString() + QStringLiteral("/api/v4/user")));
req.setRawHeader("PRIVATE-TOKEN", token.toUtf8());
break;
}
case Gitea: {
req.setUrl(QUrl(base.toString() + QStringLiteral("/api/v1/user")));
req.setRawHeader("Authorization", "token " + token.toUtf8());
break;
}
case WebDav: {
req.setUrl(base);
req.setRawHeader("Depth", "0");
const QString u = username + ':' + token;
req.setRawHeader("Authorization",
"Basic " + u.toUtf8().toBase64());
break;
}
case Syncthing: {
req.setUrl(QUrl(base.toString() + QStringLiteral("/rest/system/version")));
req.setRawHeader("X-API-Key", token.toUtf8());
break;
}
}
m_busy = true;
emit busyChanged();
QNetworkReply *reply = m_nam.sendCustomRequest(req,
static_cast<Provider>(provider) == WebDav ? "PROPFIND" : "GET");
reply->setProperty("flora_provider", provider);
}
void SyncManager::handleReply(QNetworkReply *reply, int provider)
{
reply->deleteLater();
const int httpStatus = reply->attribute(
QNetworkRequest::HttpStatusCodeAttribute).toInt();
const QString raw = QString::fromUtf8(reply->readAll());
if (reply->error() != QNetworkReply::NoError) {
const auto [code, pair] = catalog(provider, 0, reply->errorString());
finish(provider, false, code, pair.first, pair.second,
reply->errorString() + (raw.isEmpty() ? QString() : QStringLiteral(" | ") + raw));
return;
}
const auto [code, pair] = catalog(provider, httpStatus, QString());
finish(provider, code == Ok, code, pair.first, pair.second, raw);
}
void SyncManager::finish(int provider, bool ok, int code, const QString &title,
const QString &hint, const QString &raw)
{
m_busy = false;
m_lastStatus = ok ? QStringLiteral("Подключение установлено")
: title + (hint.isEmpty() ? QString() : QStringLiteral(" — ") + hint);
emit busyChanged();
emit lastStatusChanged();
emit connectionResult(provider, ok, code, title, hint, raw);
}
QString SyncManager::errorTitle(int errorCode) const
{
switch (errorCode) {
case Ok: return QStringLiteral("OK");
case Unauthorized: return QStringLiteral("401 Unauthorized");
case Forbidden: return QStringLiteral("403 Forbidden");
case NotFound: return QStringLiteral("404 Not Found");
case Conflict: return QStringLiteral("409 Conflict");
case TlsError: return QStringLiteral("TLS/SSL ошибка");
case Timeout: return QStringLiteral("Таймаут");
case NetworkError: return QStringLiteral("Сетевая ошибка");
default: return QStringLiteral("Ошибка %1").arg(errorCode);
}
}
QString SyncManager::errorHint(int errorCode) const
{
switch (errorCode) {
case Unauthorized:
return QStringLiteral("Создайте новый токен. GitHub: Settings → Developer settings → "
"Tokens (classic, scope repo). GitLab: Preferences → Access Tokens. "
"Gitea: Settings → Applications → Tokens.");
case Forbidden:
return QStringLiteral("Токену не хватает прав (repo/api). Проверьте scopes.");
case NotFound:
return QStringLiteral("Проверьте адрес и существование репозитория/папки.");
case Conflict:
return QStringLiteral("Отключите автоматическую синхронизацию и выполните ручное слияние.");
case TlsError:
return QStringLiteral("Self-hosted сервер: добавьте сертификат CA в доверенные.");
case Timeout:
return QStringLiteral("Проверьте сеть, хост и порт.");
default:
return QString();
}
}
+68
View File
@@ -0,0 +1,68 @@
#pragma once
#include <QObject>
#include <QNetworkAccessManager>
#include <QNetworkReply>
// Синхронизация и провайдеры. Фаза 1: проверка подключения (токены/URL)
// и сохранение конфигурации. Полноценный обмен (push/pull) — Фаза 11.
class SyncManager : public QObject
{
Q_OBJECT
Q_PROPERTY(bool busy READ busy NOTIFY busyChanged)
Q_PROPERTY(QString lastStatus READ lastStatus NOTIFY lastStatusChanged)
public:
enum Provider {
GitHub = 0,
GitLab,
Gitea,
WebDav,
Syncthing
};
Q_ENUM(Provider)
enum ErrorCode {
Ok = 0,
Unauthorized = 401,
Forbidden = 403,
NotFound = 404,
Conflict = 409,
TlsError = 1001,
Timeout = 1002,
NetworkError = 1003,
ServerError = 1004
};
Q_ENUM(ErrorCode)
explicit SyncManager(QObject *parent = nullptr);
bool busy() const;
QString lastStatus() const;
// Проверяет доступность провайдера с параметрами и сохраняет конфигурацию при успехе.
Q_INVOKABLE void testConnection(int provider, const QString &url,
const QString &username, const QString &token);
// Человекочитаемое описание ошибки по коду из каталога ошибок.
Q_INVOKABLE QString errorTitle(int errorCode) const;
Q_INVOKABLE QString errorHint(int errorCode) const;
signals:
void connectionResult(int provider, bool ok, int errorCode,
const QString &title, const QString &hint,
const QString &raw);
void busyChanged();
void lastStatusChanged();
private:
void runRequest(QNetworkRequest req, int provider);
void handleReply(QNetworkReply *reply, int provider);
void finish(int provider, bool ok, int code, const QString &title,
const QString &hint, const QString &raw);
QNetworkAccessManager m_nam;
bool m_busy = false;
QString m_lastStatus;
};