Initial Qt AppImage release catalog client

This commit is contained in:
Matteo Benedetto
2026-08-30 18:12:51 +02:00
commit a52c110159
10 changed files with 650 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
build/
.cache/
.DS_Store
+32
View File
@@ -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}
)
+34
View File
@@ -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/<app>.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`.
+11
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

+125
View File
@@ -0,0 +1,125 @@
#include "catalogclient.h"
#include <QJsonArray>
#include <QJsonDocument>
#include <QNetworkReply>
#include <QPixmap>
#include <functional>
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<void(const QJsonObject &)> &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<QVariantMap> 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);
});
}
+40
View File
@@ -0,0 +1,40 @@
#pragma once
#include <QJsonObject>
#include <QNetworkAccessManager>
#include <QObject>
#include <QUrl>
#include <QVariantMap>
#include <QVector>
#include <functional>
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<QVariantMap> &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<void(const QJsonObject &)> &callback);
void setBusy(bool busy);
QNetworkAccessManager m_network;
QUrl m_baseUrl;
int m_pendingRequests = 0;
};
+16
View File
@@ -0,0 +1,16 @@
#include "mainwindow.h"
#include <QApplication>
#include <QStyleFactory>
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();
}
+332
View File
@@ -0,0 +1,332 @@
#include "mainwindow.h"
#include "catalogclient.h"
#include <QDesktopServices>
#include <QFormLayout>
#include <QHBoxLayout>
#include <QHeaderView>
#include <QLabel>
#include <QLineEdit>
#include <QListWidget>
#include <QMessageBox>
#include <QPushButton>
#include <QSplitter>
#include <QTableWidget>
#include <QTableWidgetItem>
#include <QTextBrowser>
#include <QUrl>
#include <QVBoxLayout>
#include <QWidget>
#include <algorithm>
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<QVariantMap> &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);
}
+57
View File
@@ -0,0 +1,57 @@
#pragma once
#include <QMainWindow>
#include <QVariantMap>
#include <QVector>
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<QVariantMap> &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<QVariantMap> m_applications;
QVariantMap m_selectedDetails;
QVariantMap m_selectedRelease;
};