From 6fbb948082bad66a821c8332e2fb9cb7965065f8 Mon Sep 17 00:00:00 2001 From: Alexey Minnekhanov <alexeymin@postmarketos.org> Date: Sun, 12 Jan 2020 01:02:39 +0300 Subject: [PATCH] Add support for Alpine Linux apk backend Alpine Package Keeper (apk) is package manager for Alpine Linux. --- discover/FeaturedModel.cpp | 6 +- .../AlpineApkAuthActionFactory.cpp | 118 ++++ .../AlpineApkAuthActionFactory.h | 41 ++ .../AlpineApkBackend/AlpineApkAuthHelper.cpp | 302 +++++++++++ .../AlpineApkBackend/AlpineApkAuthHelper.h | 66 +++ .../AlpineApkBackend/AlpineApkBackend.cpp | 503 ++++++++++++++++++ .../AlpineApkBackend/AlpineApkBackend.h | 99 ++++ .../AlpineApkBackend/AlpineApkResource.cpp | 341 ++++++++++++ .../AlpineApkBackend/AlpineApkResource.h | 93 ++++ .../AlpineApkReviewsBackend.cpp | 35 ++ .../AlpineApkReviewsBackend.h | 52 ++ .../AlpineApkSourcesBackend.cpp | 195 +++++++ .../AlpineApkSourcesBackend.h | 57 ++ .../AlpineApkBackend/AlpineApkTransaction.cpp | 141 +++++ .../AlpineApkBackend/AlpineApkTransaction.h | 49 ++ .../AlpineApkBackend/AlpineApkUpdater.cpp | 295 ++++++++++ .../AlpineApkBackend/AlpineApkUpdater.h | 197 +++++++ .../AppstreamDataDownloader.cpp | 303 +++++++++++ .../AppstreamDataDownloader.h | 139 +++++ .../backends/AlpineApkBackend/CMakeLists.txt | 85 +++ .../org.kde.discover.alpineapkbackend.actions | 5 + libdiscover/backends/CMakeLists.txt | 10 + 22 files changed, 3129 insertions(+), 3 deletions(-) create mode 100644 libdiscover/backends/AlpineApkBackend/AlpineApkAuthActionFactory.cpp create mode 100644 libdiscover/backends/AlpineApkBackend/AlpineApkAuthActionFactory.h create mode 100644 libdiscover/backends/AlpineApkBackend/AlpineApkAuthHelper.cpp create mode 100644 libdiscover/backends/AlpineApkBackend/AlpineApkAuthHelper.h create mode 100644 libdiscover/backends/AlpineApkBackend/AlpineApkBackend.cpp create mode 100644 libdiscover/backends/AlpineApkBackend/AlpineApkBackend.h create mode 100644 libdiscover/backends/AlpineApkBackend/AlpineApkResource.cpp create mode 100644 libdiscover/backends/AlpineApkBackend/AlpineApkResource.h create mode 100644 libdiscover/backends/AlpineApkBackend/AlpineApkReviewsBackend.cpp create mode 100644 libdiscover/backends/AlpineApkBackend/AlpineApkReviewsBackend.h create mode 100644 libdiscover/backends/AlpineApkBackend/AlpineApkSourcesBackend.cpp create mode 100644 libdiscover/backends/AlpineApkBackend/AlpineApkSourcesBackend.h create mode 100644 libdiscover/backends/AlpineApkBackend/AlpineApkTransaction.cpp create mode 100644 libdiscover/backends/AlpineApkBackend/AlpineApkTransaction.h create mode 100644 libdiscover/backends/AlpineApkBackend/AlpineApkUpdater.cpp create mode 100644 libdiscover/backends/AlpineApkBackend/AlpineApkUpdater.h create mode 100644 libdiscover/backends/AlpineApkBackend/AppstreamDataDownloader.cpp create mode 100644 libdiscover/backends/AlpineApkBackend/AppstreamDataDownloader.h create mode 100644 libdiscover/backends/AlpineApkBackend/CMakeLists.txt create mode 100644 libdiscover/backends/AlpineApkBackend/org.kde.discover.alpineapkbackend.actions diff --git a/discover/FeaturedModel.cpp b/discover/FeaturedModel.cpp index 1393e206..038f22c1 100644 --- a/discover/FeaturedModel.cpp +++ b/discover/FeaturedModel.cpp @@ -98,13 +98,13 @@ void FeaturedModel::refresh() setUris(uris); } -void FeaturedModel::setUris(const QVector<QUrl>& uris) +void FeaturedModel::setUris(const QVector<QUrl> &uris) { if (!m_backend) return; - QSet<ResultsStream*> streams; - foreach(const auto &uri, uris) { + QSet<ResultsStream *> streams; + for (const QUrl &uri: uris) { AbstractResourcesBackend::Filters filter; filter.resourceUrl = uri; streams << m_backend->search(filter); diff --git a/libdiscover/backends/AlpineApkBackend/AlpineApkAuthActionFactory.cpp b/libdiscover/backends/AlpineApkBackend/AlpineApkAuthActionFactory.cpp new file mode 100644 index 00000000..972f8ec5 --- /dev/null +++ b/libdiscover/backends/AlpineApkBackend/AlpineApkAuthActionFactory.cpp @@ -0,0 +1,118 @@ +/*************************************************************************** + * Copyright © 2020 Alexey Min <alexey.min@gmail.com> * + * * + * This program is free software; you can redistribute it and/or * + * modify it under the terms of the GNU General Public License as * + * published by the Free Software Foundation; either version 2 of * + * the License or (at your option) version 3 or any later version * + * accepted by the membership of KDE e.V. (or its successor approved * + * by the membership of KDE e.V.), which shall act as a proxy * + * defined in Section 14 of version 3 of the license. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see <http://www.gnu.org/licenses/>. * + ***************************************************************************/ + +#include <KLocalizedString> +#include <kauth_version.h> + +#include "AlpineApkAuthActionFactory.h" +#include "alpineapk_backend_logging.h" + +namespace ActionFactory { + +static KAuth::Action createAlpineApkKAuthAction() +{ + KAuth::Action action(QStringLiteral("org.kde.discover.alpineapkbackend.pkgmgmt")); + action.setHelperId(QStringLiteral("org.kde.discover.alpineapkbackend")); + if (!action.isValid()) { + qCWarning(LOG_ALPINEAPK) << "Created KAuth action is not valid!"; + return action; + } + + // set action description + // setDetails deprecated since KF 5.68, use setDetailsV2() with DetailsMap. +#if KAUTH_VERSION < QT_VERSION_CHECK(5, 68, 0) + action.setDetails(i18n("Package management")); +#else + static const KAuth::Action::DetailsMap details{ + { KAuth::Action::AuthDetail::DetailMessage, i18n("Package management") } + }; + action.setDetailsV2(details); +#endif + + // change default timeout to 1 minute, bcause default DBus timeout + // of 25 seconds is not enough + action.setTimeout(1 * 60 * 1000); + + return action; +} + +KAuth::ExecuteJob *createUpdateAction(const QString &fakeRoot) +{ + KAuth::Action action = createAlpineApkKAuthAction(); + if (!action.isValid()) { + return nullptr; + } + // update-action specific details + action.setTimeout(2 * 60 * 1000); // 2 minutes + action.addArgument(QLatin1String("pkgAction"), QLatin1String("update")); + action.addArgument(QLatin1String("fakeRoot"), fakeRoot); + return action.execute(); +} + +KAuth::ExecuteJob *createUpgradeAction(bool onlySimulate) +{ + KAuth::Action action = createAlpineApkKAuthAction(); + if (!action.isValid()) { + return nullptr; + } + action.setTimeout(3 * 60 * 60 * 1000); // 3 hours, system upgrade can take really long + action.addArgument(QLatin1String("pkgAction"), QLatin1String("upgrade")); + action.addArgument(QLatin1String("onlySimulate"), onlySimulate); + return action.execute(); +} + +KAuth::ExecuteJob *createAddAction(const QString &pkgName) +{ + KAuth::Action action = createAlpineApkKAuthAction(); + if (!action.isValid()) { + return nullptr; + } + action.setTimeout(1 * 60 * 60 * 1000); // 1 hour, in case package is really big? + action.addArgument(QLatin1String("pkgAction"), QLatin1String("add")); + action.addArgument(QLatin1String("pkgName"), pkgName); + return action.execute(); +} + +KAuth::ExecuteJob *createDelAction(const QString &pkgName) +{ + KAuth::Action action = createAlpineApkKAuthAction(); + if (!action.isValid()) { + return nullptr; + } + action.setTimeout(1 * 60 * 60 * 1000); // although deletion is almost instant + action.addArgument(QLatin1String("pkgAction"), QLatin1String("del")); + action.addArgument(QLatin1String("pkgName"), pkgName); + return action.execute(); +} + +KAuth::ExecuteJob *createRepoconfigAction(const QVariant &repoUrls) +{ + KAuth::Action action = createAlpineApkKAuthAction(); + if (!action.isValid()) { + return nullptr; + } + // should be instant, writes few lines to /etc/apk/repositories + action.setTimeout(1 * 60 * 1000); // 1 minute + action.addArgument(QLatin1String("pkgAction"), QLatin1String("repoconfig")); + action.addArgument(QLatin1String("repoList"), repoUrls); + return action.execute(); +} + +} // namespace ActionFactory diff --git a/libdiscover/backends/AlpineApkBackend/AlpineApkAuthActionFactory.h b/libdiscover/backends/AlpineApkBackend/AlpineApkAuthActionFactory.h new file mode 100644 index 00000000..ff5667f8 --- /dev/null +++ b/libdiscover/backends/AlpineApkBackend/AlpineApkAuthActionFactory.h @@ -0,0 +1,41 @@ +/*************************************************************************** + * Copyright © 2020 Alexey Min <alexey.min@gmail.com> * + * * + * This program is free software; you can redistribute it and/or * + * modify it under the terms of the GNU General Public License as * + * published by the Free Software Foundation; either version 2 of * + * the License or (at your option) version 3 or any later version * + * accepted by the membership of KDE e.V. (or its successor approved * + * by the membership of KDE e.V.), which shall act as a proxy * + * defined in Section 14 of version 3 of the license. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see <http://www.gnu.org/licenses/>. * + ***************************************************************************/ + +#ifndef AlpineApkAuthActionFactory_H +#define AlpineApkAuthActionFactory_H + +#include <QString> +#include <QVariant> + +#include <KAuthAction> +#include <KAuthActionReply> +#include <KAuthExecuteJob> + +namespace ActionFactory { + +KAuth::ExecuteJob *createUpdateAction(const QString &fakeRoot); +KAuth::ExecuteJob *createUpgradeAction(bool onlySimulate = false); +KAuth::ExecuteJob *createAddAction(const QString &pkgName); +KAuth::ExecuteJob *createDelAction(const QString &pkgName); +KAuth::ExecuteJob *createRepoconfigAction(const QVariant &repoUrls); + +} // namespace ActionFactory + +#endif diff --git a/libdiscover/backends/AlpineApkBackend/AlpineApkAuthHelper.cpp b/libdiscover/backends/AlpineApkBackend/AlpineApkAuthHelper.cpp new file mode 100644 index 00000000..97affc01 --- /dev/null +++ b/libdiscover/backends/AlpineApkBackend/AlpineApkAuthHelper.cpp @@ -0,0 +1,302 @@ +/*************************************************************************** + * Copyright © 2020 Alexey Min <alexey.min@gmail.com> * + * * + * This program is free software; you can redistribute it and/or * + * modify it under the terms of the GNU General Public License as * + * published by the Free Software Foundation; either version 2 of * + * the License or (at your option) version 3 or any later version * + * accepted by the membership of KDE e.V. (or its successor approved * + * by the membership of KDE e.V.), which shall act as a proxy * + * defined in Section 14 of version 3 of the license. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see <http://www.gnu.org/licenses/>. * + ***************************************************************************/ + +#include <QProcess> +#include <QDebug> +#include <QLoggingCategory> +#include <QSocketNotifier> +#include <QScopedPointer> +#include <QFile> + +#include <KAuthHelperSupport> +#include <kauth_version.h> + +#include "AlpineApkAuthHelper.h" + +#ifdef QT_DEBUG +Q_LOGGING_CATEGORY(LOG_AUTHHELPER, "org.kde.discover.alpineapkbackend.authhelper", QtDebugMsg) +#else +Q_LOGGING_CATEGORY(LOG_AUTHHELPER, "org.kde.discover.alpineapkbackend.authhelper", QtWarningMsg) +#endif + +using namespace KAuth; + +AlpineApkAuthHelper::AlpineApkAuthHelper() {} + +AlpineApkAuthHelper::~AlpineApkAuthHelper() +{ + closeDatabase(); +} + +bool AlpineApkAuthHelper::openDatabase(const QVariantMap &args, bool readwrite) +{ + // is already opened? + if (m_apkdb.isOpen()) { + return true; + } + + // maybe set fakeRoot (needs to be done before Database::open() + const QString fakeRoot = args.value(QLatin1String("fakeRoot"), QString()).toString(); + if (!fakeRoot.isEmpty()) { + m_apkdb.setFakeRoot(fakeRoot); + } + + // calculate flags to use during open + QtApk::DbOpenFlags fl = QtApk::QTAPK_OPENF_ENABLE_PROGRESSFD; + if (readwrite) { + fl |= QtApk::QTAPK_OPENF_READWRITE; + } + + if (!m_apkdb.open(fl)) { + return false; + } + return true; +} + +void AlpineApkAuthHelper::closeDatabase() +{ + // close database only if opened + if (m_apkdb.isOpen()) { + // this also stops bg thread + m_apkdb.close(); + } +} + +void AlpineApkAuthHelper::setupTransactionPostCreate(QtApk::Transaction *trans) +{ + m_currentTransaction = trans; // remember current transaction here + + // receive progress notifications + QObject::connect(trans, &QtApk::Transaction::progressChanged, + this, &AlpineApkAuthHelper::reportProgress); + + // receive error messages + QObject::connect(trans, &QtApk::Transaction::errorOccured, + this, &AlpineApkAuthHelper::onTransactionError); + + // what to do when transaction is complete + QObject::connect(trans, &QtApk::Transaction::finished, + this, &AlpineApkAuthHelper::onTransactionFinished); + + if (!m_loop) { + m_loop = new QEventLoop(this); + } +} + +void AlpineApkAuthHelper::reportProgress(float percent) +{ + int p = static_cast<int>(percent); + if (p < 0) p = 0; + if (p > 100) p = 100; + HelperSupport::progressStep(p); +} + +void AlpineApkAuthHelper::onTransactionError(const QString &msg) +{ + qCWarning(LOG_AUTHHELPER).nospace() << "ERROR occured in transaction \"" + << m_currentTransaction->desc() + << "\": " << msg; + // construct error message to use in helper reply + const QString errMsg = m_currentTransaction->desc() + QLatin1String(" failed: ") + msg; + m_actionReply.setErrorDescription(errMsg); + m_actionReply.setData({ + { QLatin1String("errorString"), errMsg } + }); + m_trans_ok = false; +} + +void AlpineApkAuthHelper::onTransactionFinished() +{ + m_lastChangeset = m_currentTransaction->changeset(); + m_currentTransaction->deleteLater(); + m_currentTransaction = nullptr; + m_loop->quit(); +} + +// single entry point for all package management actions +ActionReply AlpineApkAuthHelper::pkgmgmt(const QVariantMap &args) +{ + m_actionReply = ActionReply::HelperErrorReply(); + HelperSupport::progressStep(0); + + // actual package management action to perform is passed in "pkgAction" argument + if (!args.contains(QLatin1String("pkgAction"))) { + m_actionReply.setError(ActionReply::InvalidActionError); + m_actionReply.setErrorDescription(QLatin1String("Please pass \'pkgAction\' argument.")); + HelperSupport::progressStep(100); + return m_actionReply; + } + + const QString pkgAction = args.value(QLatin1String("pkgAction")).toString(); + + if (pkgAction == QStringLiteral("update")) { + update(args); + } else if (pkgAction == QStringLiteral("add")) { + add(args); + } else if (pkgAction == QStringLiteral("del")) { + del(args); + } else if (pkgAction == QStringLiteral("upgrade")) { + upgrade(args); + } else if (pkgAction == QStringLiteral("repoconfig")) { + repoconfig(args); + } else { + // error: unknown pkgAction + m_actionReply.setError(ActionReply::NoSuchActionError); + m_actionReply.setErrorDescription(QLatin1String("Please pass a valid \'pkgAction\' argument. " + "Action \"%1\" is not recognized.").arg(pkgAction)); + } + + HelperSupport::progressStep(100); + return m_actionReply; +} + +void AlpineApkAuthHelper::update(const QVariantMap &args) +{ + if (!openDatabase(args)) { + m_actionReply.setErrorDescription(QStringLiteral("Failed to open database!")); + return; + } + + m_trans_ok = true; + QtApk::Transaction *trans = m_apkdb.updatePackageIndex(); + setupTransactionPostCreate(trans); + + trans->start(); + m_loop->exec(); + + if (m_trans_ok) { + int updatesCount = m_apkdb.upgradeablePackagesCount(); + m_actionReply = ActionReply::SuccessReply(); + m_actionReply.setData({ + { QLatin1String("updatesCount"), updatesCount } + }); + } +} + +void AlpineApkAuthHelper::add(const QVariantMap &args) +{ + if (!openDatabase(args)) { + m_actionReply.setErrorDescription(QStringLiteral("Failed to open database!")); + return; + } + + const QString pkgName = args.value(QLatin1String("pkgName"), QString()).toString(); + if (pkgName.isEmpty()) { + m_actionReply.setErrorDescription(QStringLiteral("Specify pkgName for adding!")); + return; + } + + m_trans_ok = true; + QtApk::Transaction *trans = m_apkdb.add(pkgName); + setupTransactionPostCreate(trans); + + trans->start(); + m_loop->exec(); + + if (m_trans_ok) { + m_actionReply = ActionReply::SuccessReply(); + } +} + +void AlpineApkAuthHelper::del(const QVariantMap &args) +{ + if (!openDatabase(args)) { + m_actionReply.setErrorDescription(QStringLiteral("Failed to open database!")); + return; + } + + const QString pkgName = args.value(QLatin1String("pkgName"), QString()).toString(); + if (pkgName.isEmpty()) { + m_actionReply.setErrorDescription(QStringLiteral("Specify pkgName for removing!")); + return; + } + + const bool delRdepends = args.value(QLatin1String("delRdepends"), false).toBool(); + + QtApk::DbDelFlags delFlags = QtApk::QTAPK_DEL_DEFAULT; + if (delRdepends) { + delFlags = QtApk::QTAPK_DEL_RDEPENDS; + } + + m_trans_ok = true; + QtApk::Transaction *trans = m_apkdb.del(pkgName, delFlags); + setupTransactionPostCreate(trans); + + trans->start(); + m_loop->exec(); + + if (m_trans_ok) { + m_actionReply = ActionReply::SuccessReply(); + } +} + +void AlpineApkAuthHelper::upgrade(const QVariantMap &args) +{ + if (!openDatabase(args)) { + m_actionReply.setErrorDescription(QStringLiteral("Failed to open database!")); + return; + } + + bool onlySimulate = args.value(QLatin1String("onlySimulate"), false).toBool(); + QtApk::DbUpgradeFlags flags = QtApk::QTAPK_UPGRADE_DEFAULT; + if (onlySimulate) { + flags = QtApk::QTAPK_UPGRADE_SIMULATE; + qCDebug(LOG_AUTHHELPER) << "Simulating upgrade run."; + } + + m_trans_ok = true; + + QtApk::Transaction *trans = m_apkdb.upgrade(flags); + setupTransactionPostCreate(trans); + + trans->start(); + m_loop->exec(); + + if (m_trans_ok) { + m_actionReply = ActionReply::SuccessReply(); + QVariantMap replyData; + const QVector<QtApk::ChangesetItem> ch = m_lastChangeset.changes(); + QVector<QVariant> chVector; + QVector<QtApk::Package> pkgVector; + for (const QtApk::ChangesetItem &it: ch) { + pkgVector << it.newPackage; + } + replyData.insert(QLatin1String("changes"), QVariant::fromValue(pkgVector)); + replyData.insert(QLatin1String("onlySimulate"), onlySimulate); + m_actionReply.setData(replyData); + } +} + +void AlpineApkAuthHelper::repoconfig(const QVariantMap &args) +{ + if (args.contains(QLatin1String("repoList"))) { + const QVariant v = args.value(QLatin1String("repoList")); + const QVector<QtApk::Repository> repoVec = v.value<QVector<QtApk::Repository>>(); + if (QtApk::Database::saveRepositories(repoVec)) { + m_actionReply = ActionReply::SuccessReply(); // OK + } else { + m_actionReply.setErrorDescription(QStringLiteral("Failed to write repositories config!")); + } + } else { + m_actionReply.setErrorDescription(QStringLiteral("repoList parameter is missing in request!")); + } +} + +KAUTH_HELPER_MAIN("org.kde.discover.alpineapkbackend", AlpineApkAuthHelper) diff --git a/libdiscover/backends/AlpineApkBackend/AlpineApkAuthHelper.h b/libdiscover/backends/AlpineApkBackend/AlpineApkAuthHelper.h new file mode 100644 index 00000000..240e6ed3 --- /dev/null +++ b/libdiscover/backends/AlpineApkBackend/AlpineApkAuthHelper.h @@ -0,0 +1,66 @@ +/*************************************************************************** + * Copyright © 2020 Alexey Min <alexey.min@gmail.com> * + * * + * This program is free software; you can redistribute it and/or * + * modify it under the terms of the GNU General Public License as * + * published by the Free Software Foundation; either version 2 of * + * the License or (at your option) version 3 or any later version * + * accepted by the membership of KDE e.V. (or its successor approved * + * by the membership of KDE e.V.), which shall act as a proxy * + * defined in Section 14 of version 3 of the license. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see <http://www.gnu.org/licenses/>. * + ***************************************************************************/ + +#include <QEventLoop> +#include <QObject> +#include <QVariant> +#include <KAuthActionReply> + +#include <QtApk> + +using namespace KAuth; + +class AlpineApkAuthHelper : public QObject +{ + Q_OBJECT +public: + AlpineApkAuthHelper(); + ~AlpineApkAuthHelper() override; + +public Q_SLOTS: + // single entry point for all package management operations + ActionReply pkgmgmt(const QVariantMap &args); + +protected: + // helpers + bool openDatabase(const QVariantMap &args, bool readwrite = true); + void closeDatabase(); + void setupTransactionPostCreate(QtApk::Transaction *trans); + + // individual pakckage management actions + void update(const QVariantMap &args); + void add(const QVariantMap &args); + void del(const QVariantMap &args); + void upgrade(const QVariantMap &args); + void repoconfig(const QVariantMap &args); + +protected Q_SLOTS: + void reportProgress(float percent); + void onTransactionError(const QString &msg); + void onTransactionFinished(); + +private: + QtApk::DatabaseAsync m_apkdb; // runs transactions in bg thread + QtApk::Transaction *m_currentTransaction = nullptr; + QEventLoop *m_loop = nullptr; // event loop that will run and wait while bg transaction is in progress + ActionReply m_actionReply; // return value for main action slots + bool m_trans_ok = true; // flag to indicate if bg transaction was successful + QtApk::Changeset m_lastChangeset; // changeset from last completed transaction +}; diff --git a/libdiscover/backends/AlpineApkBackend/AlpineApkBackend.cpp b/libdiscover/backends/AlpineApkBackend/AlpineApkBackend.cpp new file mode 100644 index 00000000..4bfe165b --- /dev/null +++ b/libdiscover/backends/AlpineApkBackend/AlpineApkBackend.cpp @@ -0,0 +1,503 @@ +/*************************************************************************** + * Copyright © 2020 Alexey Min <alexey.min@gmail.com> * + * * + * This program is free software; you can redistribute it and/or * + * modify it under the terms of the GNU General Public License as * + * published by the Free Software Foundation; either version 2 of * + * the License or (at your option) version 3 or any later version * + * accepted by the membership of KDE e.V. (or its successor approved * + * by the membership of KDE e.V.), which shall act as a proxy * + * defined in Section 14 of version 3 of the license. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see <http://www.gnu.org/licenses/>. * + ***************************************************************************/ + +#include "AlpineApkBackend.h" +#include "AlpineApkResource.h" +#include "AlpineApkReviewsBackend.h" +#include "AlpineApkTransaction.h" +#include "AlpineApkSourcesBackend.h" +#include "AlpineApkUpdater.h" +#include "AppstreamDataDownloader.h" +#include "alpineapk_backend_logging.h" // generated by ECM + +#include "resources/SourcesModel.h" +#include "Transaction/Transaction.h" +#include "Category/Category.h" + +#include <KLocalizedString> + +#include <AppStreamQt/pool.h> + +#include <QAction> +#include <QtConcurrentRun> +#include <QDebug> +#include <QFuture> +#include <QFutureWatcher> +#include <QLoggingCategory> +#include <QSet> +#include <QThread> +#include <QThreadPool> +#include <QTimer> + +DISCOVER_BACKEND_PLUGIN(AlpineApkBackend) + +AlpineApkBackend::AlpineApkBackend(QObject *parent) + : AbstractResourcesBackend(parent) + , m_updater(new AlpineApkUpdater(this)) + , m_reviews(new AlpineApkReviewsBackend(this)) + , m_updatesTimeoutTimer(new QTimer(this)) +{ +#ifndef QT_DEBUG + const_cast<QLoggingCategory &>(LOG_ALPINEAPK()).setEnabled(QtDebugMsg, false); +#endif + + // connections with our updater + QObject::connect(m_updater, &AlpineApkUpdater::updatesCountChanged, + this, &AlpineApkBackend::updatesCountChanged); + QObject::connect(m_updater, &AlpineApkUpdater::checkForUpdatesFinished, + this, &AlpineApkBackend::finishCheckForUpdates); + QObject::connect(m_updater, &AlpineApkUpdater::fetchingUpdatesProgressChanged, + this, &AlpineApkBackend::setFetchingUpdatesProgress); + + // safety measure: make sure update check process can finish in some finite time + QObject::connect(m_updatesTimeoutTimer, &QTimer::timeout, + this, &AlpineApkBackend::finishCheckForUpdates); + m_updatesTimeoutTimer->setTimerType(Qt::CoarseTimer); + m_updatesTimeoutTimer->setSingleShot(true); + m_updatesTimeoutTimer->setInterval(5 * 60 * 1000); // 5 minutes + + // load packages data in a separate thread; it takes a noticeable amount of time + // and this way UI is not blocked here + m_fetching = true; // we are busy! + QFuture<void> loadResFuture = QtConcurrent::run(QThreadPool::globalInstance(), this, + &AlpineApkBackend::loadResources); + + QObject::connect(&m_voidFutureWatcher, &QFutureWatcher<void>::finished, + this, &AlpineApkBackend::onLoadResourcesFinished); + m_voidFutureWatcher.setFuture(loadResFuture); + + SourcesModel::global()->addSourcesBackend(new AlpineApkSourcesBackend(this)); +} + +// this fills in m_appStreamComponents +void AlpineApkBackend::loadAppStreamComponents() +{ + AppStream::Pool *appStreamPool = new AppStream::Pool(); + appStreamPool->setFlags(AppStream::Pool::FlagReadCollection | + AppStream::Pool::FlagReadMetainfo | + AppStream::Pool::FlagReadDesktopFiles); + appStreamPool->setCacheFlags(AppStream::Pool::CacheFlagUseUser | + AppStream::Pool::CacheFlagUseSystem); + + // hey hey! cool stuff + appStreamPool->addMetadataLocation(AppstreamDataDownloader::getAppStreamCacheDir()); + + if (!appStreamPool->load()) { + qCWarning(LOG_ALPINEAPK) << "backend: Failed to load appstream data:" + << appStreamPool->lastError(); + } else { + m_appStreamComponents = appStreamPool->components(); + qCDebug(LOG_ALPINEAPK) << "backend: loaded AppStream metadata OK:" + << m_appStreamComponents.size() << "components."; + // collect all categories present in appstream metadata + // QSet<QString> collectedCategories; + // for (const AppStream::Component &component : m_appStreamComponents) { + // const QStringList cats = component.categories(); + // for (const QString &cat : cats) { + // collectedCategories.insert(cat); + // } + // } + // for (const QString &cat : collectedCategories) { + // qCDebug(LOG_ALPINEAPK) << " collected category: " << cat; + // m_collectedCategories << cat; + // } + } + delete appStreamPool; +} + +// this uses m_appStreamComponents and m_availablePackages +// to fill in m_resourcesAppstreamData +void AlpineApkBackend::parseAppStreamMetadata() +{ + if (m_availablePackages.size() > 0) { + + for (const QtApk::Package &pkg: qAsConst(m_availablePackages)) { + + // try to find appstream data for this package + AppStream::Component appstreamComponent; + for (const auto& appsC : qAsConst(m_appStreamComponents)) { + // find result which package name is exactly the one we want + if (appsC.packageNames().contains(pkg.name)) { + // workaround for kate (Kate Sessions is found first, but + // package name = "kate" too, bugged metadata?) + if (pkg.name == QStringLiteral("kate")) { + // qCDebug(LOG_ALPINEAPK) << appsC.packageNames() << appsC.id(); + // ^^ ("kate") "org.kde.plasma.katesessions" + if (appsC.id() != QStringLiteral("org.kde.kate")) { + continue; + } + } + appstreamComponent = appsC; + break; // exit for() loop + } + } + + const QString key = pkg.name.toLower(); + m_resourcesAppstreamData.insert(key, appstreamComponent); + } + } +} + +static AbstractResource::Type toDiscoverResourceType(const AppStream::Component &component) +{ + AbstractResource::Type resType = AbstractResource::Type::Technical; // default + // determine resource type here + switch (component.kind()) { + case AppStream::Component::KindDesktopApp: + case AppStream::Component::KindConsoleApp: + case AppStream::Component::KindWebApp: + resType = AbstractResource::Type::Application; + break; + case AppStream::Component::KindAddon: + resType = AbstractResource::Type::Addon; + break; + default: + resType = AbstractResource::Type::Technical; + break; + } + return resType; +} + +void AlpineApkBackend::fillResourcesAndApplyAppStreamData() +{ + // now the tricky part - we need to reapply appstream component metadata to each resource + if (m_availablePackages.size() > 0) { + for (const QtApk::Package &pkg: m_availablePackages) { + const QString key = pkg.name.toLower(); + + AppStream::Component &appsComponent = m_resourcesAppstreamData[key]; + const AbstractResource::Type resType = toDiscoverResourceType(appsComponent); + + AlpineApkResource *res = m_resources.value(key, nullptr); + if (res == nullptr) { + // during first run of this function during initial load + // m_resources hash is empty, so we need to insert new items + res = new AlpineApkResource(pkg, appsComponent, resType, this); + res->setCategoryName(QStringLiteral("alpine_packages")); + res->setOriginSource(QStringLiteral("apk")); + res->setSection(QStringLiteral("dummy")); + m_resources.insert(key, res); + QObject::connect(res, &AlpineApkResource::stateChanged, + this, &AlpineApkBackend::updatesCountChanged); + } else { + // this is not an initial run, just update existing resource + res->setAppStreamData(appsComponent); + } + } + } +} + +void AlpineApkBackend::reloadAppStreamMetadata() +{ + // mark us as "Loading..." + m_fetching = true; + emit fetchingChanged(); + + loadAppStreamComponents(); + parseAppStreamMetadata(); + fillResourcesAndApplyAppStreamData(); + + // mark us as "done loading" + m_fetching = false; + emit fetchingChanged(); +} + +// this function is executed in the background thread +void AlpineApkBackend::loadResources() +{ + Q_EMIT this->passiveMessage(i18n("Loading, please wait...")); + + qCDebug(LOG_ALPINEAPK) << "backend: loading AppStream metadata..."; + + loadAppStreamComponents(); + + qCDebug(LOG_ALPINEAPK) << "backend: populating resources..."; + + if (m_apkdb.open(QtApk::QTAPK_OPENF_READONLY)) { + m_availablePackages = m_apkdb.getAvailablePackages(); + m_installedPackages = m_apkdb.getInstalledPackages(); + m_apkdb.close(); + } + + parseAppStreamMetadata(); + + qCDebug(LOG_ALPINEAPK) << " available" << m_availablePackages.size() + << "packages"; + qCDebug(LOG_ALPINEAPK) << " installed" << m_installedPackages.size() + << "packages"; +} + +void AlpineApkBackend::onLoadResourcesFinished() +{ + qCDebug(LOG_ALPINEAPK) << "backend: appstream data loaded and sorted; fill in resources"; + + fillResourcesAndApplyAppStreamData(); + + // update "installed/not installed" state + if (m_installedPackages.size() > 0) { + for (const QtApk::Package &pkg: m_installedPackages) { + const QString key = pkg.name.toLower(); + if (m_resources.contains(key)) { + m_resources.value(key)->setState(AbstractResource::Installed); + } + } + } + + qCDebug(LOG_ALPINEAPK) << "backend: resources loaded."; + + m_fetching = false; + emit fetchingChanged(); + // ^^ this causes the UI to update "Featured" page and show + // to user that we actually have loaded packages data + + // schedule check for updates 1 sec after we've loaded all resources + QTimer::singleShot(1000, this, &AlpineApkBackend::checkForUpdates); + + // AppStream appdata downloader can download updated metadata files + // in a background thread. When potential download is finished, + // appstream data will be reloaded. + m_appstreamDownloader = new AppstreamDataDownloader(nullptr); + QObject::connect(m_appstreamDownloader, &AppstreamDataDownloader::downloadFinished, + this, &AlpineApkBackend::onAppstreamDataDownloaded, Qt::QueuedConnection); + m_appstreamDownloader->start(); +} + +void AlpineApkBackend::onAppstreamDataDownloaded() +{ + if (m_appstreamDownloader) { + if (m_appstreamDownloader->cacheWasUpdated()) { + // it means we need to reload previously loaded appstream metadata + // m_fetching is true if loadResources() is still executing + // in a background thread + if (!m_fetching) { + qCDebug(LOG_ALPINEAPK) << "AppStream metadata was updated; re-applying it to all resources"; + reloadAppStreamMetadata(); + } else { + qCWarning(LOG_ALPINEAPK) << "AppStream metadata was updated, but cannot apply it: still fetching"; + // it should not really happen, but if it happens, + // then downloaded metadata will be used on the next + // discover launch anyway. + } + } + delete m_appstreamDownloader; + m_appstreamDownloader = nullptr; + } +} + +QVector<Category *> AlpineApkBackend::category() const +{ + static QPair<FilterType, QString> s_apkFlt( + FilterType::CategoryFilter, QLatin1String("alpine_packages")); + + // Display a single root category + // we could add more, but Alpine apk does not have this concept + static Category *s_rootCat = new Category( + i18nc("Root category name", "Alpine Linux packages"), + QStringLiteral("package-x-generic"), // icon + { s_apkFlt }, // orFilters - include packages that match filter + { displayName() }, // pluginName + {}, // subCategories - none + QUrl(), // decoration (what is it?) + false // isAddons + ); + + return { s_rootCat }; + +// static QVector<Category *> s_cats; +// if (s_cats.isEmpty()) { +// // fill only once +// s_cats << s_rootCat; +// for (const QString &scat : m_collectedCategories) { +// Category *cat = new Category( +// scat, // name +// QStringLiteral("package-x-generic"), // icon +// {}, // orFilters +// { displayName() }, // pluginName +// {}, // subcategories +// QUrl(), // decoration +// false // isAddons +// ); +// s_cats << cat; +// } +// } +// return s_cats; + // ^^ causes deep hang in discover in recalculating QML bindings +} + +int AlpineApkBackend::updatesCount() const +{ + return m_updater->updatesCount(); +} + +ResultsStream *AlpineApkBackend::search(const AbstractResourcesBackend::Filters &filter) +{ + QVector<AbstractResource*> ret; + if (!filter.resourceUrl.isEmpty()) { + return findResourceByPackageName(filter.resourceUrl); + } else { + for (AbstractResource *resource: qAsConst(m_resources)) { + // skip technical package types (not apps/addons) + // that are not upgradeable + // (does not work because for now all Alpine packages are "technical" + // if (resource->type() == AbstractResource::Technical + // && filter.state != AbstractResource::Upgradeable) { + // continue; + // } + + // skip not-requested states + if (resource->state() < filter.state) { + continue; + } + + if(resource->name().contains(filter.search, Qt::CaseInsensitive) + || resource->comment().contains(filter.search, Qt::CaseInsensitive)) { + ret += resource; + } + } + } + return new ResultsStream(QStringLiteral("AlpineApkStream"), ret); +} + +ResultsStream *AlpineApkBackend::findResourceByPackageName(const QUrl &searchUrl) +{ +// if (search.isLocalFile()) { +// AlpineApkResource* res = new AlpineApkResource( +// search.fileName(), AbstractResource::Technical, this); +// res->setSize(666); +// res->setState(AbstractResource::None); +// m_resources.insert(res->packageName(), res); +// connect(res, &AlpineApkResource::stateChanged, this, &AlpineApkBackend::updatesCountChanged); +// return new ResultsStream(QStringLiteral("AlpineApkStream-local"), { res }); +// } + + AlpineApkResource *result = nullptr; + + // QUrl("appstream://org.kde.krita.desktop") + // smart workaround for appstream URLs - handle "featured" apps + if (searchUrl.scheme() == QLatin1String("appstream")) { + // remove leading "org.kde." + QString pkgName = searchUrl.host(); + if (pkgName.startsWith(QLatin1String("org.kde."))) { + pkgName = pkgName.mid(8); + } + // remove trailing ".desktop" + if (pkgName.endsWith(QLatin1String(".desktop"))) { + pkgName = pkgName.left(pkgName.length() - 8); + } + // now we can search for "krita" package + result = m_resources.value(pkgName); + } + + // QUrl("apk://krita") + // handle packages from Alpine repos + if (searchUrl.scheme() == QLatin1String("apk")) { + const QString pkgName = searchUrl.host(); + result = m_resources.value(pkgName); + } + + if (!result) { + return new ResultsStream(QStringLiteral("AlpineApkStream"), {}); + } + return new ResultsStream(QStringLiteral("AlpineApkStream"), { result }); +} + +AbstractBackendUpdater *AlpineApkBackend::backendUpdater() const +{ + return m_updater; +} + +AbstractReviewsBackend *AlpineApkBackend::reviewsBackend() const +{ + return m_reviews; +} + +Transaction* AlpineApkBackend::installApplication(AbstractResource *app, const AddonList &addons) +{ + return new AlpineApkTransaction(qobject_cast<AlpineApkResource *>(app), + addons, Transaction::InstallRole); +} + +Transaction* AlpineApkBackend::installApplication(AbstractResource *app) +{ + return new AlpineApkTransaction(qobject_cast<AlpineApkResource *>(app), + Transaction::InstallRole); +} + +Transaction* AlpineApkBackend::removeApplication(AbstractResource *app) +{ + return new AlpineApkTransaction(qobject_cast<AlpineApkResource *>(app), + Transaction::RemoveRole); +} + +int AlpineApkBackend::fetchingUpdatesProgress() const +{ + if (!m_fetching) return 100; + return m_fetchProgress; +} + +void AlpineApkBackend::checkForUpdates() +{ + if (m_fetching) { + qCDebug(LOG_ALPINEAPK) << "backend: checkForUpdates(): already fetching"; + return; + } + + qCDebug(LOG_ALPINEAPK) << "backend: start checkForUpdates()"; + + // safety measure - finish updates check in some time + m_updatesTimeoutTimer->start(); + + // let our updater do the job + m_updater->startCheckForUpdates(); + + // update UI + m_fetching = true; + m_fetchProgress = 0; + emit fetchingChanged(); + emit fetchingUpdatesProgressChanged(); +} + +void AlpineApkBackend::finishCheckForUpdates() +{ + m_updatesTimeoutTimer->stop(); // stop safety timer + // update UI + m_fetching = false; + emit fetchingChanged(); + emit fetchingUpdatesProgressChanged(); +} + +QString AlpineApkBackend::displayName() const +{ + return i18nc("Backend plugin display name", "Alpine APK backend"); +} + +bool AlpineApkBackend::hasApplications() const +{ + return true; +} + +void AlpineApkBackend::setFetchingUpdatesProgress(int percent) +{ + m_fetchProgress = percent; + emit fetchingUpdatesProgressChanged(); +} + +// needed because DISCOVER_BACKEND_PLUGIN(AlpineApkBackend) contains Q_OBJECT +#include "AlpineApkBackend.moc" diff --git a/libdiscover/backends/AlpineApkBackend/AlpineApkBackend.h b/libdiscover/backends/AlpineApkBackend/AlpineApkBackend.h new file mode 100644 index 00000000..07b6b7be --- /dev/null +++ b/libdiscover/backends/AlpineApkBackend/AlpineApkBackend.h @@ -0,0 +1,99 @@ +/*************************************************************************** + * Copyright © 2020 Alexey Min <alexey.min@gmail.com> * + * * + * This program is free software; you can redistribute it and/or * + * modify it under the terms of the GNU General Public License as * + * published by the Free Software Foundation; either version 2 of * + * the License or (at your option) version 3 or any later version * + * accepted by the membership of KDE e.V. (or its successor approved * + * by the membership of KDE e.V.), which shall act as a proxy * + * defined in Section 14 of version 3 of the license. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see <http://www.gnu.org/licenses/>. * + ***************************************************************************/ + +#ifndef AlpineApkBackend_H +#define AlpineApkBackend_H + +#include <resources/AbstractResourcesBackend.h> + +#include <QFutureWatcher> +#include <QVariantList> + +#include <QtApk> + +#include <AppStreamQt/component.h> + +class AlpineApkReviewsBackend; +class AlpineApkUpdater; +class AlpineApkResource; +class AppstreamDataDownloader; +class KJob; +class QTimer; + +class AlpineApkBackend : public AbstractResourcesBackend +{ + Q_OBJECT + +public: + explicit AlpineApkBackend(QObject *parent = nullptr); + + QVector<Category *> category() const override; + int updatesCount() const override; + AbstractBackendUpdater *backendUpdater() const override; + AbstractReviewsBackend *reviewsBackend() const override; + ResultsStream *search(const AbstractResourcesBackend::Filters &filter) override; + ResultsStream *findResourceByPackageName(const QUrl &search); + QHash<QString, AlpineApkResource *> resources() const { return m_resources; } + QHash<QString, AlpineApkResource *> *resourcesPtr() { return &m_resources; } + bool isValid() const override { return true; } // No external file dependencies that could cause runtime errors + + Transaction *installApplication(AbstractResource *app) override; + Transaction *installApplication(AbstractResource *app, const AddonList &addons) override; + Transaction *removeApplication(AbstractResource *app) override; + bool isFetching() const override { return m_fetching; } + int fetchingUpdatesProgress() const override; + void checkForUpdates() override; + QString displayName() const override; + bool hasApplications() const override; + +public Q_SLOTS: + void setFetchingUpdatesProgress(int percent); + +private Q_SLOTS: + void finishCheckForUpdates(); + void loadAppStreamComponents(); + void parseAppStreamMetadata(); + void reloadAppStreamMetadata(); + void fillResourcesAndApplyAppStreamData(); + void loadResources(); + void onLoadResourcesFinished(); + void onAppstreamDataDownloaded(); + +public: + QtApk::Database *apkdb() { return &m_apkdb; } + +private: + QHash<QString, AlpineApkResource *> m_resources; + QHash<QString, AppStream::Component> m_resourcesAppstreamData; + AlpineApkUpdater *m_updater; + AlpineApkReviewsBackend *m_reviews; + QtApk::Database m_apkdb; + QVector<QtApk::Package> m_availablePackages; + QVector<QtApk::Package> m_installedPackages; + bool m_fetching = false; + int m_fetchProgress = 0; + QTimer *m_updatesTimeoutTimer; + QList<AppStream::Component> m_appStreamComponents; + // QVector<QString> m_collectedCategories; + QFutureWatcher<void> m_voidFutureWatcher; + AppstreamDataDownloader *m_appstreamDownloader; +}; + +#endif // AlpineApkBackend_H diff --git a/libdiscover/backends/AlpineApkBackend/AlpineApkResource.cpp b/libdiscover/backends/AlpineApkBackend/AlpineApkResource.cpp new file mode 100644 index 00000000..8f493a49 --- /dev/null +++ b/libdiscover/backends/AlpineApkBackend/AlpineApkResource.cpp @@ -0,0 +1,341 @@ +/*************************************************************************** + * Copyright © 2020 Alexey Min <alexey.min@gmail.com> * + * * + * This program is free software; you can redistribute it and/or * + * modify it under the terms of the GNU General Public License as * + * published by the Free Software Foundation; either version 2 of * + * the License or (at your option) version 3 or any later version * + * accepted by the membership of KDE e.V. (or its successor approved * + * by the membership of KDE e.V.), which shall act as a proxy * + * defined in Section 14 of version 3 of the license. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see <http://www.gnu.org/licenses/>. * + ***************************************************************************/ + +#include "AlpineApkResource.h" +#include "alpineapk_backend_logging.h" // generated by ECM + +#include <AppStreamQt/icon.h> +#include <AppStreamQt/pool.h> +#include <AppStreamQt/release.h> + +#include <QFileInfo> +#include <QIcon> +#include <QProcess> + +// libdiscover +#include "appstream/AppStreamUtils.h" +#include "config-paths.h" +#include "Transaction/AddonList.h" + +AlpineApkResource::AlpineApkResource(const QtApk::Package &apkPkg, + AppStream::Component &component, + AbstractResource::Type typ, + AbstractResourcesBackend *parent) + : AbstractResource(parent) + , m_state(AbstractResource::State::None) + , m_type(typ) + , m_pkg(apkPkg) + , m_appsC(component) +{ +} + +QList<PackageState> AlpineApkResource::addonsInformation() +{ + return m_addons; +} + +QString AlpineApkResource::availableVersion() const +{ + return m_availableVersion; +} + +QStringList AlpineApkResource::categories() +{ + if (hasAppStreamData()) { + return m_appsC.categories(); + } + return { m_category }; +} + +QString AlpineApkResource::comment() +{ + if (hasAppStreamData()) { + return m_appsC.summary(); + } + return m_pkg.description; +} + +int AlpineApkResource::size() +{ + return static_cast<int>(m_pkg.size); +} + +QUrl AlpineApkResource::homepage() +{ + if (hasAppStreamData()) { + return m_appsC.url(AppStream::Component::UrlKindHomepage); + } + return QUrl::fromUserInput(m_pkg.url); +} + +QUrl AlpineApkResource::helpURL() +{ + if (hasAppStreamData()) { + return m_appsC.url(AppStream::Component::UrlKindHelp); + } + return QUrl(); +} + +QUrl AlpineApkResource::bugURL() +{ + if (hasAppStreamData()) { + return m_appsC.url(AppStream::Component::UrlKindBugtracker); + } + return QUrl(); +} + +QUrl AlpineApkResource::donationURL() +{ + if (hasAppStreamData()) { + return m_appsC.url(AppStream::Component::UrlKindDonation); + } + return QUrl(); +} + +///xdg-compatible icon name to represent the resource, url or QIcon +QVariant AlpineApkResource::icon() const +{ + if (hasAppStreamData()) { + const QList<AppStream::Icon> icns = m_appsC.icons(); + if (icns.size() == 0) { + return QStringLiteral("package-x-generic"); + } + QIcon ico; + const AppStream::Icon &appIco = icns.first(); + + switch (appIco.kind()) { + case AppStream::Icon::KindStock: + // we can create icons of this type directly from theme + ico = QIcon::fromTheme(appIco.name()); + break; + case AppStream::Icon::KindLocal: + case AppStream::Icon::KindCached: { + // try from predefined standard Alpine path + const QString appstreamIconsPath = QLatin1String("/usr/share/app-info/icons/"); + const QString path = appstreamIconsPath + appIco.url().path(); + if (QFileInfo::exists(path)) { + ico.addFile(path, appIco.size()); + } else { + const QString altPath = appstreamIconsPath + + QStringLiteral("%1x%2/").arg(appIco.size().width()).arg(appIco.size().height()) + + appIco.url().path(); + if (QFileInfo::exists(altPath)) { + ico.addFile(altPath, appIco.size()); + } + } + } break; + default: break; + } + + // return icon only if we successfully loaded it + if (!ico.isNull()) { + return QVariant::fromValue<QIcon>(ico); + } + + // try to load from icon theme by package name, this is better + // than nothing and works surprisingly well for many packages + ico = QIcon::fromTheme(m_pkg.name); + if (!ico.isNull()) { + return QVariant::fromValue<QIcon>(ico); + } + } + return QStringLiteral("package-x-generic"); +} + +QString AlpineApkResource::installedVersion() const +{ + return m_pkg.version; +} + +QJsonArray AlpineApkResource::licenses() +{ + return { + QJsonObject { + { QStringLiteral("name"), m_pkg.license }, + { QStringLiteral("url"), QStringLiteral("https://spdx.org/license-list") }, + } + }; +} + +QString AlpineApkResource::longDescription() +{ + if (hasAppStreamData()) { + return m_appsC.description(); + } + return m_pkg.description; +} + +QString AlpineApkResource::name() const +{ + if (hasAppStreamData()) { + return m_appsC.name(); + } + return m_pkg.name; +} + +QString AlpineApkResource::origin() const +{ + return m_originSoruce; +} + +QString AlpineApkResource::packageName() const +{ + return m_pkg.name; +} + +QString AlpineApkResource::section() +{ + return m_sectionName; +} + +AbstractResource::State AlpineApkResource::state() +{ + return m_state; +} + +void AlpineApkResource::fetchChangelog() +{ + if (hasAppStreamData()) { + emit changelogFetched(AppStreamUtils::changelogToHtml(m_appsC)); + } +} + +void AlpineApkResource::fetchScreenshots() +{ + if (hasAppStreamData()) { + const QPair<QList<QUrl>, QList<QUrl> > sc = AppStreamUtils::fetchScreenshots(m_appsC); + Q_EMIT screenshotsFetched(sc.first, sc.second); + } +} + +QString AlpineApkResource::appstreamId() const +{ + if (hasAppStreamData()) { + return m_appsC.id(); + } + return QString(); +} + +void AlpineApkResource::setState(AbstractResource::State state) +{ + m_state = state; + emit stateChanged(); +} + +void AlpineApkResource::setCategoryName(const QString &categoryName) +{ + m_category = categoryName; +} + +void AlpineApkResource::setOriginSource(const QString &originSource) +{ + m_originSoruce = originSource; +} + +void AlpineApkResource::setSection(const QString §ionName) +{ + m_sectionName = sectionName; +} + +void AlpineApkResource::setAddons(const AddonList &addons) +{ + const QStringList addonsToInstall = addons.addonsToInstall(); + for (const QString &toInstall : addonsToInstall) { + setAddonInstalled(toInstall, true); + } + const QStringList addonsToRemove = addons.addonsToRemove(); + for (const QString &toRemove : addonsToRemove) { + setAddonInstalled(toRemove, false); + } +} + +void AlpineApkResource::setAddonInstalled(const QString &addon, bool installed) +{ + for(PackageState &elem : m_addons) { + if(elem.name() == addon) { + elem.setInstalled(installed); + } + } +} + +void AlpineApkResource::setAvailableVersion(const QString &av) +{ + m_availableVersion = av; +} + +bool AlpineApkResource::hasAppStreamData() const +{ + return !m_appsC.id().isEmpty(); +} + +void AlpineApkResource::setAppStreamData(const AppStream::Component &component) +{ + m_appsC = component; +} + +bool AlpineApkResource::canExecute() const +{ + if (hasAppStreamData()) { + return (m_appsC.kind() == AppStream::Component::KindDesktopApp && + (m_state == AbstractResource::Installed || m_state == AbstractResource::Upgradeable)); + } + return false; +} + +void AlpineApkResource::invokeApplication() const +{ + const QString desktopFile = QLatin1String("/usr/share/applications/") + appstreamId(); + if (QFile::exists(desktopFile)) { + QProcess::startDetached(QStringLiteral("kstart5"), {QStringLiteral("--service"), desktopFile}); + } +} + +QUrl AlpineApkResource::url() const +{ + if (hasAppStreamData()) { + return QUrl(QStringLiteral("appstream://") + appstreamId()); + } + return QUrl(QLatin1String("apk://") + packageName()); +} + +QString AlpineApkResource::author() const +{ + if (hasAppStreamData()) { + return m_appsC.developerName(); + } + return m_pkg.maintainer; +} + +QString AlpineApkResource::sourceIcon() const +{ + return QStringLiteral("player-time"); +} + +QDate AlpineApkResource::releaseDate() const +{ + if (hasAppStreamData()) { + if (!m_appsC.releases().isEmpty()) { + auto release = m_appsC.releases().constFirst(); + return release.timestamp().date(); + } + } + // just build date is fine, too + return m_pkg.buildTime.date(); +} diff --git a/libdiscover/backends/AlpineApkBackend/AlpineApkResource.h b/libdiscover/backends/AlpineApkBackend/AlpineApkResource.h new file mode 100644 index 00000000..5304a877 --- /dev/null +++ b/libdiscover/backends/AlpineApkBackend/AlpineApkResource.h @@ -0,0 +1,93 @@ +/*************************************************************************** + * Copyright © 2020 Alexey Min <alexey.min@gmail.com> * + * * + * This program is free software; you can redistribute it and/or * + * modify it under the terms of the GNU General Public License as * + * published by the Free Software Foundation; either version 2 of * + * the License or (at your option) version 3 or any later version * + * accepted by the membership of KDE e.V. (or its successor approved * + * by the membership of KDE e.V.), which shall act as a proxy * + * defined in Section 14 of version 3 of the license. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see <http://www.gnu.org/licenses/>. * + ***************************************************************************/ + +#ifndef ALPINEAPKRESOURCE_H +#define ALPINEAPKRESOURCE_H + +#include <resources/AbstractResource.h> +#include <QtApkPackage.h> +#include <AppStreamQt/component.h> + +class AddonList; + +class AlpineApkResource : public AbstractResource +{ + Q_OBJECT + +public: + explicit AlpineApkResource(const QtApk::Package &apkPkg, + AppStream::Component &component, + AbstractResource::Type typ, + AbstractResourcesBackend *parent); + + QList<PackageState> addonsInformation() override; + QString section() override; + QString origin() const override; + QString longDescription() override; + QString availableVersion() const override; + QString installedVersion() const override; + QJsonArray licenses() override; + int size() override; + QUrl homepage() override; + QUrl helpURL() override; + QUrl bugURL() override; + QUrl donationURL() override; + QStringList categories() override; + AbstractResource::State state() override; + QVariant icon() const override; + QString comment() override; + QString name() const override; + QString packageName() const override; + AbstractResource::Type type() const override { return m_type; } + bool canExecute() const override; + void invokeApplication() const override; + void fetchChangelog() override; + void fetchScreenshots() override; + QString appstreamId() const override; + QUrl url() const override; + QString author() const override; + QString sourceIcon() const override; + QDate releaseDate() const override; + + void setState(State state); + void setCategoryName(const QString &categoryName); + void setOriginSource(const QString &originSource); + void setSection(const QString §ionName); + void setAddons(const AddonList &addons); + void setAddonInstalled(const QString &addon, bool installed); + void setAvailableVersion(const QString &av); + void setAppStreamData(const AppStream::Component &component); + +private: + bool hasAppStreamData() const; + +public: + AbstractResource::State m_state; + const AbstractResource::Type m_type; + QtApk::Package m_pkg; + QString m_availableVersion; + QString m_category; + QString m_originSoruce; + QString m_sectionName; + QList<PackageState> m_addons; + AppStream::Component m_appsC; +}; + +#endif // ALPINEAPKRESOURCE_H diff --git a/libdiscover/backends/AlpineApkBackend/AlpineApkReviewsBackend.cpp b/libdiscover/backends/AlpineApkBackend/AlpineApkReviewsBackend.cpp new file mode 100644 index 00000000..fd7ad47f --- /dev/null +++ b/libdiscover/backends/AlpineApkBackend/AlpineApkReviewsBackend.cpp @@ -0,0 +1,35 @@ +/*************************************************************************** + * Copyright © 2020 Alexey Min <alexey.min@gmail.com> * + * * + * This program is free software; you can redistribute it and/or * + * modify it under the terms of the GNU General Public License as * + * published by the Free Software Foundation; either version 2 of * + * the License or (at your option) version 3 or any later version * + * accepted by the membership of KDE e.V. (or its successor approved * + * by the membership of KDE e.V.), which shall act as a proxy * + * defined in Section 14 of version 3 of the license. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see <http://www.gnu.org/licenses/>. * + ***************************************************************************/ + +#include "AlpineApkReviewsBackend.h" +#include "AlpineApkBackend.h" +#include "resources/AbstractResource.h" + +AlpineApkReviewsBackend::AlpineApkReviewsBackend(AlpineApkBackend *parent) + : AbstractReviewsBackend(parent) +{ +} + +void AlpineApkReviewsBackend::fetchReviews(AbstractResource *app, int page) +{ + Q_UNUSED(page) + static const QVector<ReviewPtr> reviews; + Q_EMIT reviewsReady(app, reviews, false); +} diff --git a/libdiscover/backends/AlpineApkBackend/AlpineApkReviewsBackend.h b/libdiscover/backends/AlpineApkBackend/AlpineApkReviewsBackend.h new file mode 100644 index 00000000..435f845b --- /dev/null +++ b/libdiscover/backends/AlpineApkBackend/AlpineApkReviewsBackend.h @@ -0,0 +1,52 @@ +/*************************************************************************** + * Copyright © 2020 Alexey Min <alexey.min@gmail.com> * + * * + * This program is free software; you can redistribute it and/or * + * modify it under the terms of the GNU General Public License as * + * published by the Free Software Foundation; either version 2 of * + * the License or (at your option) version 3 or any later version * + * accepted by the membership of KDE e.V. (or its successor approved * + * by the membership of KDE e.V.), which shall act as a proxy * + * defined in Section 14 of version 3 of the license. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see <http://www.gnu.org/licenses/>. * + ***************************************************************************/ + +#ifndef ALPINEAPKREVIEWSBACKEND_H +#define ALPINEAPKREVIEWSBACKEND_H + +#include "ReviewsBackend/AbstractReviewsBackend.h" + +class AlpineApkBackend; + +class AlpineApkReviewsBackend : public AbstractReviewsBackend +{ + Q_OBJECT + +public: + explicit AlpineApkReviewsBackend(AlpineApkBackend *parent = nullptr); + + QString userName() const override { return QStringLiteral("dummy"); } + void login() override {} + void logout() override {} + void registerAndLogin() override {} + + Rating *ratingForApplication(AbstractResource *) const override { return nullptr; } + bool hasCredentials() const override { return false; } + void deleteReview(Review *) override {} + void fetchReviews(AbstractResource *app, int page = 1) override; + bool isFetching() const override { return false; } + bool isReviewable() const override { return false; } + void submitReview(AbstractResource *, const QString &, const QString &, const QString &) override {} + void flagReview(Review *, const QString&, const QString&) override {} + void submitUsefulness(Review *, bool) override {} + bool isResourceSupported(AbstractResource *) const override { return false; } +}; + +#endif // ALPINEAPKREVIEWSBACKEND_H diff --git a/libdiscover/backends/AlpineApkBackend/AlpineApkSourcesBackend.cpp b/libdiscover/backends/AlpineApkBackend/AlpineApkSourcesBackend.cpp new file mode 100644 index 00000000..a126483a --- /dev/null +++ b/libdiscover/backends/AlpineApkBackend/AlpineApkSourcesBackend.cpp @@ -0,0 +1,195 @@ +/*************************************************************************** + * Copyright © 2020 Alexey Min <alexey.min@gmail.com> * + * * + * This program is free software; you can redistribute it and/or * + * modify it under the terms of the GNU General Public License as * + * published by the Free Software Foundation; either version 2 of * + * the License or (at your option) version 3 or any later version * + * accepted by the membership of KDE e.V. (or its successor approved * + * by the membership of KDE e.V.), which shall act as a proxy * + * defined in Section 14 of version 3 of the license. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see <http://www.gnu.org/licenses/>. * + ***************************************************************************/ + +#include "AlpineApkSourcesBackend.h" +#include "AlpineApkAuthActionFactory.h" +#include "alpineapk_backend_logging.h" // generated by ECM + +#include <QDebug> +#include <QAction> +#include <QVector> + +// KF5 +#include <KAuthExecuteJob> +#include <KLocalizedString> + +// libapk-qt +#include <QtApk> + +AlpineApkSourcesBackend::AlpineApkSourcesBackend(AbstractResourcesBackend *parent) + : AbstractSourcesBackend(parent) + , m_sourcesModel(new QStandardItemModel(this)) + , m_refreshAction(new QAction(QIcon::fromTheme(QStringLiteral("view-refresh")), + QStringLiteral("Reload"), this)) + , m_saveAction(new QAction(QIcon::fromTheme(QStringLiteral("document-save")), + QStringLiteral("Save"), this)) + // ^^ unfortunately QML side ignores icons for custom actions +{ + loadSources(); + QObject::connect(m_refreshAction, &QAction::triggered, + this, &AlpineApkSourcesBackend::loadSources); + QObject::connect(m_saveAction, &QAction::triggered, + this, &AlpineApkSourcesBackend::saveSources); + // track enabling/disabling repo source + QObject::connect(m_sourcesModel, &QStandardItemModel::itemChanged, + this, &AlpineApkSourcesBackend::onItemChanged); +} + +QAbstractItemModel *AlpineApkSourcesBackend::sources() +{ + return m_sourcesModel; +} + +QStandardItem *AlpineApkSourcesBackend::sourceForId(const QString& id) const +{ + for (int i = 0; i < m_sourcesModel->rowCount(); ++i) { + QStandardItem *item = m_sourcesModel->item(i, 0); + if (item->data(AbstractSourcesBackend::IdRole) == id) { + return item; + } + } + return nullptr; +} + +bool AlpineApkSourcesBackend::addSource(const QString &id) +{ + m_repos.append(QtApk::Repository(id, QString(), true)); + fillModelFromRepos(); + return true; +} + +void AlpineApkSourcesBackend::loadSources() +{ + m_repos = QtApk::Database::getRepositories(); + fillModelFromRepos(); +} + +void AlpineApkSourcesBackend::fillModelFromRepos() +{ + m_sourcesModel->clear(); + for (const QtApk::Repository &repo: m_repos) { + if (repo.url.isEmpty()) { + continue; + } + qCDebug(LOG_ALPINEAPK) << "source backend: Adding source:" << repo.url << repo.enabled; + QStandardItem *it = new QStandardItem(repo.url); + it->setData(repo.url, AbstractSourcesBackend::IdRole); + it->setData(repo.comment, Qt::ToolTipRole); + it->setCheckable(true); + it->setCheckState(repo.enabled ? Qt::Checked : Qt::Unchecked); + m_sourcesModel->appendRow(it); + } +} + +void AlpineApkSourcesBackend::saveSources() +{ + const QVariant repoUrls = QVariant::fromValue<QVector<QtApk::Repository>>(m_repos); + + // run with elevated privileges + KAuth::ExecuteJob *reply = ActionFactory::createRepoconfigAction(repoUrls); + if (!reply) return; + + QObject::connect(reply, &KAuth::ExecuteJob::result, this, [this] (KJob *job) { + KAuth::ExecuteJob *reply = static_cast<KAuth::ExecuteJob *>(job); + if (reply->error() != 0) { + const QString errMessage = reply->errorString(); + qCWarning(LOG_ALPINEAPK) << "KAuth helper returned error:" + << reply->error() << errMessage; + if (reply->error() == KAuth::ActionReply::Error::AuthorizationDeniedError) { + Q_EMIT passiveMessage(i18n("Authorization denied")); + } else { + Q_EMIT passiveMessage(i18n("Error: ") + errMessage); + } + } + this->loadSources(); + }); + + reply->start(); +} + +void AlpineApkSourcesBackend::onItemChanged(QStandardItem *item) +{ + // update internal storage vector and reload model from it + // otherwise checks state are not updated in UI + const Qt::CheckState cs = item->checkState(); + const QModelIndex idx = m_sourcesModel->indexFromItem(item); + m_repos[idx.row()].enabled = (cs == Qt::Checked); + fillModelFromRepos(); +} + +bool AlpineApkSourcesBackend::removeSource(const QString &id) +{ + const QStandardItem *it = sourceForId(id); + if (!it) { + qCWarning(LOG_ALPINEAPK) << "source backend: couldn't find " << id; + return false; + } + m_repos.remove(it->row()); + return m_sourcesModel->removeRow(it->row()); +} + +QString AlpineApkSourcesBackend::idDescription() +{ + return i18nc("Adding repo", "Enter Alpine repository URL, for example: " + "http://dl-cdn.alpinelinux.org/alpine/edge/testing/"); +} + +QVariantList AlpineApkSourcesBackend::actions() const +{ + static const QVariantList s_actions { + QVariant::fromValue<QObject *>(m_saveAction), + QVariant::fromValue<QObject *>(m_refreshAction), + }; + return s_actions; +} + +bool AlpineApkSourcesBackend::supportsAdding() const +{ + return true; +} + +bool AlpineApkSourcesBackend::canMoveSources() const +{ + return true; +} + +bool AlpineApkSourcesBackend::moveSource(const QString& sourceId, int delta) +{ + int row = sourceForId(sourceId)->row(); + QList<QStandardItem *> prevRow = m_sourcesModel->takeRow(row); + if (prevRow.isEmpty()) { + return false; + } + + const int destRow = row + delta; + m_sourcesModel->insertRow(destRow, prevRow); + if (destRow == 0 || row == 0) { + Q_EMIT firstSourceIdChanged(); + } + if (destRow == (m_sourcesModel->rowCount() - 1) + || row == (m_sourcesModel->rowCount() - 1)) { + Q_EMIT lastSourceIdChanged(); + } + + // swap also items in internal storage vector + m_repos.swapItemsAt(row, destRow); + + return true; +} diff --git a/libdiscover/backends/AlpineApkBackend/AlpineApkSourcesBackend.h b/libdiscover/backends/AlpineApkBackend/AlpineApkSourcesBackend.h new file mode 100644 index 00000000..eacda22d --- /dev/null +++ b/libdiscover/backends/AlpineApkBackend/AlpineApkSourcesBackend.h @@ -0,0 +1,57 @@ +/*************************************************************************** + * Copyright © 2020 Alexey Min <alexey.min@gmail.com> * + * * + * This program is free software; you can redistribute it and/or * + * modify it under the terms of the GNU General Public License as * + * published by the Free Software Foundation; either version 2 of * + * the License or (at your option) version 3 or any later version * + * accepted by the membership of KDE e.V. (or its successor approved * + * by the membership of KDE e.V.), which shall act as a proxy * + * defined in Section 14 of version 3 of the license. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see <http://www.gnu.org/licenses/>. * + ***************************************************************************/ + +#ifndef ALPINEAPKSOURCESBACKEND_H +#define ALPINEAPKSOURCESBACKEND_H + +#include <resources/AbstractSourcesBackend.h> +#include <QStandardItemModel> + +#include <QtApkRepository.h> + +class AlpineApkSourcesBackend : public AbstractSourcesBackend +{ +public: + explicit AlpineApkSourcesBackend(AbstractResourcesBackend *parent); + + QAbstractItemModel *sources() override; + bool addSource(const QString &id) override; + bool removeSource(const QString &id) override; + QString idDescription() override; + QVariantList actions() const override; + bool supportsAdding() const override; + bool canMoveSources() const override; + bool moveSource(const QString &sourceId, int delta) override; + +private: + QStandardItem *sourceForId(const QString &id) const; + bool addSourceFull(const QString &id, const QString &comment, bool enabled); + void loadSources(); + void saveSources(); + void fillModelFromRepos(); + void onItemChanged(QStandardItem* item); + + QStandardItemModel *m_sourcesModel = nullptr; + QAction *m_refreshAction = nullptr; + QAction *m_saveAction = nullptr; + QVector<QtApk::Repository> m_repos; +}; + +#endif // ALPINEAPKSOURCESBACKEND_H diff --git a/libdiscover/backends/AlpineApkBackend/AlpineApkTransaction.cpp b/libdiscover/backends/AlpineApkBackend/AlpineApkTransaction.cpp new file mode 100644 index 00000000..26bf1bd0 --- /dev/null +++ b/libdiscover/backends/AlpineApkBackend/AlpineApkTransaction.cpp @@ -0,0 +1,141 @@ +/*************************************************************************** + * Copyright © 2020 Alexey Min <alexey.min@gmail.com> * + * * + * This program is free software; you can redistribute it and/or * + * modify it under the terms of the GNU General Public License as * + * published by the Free Software Foundation; either version 2 of * + * the License or (at your option) version 3 or any later version * + * accepted by the membership of KDE e.V. (or its successor approved * + * by the membership of KDE e.V.), which shall act as a proxy * + * defined in Section 14 of version 3 of the license. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see <http://www.gnu.org/licenses/>. * + ***************************************************************************/ + +#include "AlpineApkTransaction.h" +#include "AlpineApkBackend.h" +#include "AlpineApkResource.h" +#include "AlpineApkAuthActionFactory.h" +#include "alpineapk_backend_logging.h" // generated by ECM + +// Qt +#include <QDebug> +#include <QTimer> + +// KF5 +#include <KAuthExecuteJob> +#include <KLocalizedString> + +AlpineApkTransaction::AlpineApkTransaction(AlpineApkResource *res, Role role) + : AlpineApkTransaction(res, {}, role) +{ +} + +AlpineApkTransaction::AlpineApkTransaction(AlpineApkResource *res, const AddonList &addons, Transaction::Role role) + : Transaction(res->backend(), res, role, addons) + , m_resource(res) + , m_backend(static_cast<AlpineApkBackend *>(res->backend())) +{ + setCancellable(false); + setStatus(QueuedStatus); + // seems like Discover's transactions are supposed to start + // automatically; no dedicated method to start transaction? + startTransaction(); +} + +void AlpineApkTransaction::proceed() +{ + startTransaction(); +} + +void AlpineApkTransaction::cancel() +{ + setStatus(CancelledStatus); +} + +void AlpineApkTransaction::startTransaction() +{ + KAuth::ExecuteJob *reply = nullptr; + switch(role()) { + case InstallRole: + reply = ActionFactory::createAddAction(m_resource->m_pkg.name); + break; + case RemoveRole: + reply = ActionFactory::createDelAction(m_resource->m_pkg.name); + break; + case ChangeAddonsRole: + qCWarning(LOG_ALPINEAPK) << "Addons are not supported by Alpine APK Backend!"; + break; + } + + if (!reply) { + return; + } + + // get result of this job + QObject::connect(reply, &KAuth::ExecuteJob::result, this, [this](KJob *job) { + KAuth::ExecuteJob *reply = static_cast<KAuth::ExecuteJob *>(job); + const QVariantMap &replyData = reply->data(); + if (reply->error() == 0) { + finishTransactionOK(); + } else { + QString message = replyData.value(QLatin1String("errorString"), + reply->errorString()).toString(); + if (reply->error() == KAuth::ActionReply::Error::AuthorizationDeniedError) { + message = i18n("Error: Authorization denied"); + } + finishTransactionWithError(message); + } + }); + + // get progress reports for this job + QObject::connect(reply, QOverload<KJob*, unsigned long>::of(&KAuth::ExecuteJob::percent), this, + [this](KJob *job, unsigned long percent) { + Q_UNUSED(job) + if (percent >= 40 && role() == InstallRole) { + setStatus(CommittingStatus); + } + setProgress(static_cast<int>(percent)); + }); + + setProgress(0); + if (role() == InstallRole) { + setStatus(DownloadingStatus); + } else { + setStatus(CommittingStatus); + } + + reply->start(); +} + +void AlpineApkTransaction::finishTransactionOK() +{ + AbstractResource::State newState; + switch(role()) { + case InstallRole: + case ChangeAddonsRole: + newState = AbstractResource::Installed; + break; + case RemoveRole: + newState = AbstractResource::None; + break; + } + m_resource->setAddons(addons()); + m_resource->setState(newState); + setStatus(DoneStatus); + deleteLater(); +} + +void AlpineApkTransaction::finishTransactionWithError(const QString &errMsg) +{ + qCWarning(LOG_ALPINEAPK) << "Transaction finished with error:" << errMsg; + Q_EMIT passiveMessage(i18n("Error") + QStringLiteral(":\n") + errMsg); + setStatus(DoneWithErrorStatus); + deleteLater(); +} diff --git a/libdiscover/backends/AlpineApkBackend/AlpineApkTransaction.h b/libdiscover/backends/AlpineApkBackend/AlpineApkTransaction.h new file mode 100644 index 00000000..cab1f6b9 --- /dev/null +++ b/libdiscover/backends/AlpineApkBackend/AlpineApkTransaction.h @@ -0,0 +1,49 @@ +/*************************************************************************** + * Copyright © 2020 Alexey Min <alexey.min@gmail.com> * + * * + * This program is free software; you can redistribute it and/or * + * modify it under the terms of the GNU General Public License as * + * published by the Free Software Foundation; either version 2 of * + * the License or (at your option) version 3 or any later version * + * accepted by the membership of KDE e.V. (or its successor approved * + * by the membership of KDE e.V.), which shall act as a proxy * + * defined in Section 14 of version 3 of the license. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see <http://www.gnu.org/licenses/>. * + ***************************************************************************/ + +#ifndef ALPINEAPKTRANSACTION_H +#define ALPINEAPKTRANSACTION_H + +#include <Transaction/Transaction.h> + +class AlpineApkBackend; +class AlpineApkResource; + +class AlpineApkTransaction : public Transaction +{ +Q_OBJECT +public: + AlpineApkTransaction(AlpineApkResource *res, Role role); + AlpineApkTransaction(AlpineApkResource *res, const AddonList &list, Role role); + + void cancel() override; + void proceed() override; + +private Q_SLOTS: + void startTransaction(); + void finishTransactionOK(); + void finishTransactionWithError(const QString &errMsg); + +private: + AlpineApkResource *m_resource; + AlpineApkBackend *m_backend; +}; + +#endif // ALPINEAPKTRANSACTION_H diff --git a/libdiscover/backends/AlpineApkBackend/AlpineApkUpdater.cpp b/libdiscover/backends/AlpineApkBackend/AlpineApkUpdater.cpp new file mode 100644 index 00000000..14df959c --- /dev/null +++ b/libdiscover/backends/AlpineApkBackend/AlpineApkUpdater.cpp @@ -0,0 +1,295 @@ +/*************************************************************************** + * Copyright © 2020 Alexey Min <alexey.min@gmail.com> * + * * + * This program is free software; you can redistribute it and/or * + * modify it under the terms of the GNU General Public License as * + * published by the Free Software Foundation; either version 2 of * + * the License or (at your option) version 3 or any later version * + * accepted by the membership of KDE e.V. (or its successor approved * + * by the membership of KDE e.V.), which shall act as a proxy * + * defined in Section 14 of version 3 of the license. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see <http://www.gnu.org/licenses/>. * + ***************************************************************************/ + +#include "AlpineApkUpdater.h" +#include "AlpineApkResource.h" +#include "AlpineApkBackend.h" +#include "AlpineApkAuthActionFactory.h" +#include "alpineapk_backend_logging.h" +#include "utils.h" + +#include <KAuthExecuteJob> +#include <KLocalizedString> + +#include <QtApk> + + +AlpineApkUpdater::AlpineApkUpdater(AbstractResourcesBackend *parent) + : AbstractBackendUpdater(parent) + , m_backend(static_cast<AlpineApkBackend *>(parent)) +{ + // +} + +void AlpineApkUpdater::prepare() +{ + QtApk::Database *db = m_backend->apkdb(); + + if (db->isOpen()) { + return; + } + + // readonly is fine for a simulation of upgrade + if (!db->open(QtApk::QTAPK_OPENF_READONLY)) { + emit passiveMessage(i18n("Failed to open APK database!")); + return; + } + + if (!db->upgrade(QtApk::QTAPK_UPGRADE_SIMULATE, &m_upgradeable)) { + emit passiveMessage(i18n("Failed to get a list of packages to upgrade!")); + db->close(); + return; + } + // close DB ASAP + db->close(); + + m_updatesCount = m_upgradeable.changes().size(); + qCDebug(LOG_ALPINEAPK) << "updater: prepare: updates count" << m_updatesCount; + + m_allUpdateable.clear(); + m_markedToUpdate.clear(); + QHash<QString, AlpineApkResource *> *resources = m_backend->resourcesPtr(); + for (const QtApk::ChangesetItem &it : qAsConst(m_upgradeable.changes())) { + const QtApk::Package &oldPkg = it.oldPackage; + const QString newVersion = it.newPackage.version; + AlpineApkResource *res = resources->value(oldPkg.name); + if (res) { + res->setAvailableVersion(newVersion); + m_allUpdateable.insert(res); + m_markedToUpdate.insert(res); + } + } + + // emitting this signal here leads to infinite recursion + // emit updatesCountChanged(m_updatesCount); +} + +bool AlpineApkUpdater::hasUpdates() const +{ + return (m_updatesCount > 0); +} + +qreal AlpineApkUpdater::progress() const +{ + return m_upgradeProgress; +} + +void AlpineApkUpdater::removeResources(const QList<AbstractResource *> &apps) +{ + const QSet<AbstractResource *> checkSet = kToSet(apps); + m_markedToUpdate -= checkSet; +} + +void AlpineApkUpdater::addResources(const QList<AbstractResource *> &apps) +{ + const QSet<AbstractResource *> checkSet = kToSet(apps); + m_markedToUpdate += checkSet; +} + +QList<AbstractResource *> AlpineApkUpdater::toUpdate() const +{ + return m_allUpdateable.values(); +} + +QDateTime AlpineApkUpdater::lastUpdate() const +{ + qCDebug(LOG_ALPINEAPK) << Q_FUNC_INFO; + return QDateTime(); +} + +bool AlpineApkUpdater::isCancelable() const +{ + qCDebug(LOG_ALPINEAPK) << Q_FUNC_INFO; + return false; +} + +bool AlpineApkUpdater::isProgressing() const +{ + qCDebug(LOG_ALPINEAPK) << Q_FUNC_INFO << m_progressing; + return m_progressing; +} + +bool AlpineApkUpdater::isMarked(AbstractResource *res) const +{ + return m_markedToUpdate.contains(res); +} + +void AlpineApkUpdater::fetchChangelog() const +{ + qCDebug(LOG_ALPINEAPK) << Q_FUNC_INFO; +} + +double AlpineApkUpdater::updateSize() const +{ + double sum = 0.0; + for (AbstractResource *res : m_markedToUpdate) { + sum += res->size(); + } + return sum; +} + +quint64 AlpineApkUpdater::downloadSpeed() const +{ + qCDebug(LOG_ALPINEAPK) << Q_FUNC_INFO; + return 0; +} + +void AlpineApkUpdater::cancel() +{ + qCDebug(LOG_ALPINEAPK) << Q_FUNC_INFO; +} + +void AlpineApkUpdater::start() +{ + qCDebug(LOG_ALPINEAPK) << Q_FUNC_INFO; + + // run upgrade with elevated privileges + KAuth::ExecuteJob *reply = ActionFactory::createUpgradeAction(); + if (!reply) return; + + QObject::connect(reply, &KAuth::ExecuteJob::result, + this, &AlpineApkUpdater::handleKAuthUpgradeHelperReply); + // qOverload is needed because of conflict with getter named percent() + QObject::connect(reply, QOverload<KJob *, unsigned long>::of(&KAuth::ExecuteJob::percent), + this, &AlpineApkUpdater::handleKAuthUpgradeHelperProgress); + + m_progressing = true; + m_upgradeProgress = 0.0; + Q_EMIT progressingChanged(m_progressing); + + reply->start(); +} + +void AlpineApkUpdater::proceed() +{ + qCDebug(LOG_ALPINEAPK) << Q_FUNC_INFO; +} + +int AlpineApkUpdater::updatesCount() +{ + return m_updatesCount; +} + +void AlpineApkUpdater::startCheckForUpdates() +{ + QtApk::Database *db = m_backend->apkdb(); + + // run updates check with elevated privileges to access + // system package manager files + KAuth::ExecuteJob *reply = ActionFactory::createUpdateAction(db->fakeRoot()); + if (!reply) return; + QObject::connect(reply, &KAuth::ExecuteJob::result, + this, &AlpineApkUpdater::handleKAuthUpdateHelperReply); + // qOverload is needed because of conflict with getter named percent() + QObject::connect(reply, QOverload<KJob *, unsigned long>::of(&KAuth::ExecuteJob::percent), + this, &AlpineApkUpdater::handleKAuthUpdateHelperProgress); + + m_progressing = true; + Q_EMIT progressingChanged(m_progressing); + Q_EMIT progressChanged(0); + + reply->start(); +} + +void AlpineApkUpdater::handleKAuthUpdateHelperReply(KJob *job) +{ + KAuth::ExecuteJob *reply = static_cast<KAuth::ExecuteJob *>(job); + const QVariantMap &replyData = reply->data(); + if (reply->error() == 0) { + m_updatesCount = replyData.value(QLatin1String("updatesCount")).toInt(); + qCDebug(LOG_ALPINEAPK) << "KAuth helper update reply received, updatesCount:" << m_updatesCount; + Q_EMIT updatesCountChanged(m_updatesCount); + } else { + handleKAuthHelperError(reply, replyData); + } + + m_progressing = false; + Q_EMIT progressingChanged(m_progressing); + + // we are not in the state "Fetching updates" now, update UI + Q_EMIT checkForUpdatesFinished(); +} + +void AlpineApkUpdater::handleKAuthUpdateHelperProgress(KJob *job, unsigned long percent) +{ + Q_UNUSED(job) + qCDebug(LOG_ALPINEAPK) << " fetch updates progress: " << percent; + Q_EMIT fetchingUpdatesProgressChanged(percent); + Q_EMIT progressChanged(static_cast<qreal>(percent)); +} + +void AlpineApkUpdater::handleKAuthUpgradeHelperProgress(KJob *job, unsigned long percent) +{ + Q_UNUSED(job) + qCDebug(LOG_ALPINEAPK) << " upgrade progress: " << percent; + qreal newProgress = static_cast<qreal>(percent); + if (newProgress != m_upgradeProgress) { + m_upgradeProgress = newProgress; + Q_EMIT progressChanged(m_upgradeProgress); + } +} + +void AlpineApkUpdater::handleKAuthUpgradeHelperReply(KJob *job) +{ + KAuth::ExecuteJob *reply = static_cast<KAuth::ExecuteJob *>(job); + const QVariantMap &replyData = reply->data(); + if (reply->error() == 0) { + QVariant pkgsV = replyData.value(QLatin1String("changes")); + bool onlySimulate = replyData.value(QLatin1String("onlySimulate"), false).toBool(); + if (onlySimulate) { + qCDebug(LOG_ALPINEAPK) << "KAuth helper upgrade reply received, simulation mode"; + QVector<QtApk::Package> pkgVector = pkgsV.value<QVector<QtApk::Package>>(); + qCDebug(LOG_ALPINEAPK) << " num changes:" << pkgVector.size(); + for (const QtApk::Package &pkg : pkgVector) { + qCDebug(LOG_ALPINEAPK) << " " << pkg.name << pkg.version; + } + } + } else { + handleKAuthHelperError(reply, replyData); + } + + m_progressing = false; + Q_EMIT progressingChanged(m_progressing); +} + +void AlpineApkUpdater::handleKAuthHelperError( + KAuth::ExecuteJob *reply, + const QVariantMap &replyData) +{ + // error message should be received as part of JSON reply from helper + QString message = replyData.value(QLatin1String("errorString"), + reply->errorString()).toString(); + if (reply->error() == KAuth::ActionReply::Error::AuthorizationDeniedError) { + qCWarning(LOG_ALPINEAPK) << "updater: KAuth helper returned AuthorizationDeniedError"; + Q_EMIT passiveMessage(i18n("Authorization denied")); + } else { + // if received error message is empty, try other ways to get error text for user + // there are multiple ways to get error messages in kauth/kjob + if (message.isEmpty()) { + message = reply->errorString(); + if (message.isEmpty()) { + message = reply->errorText(); + } + } + qCDebug(LOG_ALPINEAPK) << "updater: KAuth helper returned error:" << message << reply->error(); + Q_EMIT passiveMessage(i18n("Error") + QStringLiteral(":\n") + message); + } +} + diff --git a/libdiscover/backends/AlpineApkBackend/AlpineApkUpdater.h b/libdiscover/backends/AlpineApkBackend/AlpineApkUpdater.h new file mode 100644 index 00000000..6ca3ce07 --- /dev/null +++ b/libdiscover/backends/AlpineApkBackend/AlpineApkUpdater.h @@ -0,0 +1,197 @@ +/*************************************************************************** + * Copyright © 2020 Alexey Min <alexey.min@gmail.com> * + * * + * This program is free software; you can redistribute it and/or * + * modify it under the terms of the GNU General Public License as * + * published by the Free Software Foundation; either version 2 of * + * the License or (at your option) version 3 or any later version * + * accepted by the membership of KDE e.V. (or its successor approved * + * by the membership of KDE e.V.), which shall act as a proxy * + * defined in Section 14 of version 3 of the license. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see <http://www.gnu.org/licenses/>. * + ***************************************************************************/ + +#ifndef ALPINEAPKUPDATER_H +#define ALPINEAPKUPDATER_H + +#include "resources/AbstractBackendUpdater.h" +#include "resources/AbstractResourcesBackend.h" + +#include <QSet> +#include <QDateTime> +#include <QTimer> +#include <QVariant> +#include <QMap> +#include <QVector> + +#include <QtApkChangeset.h> + +class AbstractResourcesBackend; +class AlpineApkBackend; +class KJob; +namespace KAuth { + class ExecuteJob; +} + +class AlpineApkUpdater : public AbstractBackendUpdater +{ + Q_OBJECT + Q_PROPERTY(int updatesCount READ updatesCount NOTIFY updatesCountChanged) + +public: + explicit AlpineApkUpdater(AbstractResourcesBackend *parent = nullptr); + + /** + * This method is called, when Muon switches to the updates view. + * Here the backend should mark all upgradeable packages as to be upgraded. + */ + void prepare() override; + + /** + * @returns true if the backend contains packages which can be updated + */ + bool hasUpdates() const override; + /** + * @returns the progress of the update in percent + */ + qreal progress() const override; + + /** + * This method is used to remove resources from the list of packages + * marked to be upgraded. It will potentially be called before \start. + */ + void removeResources(const QList<AbstractResource*> &apps) override; + + /** + * This method is used to add resource to the list of packages marked to be upgraded. + * It will potentially be called before \start. + */ + void addResources(const QList<AbstractResource*> &apps) override; + + /** + * @returns the list of updateable resources in the system + */ + QList<AbstractResource *> toUpdate() const override; + + /** + * @returns the QDateTime when the last update happened + */ + QDateTime lastUpdate() const override; + + /** + * @returns whether the updater can currently be canceled or not + * @see cancelableChanged + */ + bool isCancelable() const override; + + /** + * @returns whether the updater is currently running or not + * this property decides, if there will be progress reporting in the GUI. + * This has to stay true during the whole transaction! + * @see progressingChanged + */ + bool isProgressing() const override; + + /** + * @returns whether @p res is marked for update + */ + bool isMarked(AbstractResource* res) const override; + + void fetchChangelog() const override; + + /** + * @returns the size of all the packages set to update combined + */ + double updateSize() const override; + + /** + * @returns the speed at which we are downloading + */ + quint64 downloadSpeed() const override; + +public Q_SLOTS: + /** + * If \isCancelable is true during the transaction, this method has + * to be implemented and will potentially be called when the user + * wants to cancel the update. + */ + void cancel() override; + + /** + * This method starts the update. All packages which are in \toUpdate + * are going to be updated. + * + * From this moment on the AbstractBackendUpdater should continuously update + * the other methods to show its progress. + * + * @see progress + * @see progressChanged + * @see isProgressing + * @see progressingChanged + */ + void start() override; + + /** + * Answers a proceed request + */ + void proceed() override; + +Q_SIGNALS: + void checkForUpdatesFinished(); + void updatesCountChanged(int updatesCount); + void fetchingUpdatesProgressChanged(int progress); + //void cancelTransaction(); + +public Q_SLOTS: + int updatesCount(); + void startCheckForUpdates(); + + // KAuth handler slots + // update + void handleKAuthUpdateHelperReply(KJob *job); + void handleKAuthUpdateHelperProgress(KJob *job, unsigned long percent); + // upgrade + void handleKAuthUpgradeHelperReply(KJob *job); + void handleKAuthUpgradeHelperProgress(KJob *job, unsigned long percent); + + //void transactionRemoved(Transaction* t); + //void cleanup(); + +public: + QVector<QtApk::ChangesetItem> &changes() { return m_upgradeable.changes(); } + const QVector<QtApk::ChangesetItem> &changes() const { return m_upgradeable.changes(); } + +protected: + void handleKAuthHelperError(KAuth::ExecuteJob *reply, const QVariantMap &replyData); + +private: + AlpineApkBackend *const m_backend; + int m_updatesCount = 0; + QtApk::Changeset m_upgradeable; + QSet<AbstractResource *> m_allUpdateable; + QSet<AbstractResource *> m_markedToUpdate; +// void resourcesChanged(AbstractResource* res, const QVector<QByteArray>& props); +// void refreshUpdateable(); +// void transactionAdded(Transaction* newTransaction); +// void transactionProgressChanged(); +// void refreshProgress(); +// QVector<Transaction*> transactions() const; + +// QSet<AbstractResource*> m_upgradeable; +// QSet<AbstractResource*> m_pendingResources; + bool m_progressing = false; + qreal m_upgradeProgress = 0.0; +// QDateTime m_lastUpdate; +// QTimer m_timer; +// bool m_canCancel = false; +}; + + +#endif // ALPINEAPKUPDATER_H diff --git a/libdiscover/backends/AlpineApkBackend/AppstreamDataDownloader.cpp b/libdiscover/backends/AlpineApkBackend/AppstreamDataDownloader.cpp new file mode 100644 index 00000000..16587994 --- /dev/null +++ b/libdiscover/backends/AlpineApkBackend/AppstreamDataDownloader.cpp @@ -0,0 +1,303 @@ +/*************************************************************************** + * Copyright © 2020 Alexey Min <alexey.min@gmail.com> * + * * + * This program is free software; you can redistribute it and/or * + * modify it under the terms of the GNU General Public License as * + * published by the Free Software Foundation; either version 2 of * + * the License or (at your option) version 3 or any later version * + * accepted by the membership of KDE e.V. (or its successor approved * + * by the membership of KDE e.V.), which shall act as a proxy * + * defined in Section 14 of version 3 of the license. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see <http://www.gnu.org/licenses/>. * + ***************************************************************************/ + +#include <QDateTime> +#include <QDir> +#include <QEventLoop> +#include <QFile> +#include <QFileInfo> +#include <QJsonArray> +#include <QJsonDocument> +#include <QJsonParseError> +#include <QJsonObject> +#include <QNetworkAccessManager> +#include <QNetworkReply> +#include <QNetworkRequest> +#include <QStandardPaths> +#include <QThreadPool> +#include <QUrl> +#include <QtConcurrentRun> + +#include "AppstreamDataDownloader.h" +#include "alpineapk_backend_logging.h" + +namespace DiscoverVersion { +// contains static QLatin1String version("5.20.5"); definition +// autogenerated from top CMakeLists.txt +#include "../../../DiscoverVersion.h" +} + +#ifdef ALPINE_LINUX_BUILD +/** + * @brief ApkAppstreamDataDownloader::getApkArch + * Reads current configured system apk architecture + * from "/etc/apk/arch" file. + * @return "x86_64" / "armhf" / "armv7" / "aarch64" and so on + */ +static QString getApkArch() +{ + static QString s_retArch; + if (!s_retArch.isEmpty()) { + return s_retArch; + } + QFile archFile(QStringLiteral("/etc/apk/arch")); + if (!archFile.open(QIODevice::ReadOnly)) { + // TODO: we could try to guess at compile time by checking presence of + // defines like __x86_64__ (check with: "gcc -march=native -dM -E - </dev/null") + // but that seems like outside of scope of this small function + return s_retArch; + } + s_retArch = QString::fromUtf8(archFile.readAll()).trimmed(); + archFile.close(); + return s_retArch; +} + +static inline void replaceCARCH(QString &in, const QString &arch) +{ + // URLs in JSON look like this: + // https://appstream.alpinelinux.org/data/edge/main/Components-main-@CARCH@.xml.gz + // we need to replace @CARCH@ with a real arch value + in.replace(QLatin1String("@CARCH@"), arch); +} +#endif + +QString AppstreamDataDownloader::getAppStreamCacheDir() +{ + QString cachePath = QStandardPaths::writableLocation(QStandardPaths::CacheLocation); + // ^^ "~/.cache/discover" + cachePath += QStringLiteral("/appstream_data"); + QDir cacheDir(cachePath); + if (!cacheDir.exists()) { + const bool ok = cacheDir.mkpath(QStringLiteral(".")); + if (ok) { + qCDebug(LOG_ALPINEAPK) << "Created appstream data cache dir:" << cachePath; + } else { + qCWarning(LOG_ALPINEAPK) << "Failed to create appstream data cache dir:" << cachePath; + } + } + return cachePath; +} + +AppstreamDataDownloader::AppstreamDataDownloader(QObject *parent) + : QObject(parent) +{ +} + +void AppstreamDataDownloader::setCacheExpirePeriodSecs(qint64 secs) +{ + m_cacheExpireSeconds = secs; +} + +void AppstreamDataDownloader::loadUrlsJson(const QString &jsonPath) +{ + const QString jsonBaseName = QFileInfo(jsonPath).baseName(); + QFile jsonFile(jsonPath); + if (!jsonFile.open(QIODevice::ReadOnly)) { + qCWarning(LOG_ALPINEAPK) << "Failed to open JSON:" << jsonPath << "for reading!"; + Q_EMIT downloadFinished(); + return; + } + const QByteArray jsonBa = jsonFile.readAll(); + jsonFile.close(); + + QJsonParseError jsonError; + const QJsonDocument jDoc = QJsonDocument::fromJson(jsonBa, &jsonError); + if (jDoc.isNull()) { + qCWarning(LOG_ALPINEAPK) << "Failed to parse JSON:" << jsonPath << "!"; + qCWarning(LOG_ALPINEAPK) << jsonError.errorString(); + Q_EMIT downloadFinished(); + return; + } + // JSON structure: + // { + // "urls": [ + // "https://...", "https://...", "https://..." + // ] + // } + const QJsonObject rootObj = jDoc.object(); + const QJsonArray urls = rootObj.value(QLatin1String("urls")).toArray(); + for (const QJsonValue &urlValue : urls) { + QString url = urlValue.toString(); +#ifdef ALPINE_LINUX_BUILD + replaceCARCH(url, getApkArch()); +#endif + m_urls.append(url); + // prefixes are used to avoid name clashes with similar URL paths + // from other JSON files. json file basename is used as prefix + m_urlPrefixes.insert(url, jsonBaseName); + } +} + +QString AppstreamDataDownloader::getLocalFileSavePath(const QUrl &urlToDownload) +{ + // we are adding a prefix here to local file name to avoid possible + // file name clashes with files from other JSONs + const QString urlPrefix = m_urlPrefixes.value(urlToDownload.toString(), QString()); + const QFileInfo urlInfo(urlToDownload.path()); + const QString localCacheFile = AppstreamDataDownloader::getAppStreamCacheDir() + + QDir::separator() + + urlPrefix + QLatin1Char('_') + + urlInfo.fileName(); + // aka "~/.cache/discover/appstream_data/urlPrefix_fileName.xml.gz" + return localCacheFile; +} + +void AppstreamDataDownloader::start() +{ + m_urls.clear(); + // load json files with appdata URLs configuration + const QString path = QStandardPaths::locate( + QStandardPaths::GenericDataLocation, + QLatin1String("libdiscover/external-appstream-urls"), + QStandardPaths::LocateDirectory); + if (path.isEmpty()) { + qCWarning(LOG_ALPINEAPK) << "external-appstream-urls directory does not exist."; + return; + } + + QDir jsonsDir(path); + // search for all JSON files in that directory and load each one + QFileInfoList fileList = jsonsDir.entryInfoList({QStringLiteral("*.json")}, QDir::Files); + for (const QFileInfo &fi : fileList) { + qCDebug(LOG_ALPINEAPK) << " reading URLs JSON: " << fi.absoluteFilePath(); + loadUrlsJson(fi.absoluteFilePath()); + } + + qCDebug(LOG_ALPINEAPK) << "appstream_downloader: urls:" << m_urls; + + // check if download is needed at all, maybe all files are already up to date? + + getAppStreamCacheDir(); // can create a cache dir if not exists + + const QDateTime dtNow = QDateTime::currentDateTime(); + m_urlsToDownload.clear(); + for (const QString &url : m_urls) { + const QUrl urlToDownload(url, QUrl::TolerantMode); + const QString localCacheFile = getLocalFileSavePath(urlToDownload); + const QFileInfo localFi(localCacheFile); + if (localFi.exists()) { + int modifiedSecsAgo = localFi.lastModified().secsTo(dtNow); + if (modifiedSecsAgo >= m_cacheExpireSeconds) { + m_urlsToDownload.append(url); + } + qCDebug(LOG_ALPINEAPK) << " appstream metadata file: " << localFi.fileName() + << " was last modified " << modifiedSecsAgo << " seconds ago"; + } else { + // locally downloaded file does not even exist, we need to download it + m_urlsToDownload.append(url); + qCDebug(LOG_ALPINEAPK) << " appstream metadata file: " << localFi.fileName() + << " does not exist, queued for downloading"; + } + } + + if (m_urlsToDownload.size() > 0) { + // some files are outdated; download is needed + qCDebug(LOG_ALPINEAPK) << "appstream_downloader: We will need to download " + << m_urlsToDownload.size() << " file(s)"; + + // start downloader in a background thread + QFuture<void> downloaderFuture = QtConcurrent::run( + QThreadPool::globalInstance(), this, &AppstreamDataDownloader::download); + + // directly connect signal to signal + QObject::connect(&m_voidFutureWatcher, &QFutureWatcher<void>::finished, + this, &AppstreamDataDownloader::downloadFinished); + m_voidFutureWatcher.setFuture(downloaderFuture); + } else { + // no need to download anything + qCDebug(LOG_ALPINEAPK) << "appstream_downloader: All appstream data files " + "are up to date, not downloading anything"; + Q_EMIT downloadFinished(); + return; + } +} + +// this function runs in background thread +void AppstreamDataDownloader::download() +{ + QNetworkAccessManager nam; + QList<QNetworkReply *> replies; + QEventLoop loop; + + // start a HTTP GET request for each URL + const QStringList urls = m_urlsToDownload; + const QString discoverVersion(QStringLiteral("plasma-discover %1").arg(DiscoverVersion::version)); + for (const QString &url : urls) { + const QUrl uurl(url, QUrl::TolerantMode); + QNetworkRequest req(uurl); + req.setHeader(QNetworkRequest::UserAgentHeader, discoverVersion); + replies.push_back(nam.get(req)); + } + + for (QNetworkReply *rep : replies) { + // lambda that stops the loop when all requests have finished + // intentionaly use contextless lambda, it is not called otherwise + QObject::connect(rep, &QNetworkReply::finished, [&loop, &replies, rep] () { + const int numReplies = replies.size(); + int numFinished = 0; + for (QNetworkReply *arep : replies) { + if (arep->isFinished()) { + numFinished++; + } + } + if (numFinished >= numReplies) { + loop.quit(); + } + qCDebug(LOG_ALPINEAPK).nospace() + << "appstream_downloader: " << rep->url() + << " request finished (" << numFinished << "/" << numReplies << ")"; + }); + } + + // wait for all requests to finish + loop.exec(); + + qCDebug(LOG_ALPINEAPK) << "appstream_downloader: all downloads have finished!"; + + int numErrors = 0; + for (QNetworkReply *rep : replies) { + const QString localCacheFile = getLocalFileSavePath(rep->url()); + + if (rep->error() == QNetworkReply::NoError) { + // read received reply contents and save it to file + const QByteArray data = rep->readAll(); + QFile fout(localCacheFile); + if (fout.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + fout.write(data); + fout.close(); + m_cacheWasUpdated = true; + qCDebug(LOG_ALPINEAPK) << "appstream_downloader: saved: " << localCacheFile; + } else { + qCWarning(LOG_ALPINEAPK) << "appstream_downloader: failed to save:" << localCacheFile; + } + } else { + // download failed for some reason + QFileInfo urlinfo(rep->url().path()); + qCWarning(LOG_ALPINEAPK) << "appstream_downloader: failed to download" + << urlinfo.fileName() << rep->errorString(); + numErrors++; + } + } + + // cleanup: delete all replies objects + for (QNetworkReply *arep : replies) { + arep->deleteLater(); + } +} diff --git a/libdiscover/backends/AlpineApkBackend/AppstreamDataDownloader.h b/libdiscover/backends/AlpineApkBackend/AppstreamDataDownloader.h new file mode 100644 index 00000000..05771982 --- /dev/null +++ b/libdiscover/backends/AlpineApkBackend/AppstreamDataDownloader.h @@ -0,0 +1,139 @@ +/*************************************************************************** + * Copyright © 2020 Alexey Min <alexey.min@gmail.com> * + * * + * This program is free software; you can redistribute it and/or * + * modify it under the terms of the GNU General Public License as * + * published by the Free Software Foundation; either version 2 of * + * the License or (at your option) version 3 or any later version * + * accepted by the membership of KDE e.V. (or its successor approved * + * by the membership of KDE e.V.), which shall act as a proxy * + * defined in Section 14 of version 3 of the license. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see <http://www.gnu.org/licenses/>. * + ***************************************************************************/ + +#ifndef AlpineAppstreamDataDownloader_H +#define AlpineAppstreamDataDownloader_H + +#include <QFutureWatcher> +#include <QHash> +#include <QList> +#include <QObject> +#include <QString> +#include <QUrl> + +/** + * @brief The AppstreamDataDownloader class + * + * @details The job of this class is to download appstream data + * gzipped XMLs from the Web server hosted somewhere (for + * Alpine Linux - at https://appstream.alpinelinux.org). + * + * Some distros (for example, Alpine Linux) do not provide + * appstream data as installable package, instead they host it + * on the internet and you have to download and install them + * manually. + * + * Logic behind this decision is beyond my understanding, but we + * have what we have... Those files are not very large (from few + * kilobytes to couple of megabytes) but we still need them. + * + * URLs to download archives from are stored in JSON files in + * /usr/share/libdiscover/external-appstream-urls/ directory + * (QStandardPaths::GenericDataLocation/libdiscover/external-appstream-urls + * from C++/Qt code, ${DATA_INSTALL_DIR}/libdiscover/external-appstream-urls + * from cmake). + * + * JSON file format: + * --------------------- + * { + * "urls": [ "https://url1", "https://url2", ... ] + * } + * --------------------- + * + * This class can load any amount of those JSON files, + * fetch URLs from them and download all files pointed by + * those URLs to discover's cache directory: + * "~/.cache/discover/appstream_data" (aka QStandardPaths::CacheLocation). + * If files are already present in cache and not outdated, + * they are not downloaded again. Default cache expiration + * time is 2 days. + */ +class AppstreamDataDownloader: public QObject +{ + Q_OBJECT +public: + explicit AppstreamDataDownloader(QObject *parent = nullptr); + + /** + * @brief getAppstreamCacheDir + * @details Use return value of this function to add extra metadata + * directories to AppStream loader, in case of AppStreamQt: + * AppStream::Pool::addMetadataLocation(). + * This method creates a cache dir if it does not exist. + * @return directory where downloaded files are stored. + */ + static QString getAppStreamCacheDir(); + + /** + * @brief cacheWasUpdated + * @details Call this after receiving downloadFinished() signal to + * test if there actually was something new downloaded. + * + * @return true, if new files were actually downloaded, or + * false is files already present in cache are up to date. + */ + bool cacheWasUpdated() const { return m_cacheWasUpdated; } + + /** + * @brief getCacheExpirePeriodSecs + * @return cache expire timeout in seconds + */ + qint64 getCacheExpirePeriodSecs() const { return m_cacheExpireSeconds; } + + /** + * @brief setCacheExpirePeriodSecs + * @param secs - new cache expiration timeout, in seconds. + */ + void setCacheExpirePeriodSecs(qint64 secs); + +public Q_SLOTS: + /** + * @brief start + * Start the background thread that does all the job. + * downloadFinished() signal will be emitted when everything is done. + * start() may finish immediately if all cached files are + * up to date and no downloads are needed. + */ + void start(); + +Q_SIGNALS: + /** + * @brief downloadFinished + * This signal is emitted when download job is finished. + * To check if there were actual downloads performed, call + * cacheWasUpdated(). + */ + void downloadFinished(); + +private: + QString getLocalFileSavePath(const QUrl &urlTodownload); + void loadUrlsJson(const QString &path); + void download(); + +protected: + qint64 m_cacheExpireSeconds = 2 * 24 * 3600; // 2 days + QStringList m_urls; + QStringList m_urlsToDownload; + QHash<QString, QString> m_urlPrefixes; + QFutureWatcher<void> m_voidFutureWatcher; + bool m_cacheWasUpdated = false; +}; + +#endif diff --git a/libdiscover/backends/AlpineApkBackend/CMakeLists.txt b/libdiscover/backends/AlpineApkBackend/CMakeLists.txt new file mode 100644 index 00000000..8602dce7 --- /dev/null +++ b/libdiscover/backends/AlpineApkBackend/CMakeLists.txt @@ -0,0 +1,85 @@ +find_package(KF5Auth CONFIG REQUIRED) # Probably should be moved to top CMakeLists + +set(alpineapkbackend_SRCS + AlpineApkAuthActionFactory.h + AlpineApkAuthActionFactory.cpp + AlpineApkBackend.cpp + AlpineApkBackend.h + AlpineApkResource.cpp + AlpineApkResource.h + AlpineApkReviewsBackend.cpp + AlpineApkReviewsBackend.h + AlpineApkSourcesBackend.cpp + AlpineApkSourcesBackend.h + AlpineApkUpdater.cpp + AlpineApkUpdater.h + AlpineApkTransaction.cpp + AlpineApkTransaction.h + AppstreamDataDownloader.h + AppstreamDataDownloader.cpp +) + +ecm_qt_declare_logging_category( + alpineapkbackend_SRCS # sources_var + HEADER alpineapk_backend_logging.h + IDENTIFIER LOG_ALPINEAPK + CATEGORY_NAME org.kde.plasma.discover.alpineapk + DEFAULT_SEVERITY Debug +) + +add_library( + alpineapk-backend + MODULE + ${alpineapkbackend_SRCS} +) + +target_link_libraries( + alpineapk-backend + PRIVATE + Qt5::Core + Qt5::Widgets + Qt5::Concurrent + KF5::CoreAddons + KF5::ConfigCore + KF5::AuthCore + Discover::Common + ApkQt::ApkQt + AppStreamQt +) + +# KAuth helper exe +add_executable(alpineapk_kauth_helper + AlpineApkAuthHelper.cpp + AlpineApkAuthHelper.h + org.kde.discover.alpineapkbackend.actions +) +set_source_files_properties( + org.kde.discover.alpineapkbackend.actions + PROPERTIES HEADER_FILE_ONLY ON +) +target_link_libraries(alpineapk_kauth_helper + Qt5::Core + KF5::AuthCore + ApkQt::ApkQt +) + +kauth_install_actions(org.kde.discover.alpineapkbackend org.kde.discover.alpineapkbackend.actions) +kauth_install_helper_files(alpineapk_kauth_helper org.kde.discover.alpineapkbackend root) + +install( + TARGETS alpineapk-backend + DESTINATION ${PLUGIN_INSTALL_DIR}/discover +) + +install( + TARGETS alpineapk_kauth_helper + DESTINATION ${KAUTH_HELPER_INSTALL_DIR} +) + +# add_library(AlpineApkNotifier MODULE AlpineApkNotifier.cpp) + +# target_link_libraries(AlpineApkNotifier Discover::Notifiers) + +# set_target_properties(AlpineApkNotifier PROPERTIES INSTALL_RPATH ${CMAKE_INSTALL_FULL_LIBDIR}/plasma-discover) + +# install(TARGETS AlpineApkNotifier DESTINATION ${PLUGIN_INSTALL_DIR}/discover-notifier) diff --git a/libdiscover/backends/AlpineApkBackend/org.kde.discover.alpineapkbackend.actions b/libdiscover/backends/AlpineApkBackend/org.kde.discover.alpineapkbackend.actions new file mode 100644 index 00000000..c9bb5f9f --- /dev/null +++ b/libdiscover/backends/AlpineApkBackend/org.kde.discover.alpineapkbackend.actions @@ -0,0 +1,5 @@ +[org.kde.discover.alpineapkbackend.pkgmgmt] +Name=Package management +Description=Install or remove packages, upgrade system +Policy=auth_admin +Persistence=session diff --git a/libdiscover/backends/CMakeLists.txt b/libdiscover/backends/CMakeLists.txt index 5f87f639..18947339 100644 --- a/libdiscover/backends/CMakeLists.txt +++ b/libdiscover/backends/CMakeLists.txt @@ -45,4 +45,14 @@ if(BUILD_FwupdBackend AND TARGET PkgConfig::Fwupd) add_subdirectory(FwupdBackend) endif() +find_package(ApkQt CONFIG) +set_package_properties(ApkQt PROPERTIES + DESCRIPTION "C++/Qt interface library for Alpine package keeper" + URL "https://www.alpinelinux.org" + PURPOSE "Required to build the Alpine APK backend" + TYPE OPTIONAL) +option(BUILD_AlpineApkBackend "Build Alpine APK support." "ON") +if(BUILD_AlpineApkBackend AND ApkQt_FOUND) + add_subdirectory(AlpineApkBackend) +endif() -- 2.30.0