commit a52c11015939bbe613095dc0c5beaf52cb318e6c Author: Matteo Benedetto Date: Sun Aug 30 18:12:51 2026 +0200 Initial Qt AppImage release catalog client diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..af83a81 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +build/ +.cache/ +.DS_Store diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..f814552 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,32 @@ +cmake_minimum_required(VERSION 3.21) +project(appimage-release-client VERSION 0.1.0 LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_AUTOMOC ON) +set(CMAKE_AUTOUIC ON) +set(CMAKE_AUTORCC ON) + +find_package(Qt6 6.4 REQUIRED COMPONENTS Widgets Network) + +qt_add_executable(appimage-release-client + src/main.cpp + src/catalogclient.cpp + src/catalogclient.h + src/mainwindow.cpp + src/mainwindow.h +) + +target_link_libraries(appimage-release-client PRIVATE + Qt6::Widgets + Qt6::Network +) + +target_compile_definitions(appimage-release-client PRIVATE + APP_VERSION="${PROJECT_VERSION}" +) + +include(GNUInstallDirs) +install(TARGETS appimage-release-client + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..b2087dc --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ +# AppImage Release Catalog + +Client desktop Qt 6/C++ per visualizzare il catalogo JSON delle release AppImage pubblicato su Brain. + +## Funzioni + +- legge `/api/index.json` e `/api/apps/.json`; +- mostra applicazioni, descrizione, release, architettura, dimensione e SHA-256; +- carica l'icona dal catalogo; +- apre gli URL dell'AppImage stabile, del manifest zsync e del progetto; +- mantiene la GUI responsiva durante le richieste di rete; +- URL del catalogo modificabile dalla barra superiore. + +Il client è **sola lettura**: non pubblica file e non modifica il repository remoto. + +## Requisiti + +- C++17; +- CMake 3.21 o superiore; +- Qt 6.4 o superiore, moduli `Widgets` e `Network`. + +Su Debian/Ubuntu, i pacchetti di sviluppo normalmente necessari sono `cmake`, `g++`, `qt6-base-dev` e `qt6-base-dev-tools`. + +## Build + +```sh +cmake -S . -B build -DCMAKE_BUILD_TYPE=Release +cmake --build build --parallel +./build/appimage-release-client +``` + +URL predefinito: `http://10.8.0.3:8092`. + +Per la rete LAN è possibile usare `http://10.0.0.181:8092`. diff --git a/appimage-release-client.desktop b/appimage-release-client.desktop new file mode 100644 index 0000000..9975e7d --- /dev/null +++ b/appimage-release-client.desktop @@ -0,0 +1,11 @@ +[Desktop Entry] +Type=Application +Name=AppImage Release Catalog +Comment=Visualizza le release AppImage dal catalogo Brain +Exec=/opt/appimage-release-client/bin/appimage-release-client +TryExec=/opt/appimage-release-client/bin/appimage-release-client +Icon=appimage-release-client +Terminal=false +Categories=Utility; +StartupNotify=true +StartupWMClass=appimage-release-client diff --git a/resources/appimage-release-client.png b/resources/appimage-release-client.png new file mode 100644 index 0000000..9086625 Binary files /dev/null and b/resources/appimage-release-client.png differ diff --git a/src/catalogclient.cpp b/src/catalogclient.cpp new file mode 100644 index 0000000..0c5b276 --- /dev/null +++ b/src/catalogclient.cpp @@ -0,0 +1,125 @@ +#include "catalogclient.h" + +#include +#include +#include +#include + +#include + +namespace { +QUrl appendPath(const QUrl &base, const QString &path) +{ + QString value = base.toString(); + while (value.endsWith('/')) + value.chop(1); + return QUrl(value + path); +} +} + +CatalogClient::CatalogClient(QObject *parent) + : QObject(parent), m_baseUrl(QStringLiteral("http://10.8.0.3:8092")) +{ +} + +void CatalogClient::setBaseUrl(const QUrl &baseUrl) +{ + m_baseUrl = baseUrl; +} + +QUrl CatalogClient::baseUrl() const +{ + return m_baseUrl; +} + +void CatalogClient::setBusy(bool busy) +{ + emit busyChanged(busy); +} + +void CatalogClient::getJson(const QUrl &url, const std::function &callback) +{ + if (!url.isValid() || url.scheme().isEmpty() || url.host().isEmpty()) { + emit requestFailed(QStringLiteral("URL non valida: %1").arg(url.toString())); + return; + } + + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::UserAgentHeader, + QStringLiteral("AppImage Release Client/%1").arg(APP_VERSION)); + auto *reply = m_network.get(request); + ++m_pendingRequests; + setBusy(true); + + connect(reply, &QNetworkReply::finished, this, [this, reply, callback]() { + if (reply->error() != QNetworkReply::NoError) { + emit requestFailed(QStringLiteral("Richiesta fallita: %1").arg(reply->errorString())); + reply->deleteLater(); + --m_pendingRequests; + if (m_pendingRequests == 0) + setBusy(false); + return; + } + + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(reply->readAll(), &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) { + emit requestFailed(QStringLiteral("Risposta JSON non valida: %1").arg(parseError.errorString())); + reply->deleteLater(); + --m_pendingRequests; + if (m_pendingRequests == 0) + setBusy(false); + return; + } + + callback(document.object()); + reply->deleteLater(); + --m_pendingRequests; + if (m_pendingRequests == 0) + setBusy(false); + }); +} + +void CatalogClient::fetchIndex() +{ + getJson(appendPath(m_baseUrl, QStringLiteral("/api/index.json")), + [this](const QJsonObject &root) { + QVariantMap repository = root.value(QStringLiteral("repository")).toObject().toVariantMap(); + QVector applications; + const QJsonArray array = root.value(QStringLiteral("applications")).toArray(); + applications.reserve(array.size()); + for (const QJsonValue &value : array) { + if (value.isObject()) + applications.append(value.toObject().toVariantMap()); + } + emit indexReady(repository, applications); + }); +} + +void CatalogClient::fetchDetails(const QUrl &url) +{ + getJson(url, [this](const QJsonObject &details) { + emit detailsReady(details.toVariantMap()); + }); +} + +void CatalogClient::fetchIcon(const QUrl &url) +{ + if (!url.isValid()) + return; + + auto *reply = m_network.get(QNetworkRequest(url)); + ++m_pendingRequests; + setBusy(true); + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + if (reply->error() == QNetworkReply::NoError) { + QPixmap icon; + if (icon.loadFromData(reply->readAll())) + emit iconReady(icon); + } + reply->deleteLater(); + --m_pendingRequests; + if (m_pendingRequests == 0) + setBusy(false); + }); +} diff --git a/src/catalogclient.h b/src/catalogclient.h new file mode 100644 index 0000000..7afecd6 --- /dev/null +++ b/src/catalogclient.h @@ -0,0 +1,40 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include + +class QPixmap; + +class CatalogClient final : public QObject { + Q_OBJECT + +public: + explicit CatalogClient(QObject *parent = nullptr); + + void setBaseUrl(const QUrl &baseUrl); + QUrl baseUrl() const; + void fetchIndex(); + void fetchDetails(const QUrl &url); + void fetchIcon(const QUrl &url); + +signals: + void indexReady(const QVariantMap &repository, const QVector &applications); + void detailsReady(const QVariantMap &details); + void iconReady(const QPixmap &icon); + void requestFailed(const QString &message); + void busyChanged(bool busy); + +private: + void getJson(const QUrl &url, const std::function &callback); + void setBusy(bool busy); + + QNetworkAccessManager m_network; + QUrl m_baseUrl; + int m_pendingRequests = 0; +}; diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..4d42ec7 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,16 @@ +#include "mainwindow.h" + +#include +#include + +int main(int argc, char *argv[]) +{ + QApplication app(argc, argv); + QApplication::setApplicationName(QStringLiteral("AppImage Release Catalog")); + QApplication::setApplicationVersion(QStringLiteral(APP_VERSION)); + QApplication::setOrganizationName(QStringLiteral("enne2")); + + MainWindow window; + window.show(); + return app.exec(); +} diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp new file mode 100644 index 0000000..fea8fcb --- /dev/null +++ b/src/mainwindow.cpp @@ -0,0 +1,332 @@ +#include "mainwindow.h" + +#include "catalogclient.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { +QVariantMap mapValue(const QVariantMap &map, const QString &key) +{ + return map.value(key).toMap(); +} + +QString urlValue(const QVariantMap &map, const QString &key) +{ + return map.value(key).toString(); +} +} + +MainWindow::MainWindow(QWidget *parent) + : QMainWindow(parent), m_client(new CatalogClient(this)) +{ + buildUi(); + connect(m_client, &CatalogClient::indexReady, this, &MainWindow::showIndex); + connect(m_client, &CatalogClient::detailsReady, this, &MainWindow::showDetails); + connect(m_client, &CatalogClient::iconReady, this, &MainWindow::showIcon); + connect(m_client, &CatalogClient::requestFailed, this, &MainWindow::showError); + connect(m_client, &CatalogClient::busyChanged, this, &MainWindow::setBusy); + + refreshCatalog(); +} + +void MainWindow::buildUi() +{ + setWindowTitle(QStringLiteral("AppImage Release Catalog")); + resize(1120, 720); + + auto *central = new QWidget(this); + auto *rootLayout = new QVBoxLayout(central); + rootLayout->setContentsMargins(12, 12, 12, 8); + rootLayout->setSpacing(8); + + auto *connectionLayout = new QHBoxLayout; + connectionLayout->addWidget(new QLabel(QStringLiteral("Catalogo:"), central)); + m_baseUrlEdit = new QLineEdit(QStringLiteral("http://10.8.0.3:8092"), central); + m_baseUrlEdit->setPlaceholderText(QStringLiteral("https://host:porta")); + m_baseUrlEdit->setToolTip(QStringLiteral("URL base del repository AppImage")); + connectionLayout->addWidget(m_baseUrlEdit, 1); + m_refreshButton = new QPushButton(QStringLiteral("Aggiorna"), central); + m_refreshButton->setDefault(true); + connectionLayout->addWidget(m_refreshButton); + rootLayout->addLayout(connectionLayout); + + auto *splitter = new QSplitter(Qt::Horizontal, central); + splitter->setChildrenCollapsible(false); + + auto *leftPanel = new QWidget(splitter); + auto *leftLayout = new QVBoxLayout(leftPanel); + leftLayout->setContentsMargins(0, 0, 8, 0); + leftLayout->addWidget(new QLabel(QStringLiteral("Applicazioni"), leftPanel)); + m_applicationList = new QListWidget(leftPanel); + m_applicationList->setMinimumWidth(280); + m_applicationList->setAlternatingRowColors(true); + leftLayout->addWidget(m_applicationList, 1); + + auto *rightPanel = new QWidget(splitter); + auto *rightLayout = new QVBoxLayout(rightPanel); + rightLayout->setContentsMargins(8, 0, 0, 0); + + auto *headingLayout = new QHBoxLayout; + m_iconLabel = new QLabel(rightPanel); + m_iconLabel->setFixedSize(80, 80); + m_iconLabel->setAlignment(Qt::AlignCenter); + m_iconLabel->setStyleSheet(QStringLiteral("background: palette(alternate-base); border-radius: 8px;")); + headingLayout->addWidget(m_iconLabel); + auto *titleLayout = new QVBoxLayout; + m_nameLabel = new QLabel(QStringLiteral("Seleziona un'applicazione"), rightPanel); + m_nameLabel->setStyleSheet(QStringLiteral("font-size: 20px; font-weight: 600;")); + m_nameLabel->setWordWrap(true); + titleLayout->addWidget(m_nameLabel); + m_summaryLabel = new QLabel(rightPanel); + m_summaryLabel->setWordWrap(true); + m_summaryLabel->setStyleSheet(QStringLiteral("color: palette(placeholder-text);")); + titleLayout->addWidget(m_summaryLabel); + headingLayout->addLayout(titleLayout, 1); + rightLayout->addLayout(headingLayout); + + m_descriptionLabel = new QLabel(rightPanel); + m_descriptionLabel->setWordWrap(true); + m_descriptionLabel->setTextInteractionFlags(Qt::TextSelectableByMouse); + rightLayout->addWidget(m_descriptionLabel); + + m_metadataLabel = new QLabel(rightPanel); + m_metadataLabel->setWordWrap(true); + m_metadataLabel->setTextInteractionFlags(Qt::TextSelectableByMouse); + rightLayout->addWidget(m_metadataLabel); + + auto *releaseTitle = new QLabel(QStringLiteral("Release"), rightPanel); + releaseTitle->setStyleSheet(QStringLiteral("font-weight: 600; margin-top: 8px;")); + rightLayout->addWidget(releaseTitle); + m_releaseTable = new QTableWidget(0, 7, rightPanel); + m_releaseTable->setHorizontalHeaderLabels({QStringLiteral("Version"), QStringLiteral("Canale"), + QStringLiteral("Arch"), QStringLiteral("Data"), + QStringLiteral("Dimensione"), QStringLiteral("File"), + QStringLiteral("SHA-256")}); + m_releaseTable->setSelectionBehavior(QAbstractItemView::SelectRows); + m_releaseTable->setEditTriggers(QAbstractItemView::NoEditTriggers); + m_releaseTable->setAlternatingRowColors(true); + m_releaseTable->horizontalHeader()->setStretchLastSection(true); + m_releaseTable->horizontalHeader()->setSectionResizeMode(5, QHeaderView::Stretch); + rightLayout->addWidget(m_releaseTable, 1); + + auto *actionsLayout = new QHBoxLayout; + m_downloadButton = new QPushButton(QStringLiteral("Apri download AppImage"), rightPanel); + m_zsyncButton = new QPushButton(QStringLiteral("Apri zsync"), rightPanel); + m_projectButton = new QPushButton(QStringLiteral("Apri progetto"), rightPanel); + actionsLayout->addWidget(m_downloadButton); + actionsLayout->addWidget(m_zsyncButton); + actionsLayout->addWidget(m_projectButton); + actionsLayout->addStretch(); + rightLayout->addLayout(actionsLayout); + + splitter->addWidget(leftPanel); + splitter->addWidget(rightPanel); + splitter->setStretchFactor(1, 1); + rootLayout->addWidget(splitter, 1); + + m_statusLabel = new QLabel(QStringLiteral("Pronto"), central); + m_statusLabel->setTextInteractionFlags(Qt::TextSelectableByMouse); + rootLayout->addWidget(m_statusLabel); + setCentralWidget(central); + + connect(m_refreshButton, &QPushButton::clicked, this, &MainWindow::refreshCatalog); + connect(m_baseUrlEdit, &QLineEdit::returnPressed, this, &MainWindow::refreshCatalog); + connect(m_applicationList, &QListWidget::itemClicked, this, &MainWindow::selectApplication); + connect(m_downloadButton, &QPushButton::clicked, this, [this] { openSelectedUrl(QStringLiteral("stableUrl")); }); + connect(m_zsyncButton, &QPushButton::clicked, this, [this] { openSelectedUrl(QStringLiteral("zsyncUrl")); }); + connect(m_projectButton, &QPushButton::clicked, this, [this] { openSelectedUrl(QStringLiteral("projectUrl")); }); + + clearDetails(QStringLiteral("Seleziona un'applicazione per visualizzare le release.")); +} + +void MainWindow::refreshCatalog() +{ + const QUrl url(m_baseUrlEdit->text().trimmed()); + if (!url.isValid() || url.scheme().isEmpty() || url.host().isEmpty()) { + showError(QStringLiteral("Inserisci un URL base valido, ad esempio http://10.8.0.3:8092")); + return; + } + m_client->setBaseUrl(url); + m_statusLabel->setText(QStringLiteral("Caricamento catalogo…")); + m_client->fetchIndex(); +} + +void MainWindow::selectApplication(QListWidgetItem *item) +{ + if (!item) + return; + const int index = item->data(Qt::UserRole).toInt(); + if (index < 0 || index >= m_applications.size()) + return; + + const QVariantMap app = m_applications.at(index); + m_statusLabel->setText(QStringLiteral("Caricamento dettagli…")); + clearDetails(QStringLiteral("Caricamento dettagli dell'applicazione…")); + m_client->fetchDetails(QUrl(urlValue(app, QStringLiteral("detailsUrl")))); +} + +void MainWindow::showIndex(const QVariantMap &repository, const QVector &applications) +{ + m_applications = applications; + m_applicationList->clear(); + + const QString repositoryName = stringValue(repository, QStringLiteral("name")); + const QString updatedAt = stringValue(repository, QStringLiteral("updatedAt")); + m_statusLabel->setText(QStringLiteral("%1 — %2 applicazioni — aggiornato %3") + .arg(repositoryName.isEmpty() ? QStringLiteral("Catalogo") : repositoryName) + .arg(applications.size()) + .arg(updatedAt.isEmpty() ? QStringLiteral("n/d") : updatedAt)); + + for (int i = 0; i < applications.size(); ++i) { + const QVariantMap app = applications.at(i); + auto *item = new QListWidgetItem(stringValue(app, QStringLiteral("name")), m_applicationList); + item->setData(Qt::UserRole, i); + item->setToolTip(stringValue(app, QStringLiteral("summary"))); + } + + if (!applications.isEmpty()) { + m_applicationList->setCurrentRow(0); + selectApplication(m_applicationList->item(0)); + } else { + clearDetails(QStringLiteral("Il catalogo non contiene applicazioni.")); + } +} + +void MainWindow::showDetails(const QVariantMap &details) +{ + m_selectedDetails = details; + m_nameLabel->setText(stringValue(details, QStringLiteral("name"))); + m_summaryLabel->setText(stringValue(details, QStringLiteral("summary"))); + m_descriptionLabel->setText(stringValue(details, QStringLiteral("description"))); + + const QVariantMap developer = mapValue(details, QStringLiteral("developer")); + const QStringList categories = details.value(QStringLiteral("categories")).toStringList(); + const QStringList licenses = details.value(QStringLiteral("licenses")).toStringList(); + m_metadataLabel->setText(QStringLiteral("Sviluppatore: %1 • Categorie: %2 • Licenze: %3") + .arg(stringValue(developer, QStringLiteral("name"))) + .arg(categories.join(QStringLiteral(", "))) + .arg(licenses.join(QStringLiteral(", ")))); + + m_releaseTable->setRowCount(0); + const QVariantList releases = details.value(QStringLiteral("releases")).toList(); + if (!releases.isEmpty()) { + m_selectedRelease = releases.first().toMap(); + for (const QVariant &value : releases) + populateRelease(value.toMap()); + } else { + m_selectedRelease.clear(); + } + + m_downloadButton->setEnabled(!m_selectedRelease.isEmpty()); + m_zsyncButton->setEnabled(!m_selectedRelease.isEmpty()); + m_projectButton->setEnabled(!urlValue(details, QStringLiteral("projectUrl")).isEmpty()); + + const QString iconUrl = urlValue(details, QStringLiteral("icon")); + if (iconUrl.isEmpty()) + showIcon(QPixmap()); + else + m_client->fetchIcon(QUrl(iconUrl)); + m_statusLabel->setText(QStringLiteral("Dettagli caricati")); +} + +void MainWindow::populateRelease(const QVariantMap &release) +{ + const int row = m_releaseTable->rowCount(); + m_releaseTable->insertRow(row); + const QStringList values = { + stringValue(release, QStringLiteral("version")), + stringValue(release, QStringLiteral("channel")), + stringValue(release, QStringLiteral("arch")), + stringValue(release, QStringLiteral("date")), + formatBytes(release.value(QStringLiteral("size")).toLongLong()), + stringValue(release, QStringLiteral("file")), + stringValue(release, QStringLiteral("sha256")) + }; + for (int column = 0; column < values.size(); ++column) + m_releaseTable->setItem(row, column, new QTableWidgetItem(values.at(column))); +} + +void MainWindow::showIcon(const QPixmap &icon) +{ + if (icon.isNull()) { + m_iconLabel->setPixmap(QPixmap()); + m_iconLabel->setText(QStringLiteral("APP")); + return; + } + m_iconLabel->setText(QString()); + m_iconLabel->setPixmap(icon.scaled(m_iconLabel->size(), Qt::KeepAspectRatio, Qt::SmoothTransformation)); +} + +void MainWindow::openSelectedUrl(const QString &field) +{ + QString value; + if (field == QStringLiteral("projectUrl")) + value = urlValue(m_selectedDetails, field); + else + value = urlValue(m_selectedRelease, field); + if (!value.isEmpty()) + QDesktopServices::openUrl(QUrl(value)); +} + +void MainWindow::clearDetails(const QString &message) +{ + m_nameLabel->setText(QStringLiteral("AppImage Release Catalog")); + m_summaryLabel->clear(); + m_descriptionLabel->setText(message); + m_metadataLabel->clear(); + m_releaseTable->setRowCount(0); + m_selectedRelease.clear(); + m_downloadButton->setEnabled(false); + m_zsyncButton->setEnabled(false); + m_projectButton->setEnabled(false); + showIcon(QPixmap()); +} + +void MainWindow::showError(const QString &message) +{ + m_statusLabel->setText(message); +} + +void MainWindow::setBusy(bool busy) +{ + m_refreshButton->setEnabled(!busy); + if (busy) + m_refreshButton->setText(QStringLiteral("Caricamento…")); + else + m_refreshButton->setText(QStringLiteral("Aggiorna")); +} + +QString MainWindow::stringValue(const QVariantMap &map, const QString &key) +{ + return map.value(key).toString(); +} + +QString MainWindow::formatBytes(qint64 bytes) +{ + if (bytes < 0) + return QStringLiteral("n/d"); + if (bytes >= 1024LL * 1024 * 1024) + return QStringLiteral("%1 GiB").arg(bytes / (1024.0 * 1024 * 1024), 0, 'f', 2); + if (bytes >= 1024LL * 1024) + return QStringLiteral("%1 MiB").arg(bytes / (1024.0 * 1024), 0, 'f', 1); + if (bytes >= 1024) + return QStringLiteral("%1 KiB").arg(bytes / 1024.0, 0, 'f', 1); + return QStringLiteral("%1 B").arg(bytes); +} diff --git a/src/mainwindow.h b/src/mainwindow.h new file mode 100644 index 0000000..82cc2f0 --- /dev/null +++ b/src/mainwindow.h @@ -0,0 +1,57 @@ +#pragma once + +#include +#include +#include + +class CatalogClient; +class QLabel; +class QLineEdit; +class QPixmap; +class QListWidget; +class QListWidgetItem; +class QPushButton; +class QTableWidget; + +class MainWindow final : public QMainWindow { + Q_OBJECT + +public: + explicit MainWindow(QWidget *parent = nullptr); + +private slots: + void refreshCatalog(); + void selectApplication(QListWidgetItem *item); + void showIndex(const QVariantMap &repository, const QVector &applications); + void showDetails(const QVariantMap &details); + void showIcon(const QPixmap &icon); + void showError(const QString &message); + void setBusy(bool busy); + +private: + void buildUi(); + void clearDetails(const QString &message); + void populateRelease(const QVariantMap &release); + void openSelectedUrl(const QString &field); + static QString formatBytes(qint64 bytes); + static QString stringValue(const QVariantMap &map, const QString &key); + + CatalogClient *m_client = nullptr; + QLineEdit *m_baseUrlEdit = nullptr; + QPushButton *m_refreshButton = nullptr; + QListWidget *m_applicationList = nullptr; + QLabel *m_iconLabel = nullptr; + QLabel *m_nameLabel = nullptr; + QLabel *m_summaryLabel = nullptr; + QLabel *m_descriptionLabel = nullptr; + QLabel *m_metadataLabel = nullptr; + QTableWidget *m_releaseTable = nullptr; + QPushButton *m_downloadButton = nullptr; + QPushButton *m_zsyncButton = nullptr; + QPushButton *m_projectButton = nullptr; + QLabel *m_statusLabel = nullptr; + + QVector m_applications; + QVariantMap m_selectedDetails; + QVariantMap m_selectedRelease; +};