diff --git a/CMakeLists.txt b/CMakeLists.txt --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -32,6 +32,7 @@ find_package(KF5IconThemes ${KF5_DEP_VERSION} REQUIRED) find_package(KF5KIO ${KF5_DEP_VERSION} REQUIRED) find_package(KF5ItemViews ${KF5_DEP_VERSION} REQUIRED) +find_package(KF5Package ${KF5_DEP_VERSION} REQUIRED) find_package(KF5Service ${KF5_DEP_VERSION} REQUIRED) find_package(KF5TextWidgets ${KF5_DEP_VERSION} REQUIRED) find_package(KF5WidgetsAddons ${KF5_DEP_VERSION} REQUIRED) diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -30,6 +30,9 @@ jobs/httpjob.cpp jobs/httpworker.cpp + # A simple wrapper around KPackage operations, which allows for asynchronous interaction + jobs/kpackagejob.cpp + ../attica/atticaprovider.cpp ../staticxml/staticxmlprovider.cpp @@ -73,6 +76,7 @@ KF5::Archive # For decompressing archives KF5::I18n # For translations KF5::ConfigCore + KF5::Package Qt5::Gui # For QImage ) diff --git a/src/core/cache.h b/src/core/cache.h --- a/src/core/cache.h +++ b/src/core/cache.h @@ -57,6 +57,19 @@ void insertRequest(const KNSCore::Provider::SearchRequest &, const KNSCore::EntryInternal::List &entries); EntryInternal::List requestFromCache(const KNSCore::Provider::SearchRequest &); + /** + * This will run through all entries in the cache, and remove all entries + * where all the installed files they refer to no longer exist. + * + * This cannot be done wholesale for all caches, as some consumers will allow + * this to happen (or indeed expect it to), and so we have to do this on a + * per-type basis + * + * This will also cause the cache store to be updated + * + * @since 5.71 + */ + void removeDeletedEntries(); public Q_SLOTS: void registerChangedEntry(const KNSCore::EntryInternal &entry); diff --git a/src/core/cache.cpp b/src/core/cache.cpp --- a/src/core/cache.cpp +++ b/src/core/cache.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include #include #include @@ -241,6 +242,7 @@ { setProperty("dirty", true); cache.insert(entry); + QTimer::singleShot(1000, this, [this](){ writeRegistry(); }); } void Cache::insertRequest(const KNSCore::Provider::SearchRequest &request, const KNSCore::EntryInternal::List &entries) @@ -261,3 +263,22 @@ return requestCache.value(request.hashForRequest()); } +void KNSCore::Cache::removeDeletedEntries() +{ + QMutableSetIterator i(cache); + while (i.hasNext()) { + const KNSCore::EntryInternal &entry = i.next(); + bool installedFileExists{false}; + for (const auto &installedFile: entry.installedFiles()) { + if (QFile::exists(installedFile)) { + installedFileExists = true; + break; + } + } + if (!installedFileExists) { + i.remove(); + setProperty("dirty", true); + } + } + writeRegistry(); +} diff --git a/src/core/engine.h b/src/core/engine.h --- a/src/core/engine.h +++ b/src/core/engine.h @@ -89,6 +89,7 @@ * * @param configfile KNewStuff2 configuration file (*.knsrc) * @return \b true if any valid configuration was found, \b false otherwise + * @see KNS3::DownloadDialog */ bool init(const QString &configfile); diff --git a/src/core/engine.cpp b/src/core/engine.cpp --- a/src/core/engine.cpp +++ b/src/core/engine.cpp @@ -180,6 +180,9 @@ qCDebug(KNEWSTUFFCORE) << "Cache is" << m_cache << "for" << configFileName; connect(this, &Engine::signalEntryChanged, m_cache.data(), &Cache::registerChangedEntry); m_cache->readRegistry(); + if (m_installation->uncompressionSetting() == Installation::UseKPackageUncompression) { + m_cache->removeDeletedEntries(); + } m_initialized = true; diff --git a/src/core/installation.h b/src/core/installation.h --- a/src/core/installation.h +++ b/src/core/installation.h @@ -64,6 +64,13 @@ ScopeUser, ScopeSystem }; + enum UncompressionOptions { + NeverUncompress, ///@< Never attempt to decompress a file, whatever format it is. Matches "never" knsrc setting + AlwaysUncompress, ///@< Assume all downloaded files are archives, and attempt to decompress them. Will cause failure if decompression fails. Matches "always" knsrc setting + UncompressIfArchive, ///@< If the file is an archive, decompress it, otherwise just pass it on. Matches "archive" knsrc setting + UncompressIntoSubdir, ///@< As Archive, except that if there is more than an item in the file, put contents in a subdirectory with the same name as the file. Matches "subdir" knsrc setting + UseKPackageUncompression ///@< Use the internal KPackage support for installing and uninstalling the package. Matches "kpackage" knsrc setting + }; bool readConfig(const KConfigGroup &group); bool isRemote() const; @@ -116,6 +123,14 @@ */ void uninstall(KNSCore::EntryInternal entry); + /** + * Returns the uncompression setting, in a computer-readable format + * + * @return The value of this setting + * @since 5.71 + */ + UncompressionOptions uncompressionSetting() const; + // TODO KF6: remove, was used with deprecated Security class. #if KNEWSTUFFCORE_ENABLE_DEPRECATED_SINCE(5, 31) KNEWSTUFFCORE_DEPRECATED_VERSION(5, 31, "No longer use") diff --git a/src/core/installation.cpp b/src/core/installation.cpp --- a/src/core/installation.cpp +++ b/src/core/installation.cpp @@ -33,6 +33,11 @@ #include "krandom.h" #include "kshell.h" +#include +#include +#include +#include "jobs/kpackagejob.h" + #include #include "klocalizedstring.h" #include @@ -54,6 +59,8 @@ , customName(false) , acceptHtml(false) { + // TODO KF6 Make this a real property, when we can refactor this and add a proper dptr + setProperty("kpackageType", QLatin1String("")); } bool Installation::readConfig(const KConfigGroup &group) @@ -68,11 +75,12 @@ else if (uncompresssetting == QLatin1String("true")) { uncompresssetting = QStringLiteral("always"); } - if (uncompresssetting != QLatin1String("always") && uncompresssetting != QLatin1String("archive") && uncompresssetting != QLatin1String("never") && uncompresssetting != QLatin1String("subdir")) { - qCCritical(KNEWSTUFFCORE) << "invalid Uncompress setting chosen, must be one of: subdir, always, archive, or never"; + if (uncompresssetting != QLatin1String("always") && uncompresssetting != QLatin1String("archive") && uncompresssetting != QLatin1String("never") && uncompresssetting != QLatin1String("subdir") && uncompresssetting != QLatin1String("kpackage")) { + qCCritical(KNEWSTUFFCORE) << "invalid Uncompress setting chosen, must be one of: subdir, always, archive, never, or kpackage"; return false; } uncompression = uncompresssetting; + setProperty("kpackageType", group.readEntry("KPackageType", QString())); postInstallationCommand = group.readEntry("InstallationCommand", QString()); uninstallCommand = group.readEntry("UninstallCommand", QString()); standardResourceDirectory = group.readEntry("StandardResource", QString()); @@ -91,6 +99,21 @@ uncompression = QStringLiteral("subdir"); } + // A touch of special treatment for the various old kpackage based knsrc files, so they work + // with the new, internal stuff. The result unfortunately is that all entries marked as + // installed in knewstuff no longer will be, but since it never worked right anyway... we'll + // simply have to live with that. + if (postInstallationCommand.startsWith(QLatin1String("kpackagetool5 -t")) && + postInstallationCommand.endsWith(QLatin1String("-i %f")) && + uninstallCommand.startsWith(QLatin1String("kpackagetool5 -t")) && + uninstallCommand.endsWith(QLatin1String("-r %f"))) { + uncompression = QLatin1String("kpackage"); + postInstallationCommand = QLatin1String(""); + // Not clearing uninstallCommand, as this is used for the fallback situation + setProperty("kpackageType", uninstallCommand.mid(17, uninstallCommand.length() - 17 - 6)); + qCWarning(KNEWSTUFFCORE) << "Your configuration file uses an old version of the kpackage support, and should be converted. Please report this to the author of the software you are currently using. The package type, we assume, is" << property("kpackageType").toString(); + } + installPath = group.readEntry("InstallPath", QString()); absoluteInstallPath = group.readEntry("AbsoluteInstallPath", QString()); customName = group.readEntry("CustomName", false); @@ -297,40 +320,42 @@ QString targetPath = targetInstallationPath(); QStringList installedFiles = installDownloadedFileAndUncompress(entry, downloadedFile, targetPath); - if (installedFiles.isEmpty()) { - if (entry.status() == KNS3::Entry::Installing) { - entry.setStatus(KNS3::Entry::Downloadable); - } else if (entry.status() == KNS3::Entry::Updating) { - entry.setStatus(KNS3::Entry::Updateable); + if (uncompression != QLatin1String("kpackage")) { + if (installedFiles.isEmpty()) { + if (entry.status() == KNS3::Entry::Installing) { + entry.setStatus(KNS3::Entry::Downloadable); + } else if (entry.status() == KNS3::Entry::Updating) { + entry.setStatus(KNS3::Entry::Updateable); + } + emit signalEntryChanged(entry); + emit signalInstallationFailed(i18n("Could not install \"%1\": file not found.", entry.name())); + return; } - emit signalEntryChanged(entry); - emit signalInstallationFailed(i18n("Could not install \"%1\": file not found.", entry.name())); - return; - } - entry.setInstalledFiles(installedFiles); + entry.setInstalledFiles(installedFiles); - auto installationFinished = [this, entry]() { - EntryInternal newentry = entry; - // update version and release date to the new ones - if (newentry.status() == KNS3::Entry::Updating) { - if (!newentry.updateVersion().isEmpty()) { - newentry.setVersion(newentry.updateVersion()); - } - if (newentry.updateReleaseDate().isValid()) { - newentry.setReleaseDate(newentry.updateReleaseDate()); + auto installationFinished = [this, entry]() { + EntryInternal newentry = entry; + // update version and release date to the new ones + if (newentry.status() == KNS3::Entry::Updating) { + if (!newentry.updateVersion().isEmpty()) { + newentry.setVersion(newentry.updateVersion()); + } + if (newentry.updateReleaseDate().isValid()) { + newentry.setReleaseDate(newentry.updateReleaseDate()); + } } - } - newentry.setStatus(KNS3::Entry::Installed); - emit signalEntryChanged(newentry); - emit signalInstallationFinished(); - }; - if (!postInstallationCommand.isEmpty()) { - QProcess* p = runPostInstallationCommand(installedFiles.size() == 1 ? installedFiles.first() : targetPath); - connect(p, static_cast(&QProcess::finished), this, installationFinished); - } else { - installationFinished(); + newentry.setStatus(KNS3::Entry::Installed); + emit signalEntryChanged(newentry); + emit signalInstallationFinished(); + }; + if (!postInstallationCommand.isEmpty()) { + QProcess* p = runPostInstallationCommand(installedFiles.size() == 1 ? installedFiles.first() : targetPath); + connect(p, static_cast(&QProcess::finished), this, installationFinished); + } else { + installationFinished(); + } } } @@ -425,141 +450,242 @@ bool isarchive = true; // respect the uncompress flag in the knsrc - if (uncompression == QLatin1String("always") || uncompression == QLatin1String("archive") || uncompression == QLatin1String("subdir")) { - // this is weird but a decompression is not a single name, so take the path instead - QMimeDatabase db; - QMimeType mimeType = db.mimeTypeForFile(payloadfile); - qCDebug(KNEWSTUFFCORE) << "Postinstallation: uncompress the file"; - - // FIXME: check for overwriting, malicious archive entries (../foo) etc. - // FIXME: KArchive should provide "safe mode" for this! - QScopedPointer archive; - - if (mimeType.inherits(QStringLiteral("application/zip"))) { - archive.reset(new KZip(payloadfile)); - } else if (mimeType.inherits(QStringLiteral("application/tar")) - || mimeType.inherits(QStringLiteral("application/x-gzip")) - || mimeType.inherits(QStringLiteral("application/x-bzip")) - || mimeType.inherits(QStringLiteral("application/x-lzma")) - || mimeType.inherits(QStringLiteral("application/x-xz")) - || mimeType.inherits(QStringLiteral("application/x-bzip-compressed-tar")) - || mimeType.inherits(QStringLiteral("application/x-compressed-tar"))) { - archive.reset(new KTar(payloadfile)); - } else { - qCCritical(KNEWSTUFFCORE) << "Could not determine type of archive file '" << payloadfile << "'"; - if (uncompression == QLatin1String("always")) { - emit signalInstallationError(i18n("Could not determine the type of archive of the downloaded file %1", payloadfile)); - return QStringList(); + if (uncompression == QLatin1String("kpackage")) { + qCDebug(KNEWSTUFFCORE) << "Using KPackage for installation"; + KPackage::PackageStructure structure; + KPackage::Package package(&structure); + QString serviceType; + package.setPath(payloadfile); + auto resetEntryStatus = [this,entry](){ + KNSCore::EntryInternal changedEntry(entry); + if (changedEntry.status() == KNS3::Entry::Installing) { + changedEntry.setStatus(KNS3::Entry::Downloadable); + } else if (changedEntry.status() == KNS3::Entry::Updating) { + changedEntry.setStatus(KNS3::Entry::Updateable); } - isarchive = false; + emit signalEntryChanged(changedEntry); + }; + if (package.isValid() && package.metadata().isValid()) { + qCDebug(KNEWSTUFFCORE) << "Package metadata is valid"; + serviceType = package.metadata().value(QStringLiteral("X-Plasma-ServiceType")); + if (serviceType.isEmpty() && !package.metadata().serviceTypes().isEmpty()) { + serviceType = package.metadata().serviceTypes().first(); + } + if (serviceType.isEmpty()) { + serviceType = property("kpackageType").toString(); + } + if (!serviceType.isEmpty()) { + qCDebug(KNEWSTUFFCORE) << "Service type discovered as" << serviceType; + KPackage::PackageStructure *structure = KPackage::PackageLoader::self()->loadPackageStructure(serviceType); + if (structure) { + KPackage::Package installer = KPackage::Package(structure); + if (installer.hasValidStructure()) { + QString packageRoot = QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation) + QLatin1Char('/') + installer.defaultPackageRoot(); + qCDebug(KNEWSTUFFCORE) << "About to attempt to install" << package.metadata().pluginId() << "into" << packageRoot; + const QString expectedDir{packageRoot + package.metadata().pluginId()}; + KJob *installJob = KPackageJob::update(payloadfile, packageRoot, serviceType); + // TODO KF6 Really, i would prefer to make more functions to handle this, but as this is + // an exported class, i'd rather not pollute the public namespace with internal functions, + // and we don't have a pimpl, so... we'll just have to deal with it for now + connect(installJob, &KJob::result, this, [this,entry,payloadfile,expectedDir,resetEntryStatus](KJob* job){ + if (job->error() == KJob::NoError) { + if (QFile::exists(expectedDir)) { + EntryInternal newentry = entry; + newentry.setInstalledFiles(QStringList{expectedDir}); + // update version and release date to the new ones + if (newentry.status() == KNS3::Entry::Updating) { + if (!newentry.updateVersion().isEmpty()) { + newentry.setVersion(newentry.updateVersion()); + } + if (newentry.updateReleaseDate().isValid()) { + newentry.setReleaseDate(newentry.updateReleaseDate()); + } + } + newentry.setStatus(KNS3::Entry::Installed); + emit signalEntryChanged(newentry); + emit signalInstallationFinished(); + qCDebug(KNEWSTUFFCORE) << "Install job finished with no error and we now have files" << expectedDir; + } else { + emit signalInstallationFailed(i18n("The installation of %1 failed to create the expected new directory %2").arg(payloadfile).arg(expectedDir)); + resetEntryStatus(); + qCDebug(KNEWSTUFFCORE) << "Install job finished with no error, but we do not have the expected new directory" << expectedDir; + } + } else { + if (job->error() == KPackage::Package::JobError::NewerVersionAlreadyInstalledError) { + EntryInternal newentry = entry; + newentry.setStatus(KNS3::Entry::Installed); + emit signalEntryChanged(newentry); + emit signalInstallationFinished(); + newentry.setInstalledFiles(QStringList{expectedDir}); + qCDebug(KNEWSTUFFCORE) << "Install job finished telling us this item was already installed with this version, so... let's just make a small fib and say we totally installed that, honest, and we now have files" << expectedDir; + } else { + emit signalInstallationFailed(i18n("Installation of %1 failed: %2", payloadfile, job->errorText())); + resetEntryStatus(); + qCDebug(KNEWSTUFFCORE) << "Install job finished with error state" << job->error() << "and description" << job->error(); + } + } + }); + installJob->start(); + } else { + emit signalInstallationFailed(i18n("The installation of %1 failed, as the service type %2 was not accepted by the system (did you forget to install the KPackage support plugin for this type of package?)", payloadfile, serviceType)); + resetEntryStatus(); + qCWarning(KNEWSTUFFCORE) << "Package serviceType" << serviceType << "not found"; + } + } else { + // no package structure + emit signalInstallationFailed(i18n("The installation of %1 failed, as the downloaded package does not contain a correct KPackage structure.", payloadfile)); + resetEntryStatus(); + qCWarning(KNEWSTUFFCORE) << "Could not load the package structure for KPackage service type" << serviceType; + } + } else { + // no service type + emit signalInstallationFailed(i18n("The installation of %1 failed, as the downloaded package does not list a service type.", payloadfile)); + resetEntryStatus(); + qCWarning(KNEWSTUFFCORE) << "No service type listed in" << payloadfile; + } + } else { + // package or package metadata is invalid + emit signalInstallationFailed(i18n("The installation of %1 failed, as the downloaded package does not contain any useful meta information, which means it is not a valid KPackage.", payloadfile)); + resetEntryStatus(); + qCWarning(KNEWSTUFFCORE) << "No valid meta information (which suggests no valid KPackage) found in" << payloadfile; } - - if (isarchive) { - bool success = archive->open(QIODevice::ReadOnly); - if (!success) { - qCCritical(KNEWSTUFFCORE) << "Cannot open archive file '" << payloadfile << "'"; + } else { + if (uncompression == QLatin1String("always") || uncompression == QLatin1String("archive") || uncompression == QLatin1String("subdir")) { + // this is weird but a decompression is not a single name, so take the path instead + QMimeDatabase db; + QMimeType mimeType = db.mimeTypeForFile(payloadfile); + qCDebug(KNEWSTUFFCORE) << "Postinstallation: uncompress the file"; + + // FIXME: check for overwriting, malicious archive entries (../foo) etc. + // FIXME: KArchive should provide "safe mode" for this! + QScopedPointer archive; + + if (mimeType.inherits(QStringLiteral("application/zip"))) { + archive.reset(new KZip(payloadfile)); + } else if (mimeType.inherits(QStringLiteral("application/tar")) + || mimeType.inherits(QStringLiteral("application/x-gzip")) + || mimeType.inherits(QStringLiteral("application/x-bzip")) + || mimeType.inherits(QStringLiteral("application/x-lzma")) + || mimeType.inherits(QStringLiteral("application/x-xz")) + || mimeType.inherits(QStringLiteral("application/x-bzip-compressed-tar")) + || mimeType.inherits(QStringLiteral("application/x-compressed-tar"))) { + archive.reset(new KTar(payloadfile)); + } else { + qCCritical(KNEWSTUFFCORE) << "Could not determine type of archive file '" << payloadfile << "'"; if (uncompression == QLatin1String("always")) { - emit signalInstallationError(i18n("Failed to open the archive file %1. The reported error was: %2", payloadfile, archive->errorString())); + emit signalInstallationError(i18n("Could not determine the type of archive of the downloaded file %1", payloadfile)); return QStringList(); } - // otherwise, just copy the file isarchive = false; } if (isarchive) { - const KArchiveDirectory *dir = archive->directory(); - //if there is more than an item in the file, and we are requested to do so - //put contents in a subdirectory with the same name as the file - QString installpath; - if (uncompression == QLatin1String("subdir") && dir->entries().count() > 1) { - installpath = installdir + QLatin1Char('/') + QFileInfo(archive->fileName()).baseName(); - } else { - installpath = installdir; + bool success = archive->open(QIODevice::ReadOnly); + if (!success) { + qCCritical(KNEWSTUFFCORE) << "Cannot open archive file '" << payloadfile << "'"; + if (uncompression == QLatin1String("always")) { + emit signalInstallationError(i18n("Failed to open the archive file %1. The reported error was: %2", payloadfile, archive->errorString())); + return QStringList(); + } + // otherwise, just copy the file + isarchive = false; } - if (dir->copyTo(installpath)) { - installedFiles << archiveEntries(installpath, dir); - installedFiles << installpath + QLatin1Char('/'); - } else - qCWarning(KNEWSTUFFCORE) << "could not install" << entry.name() << "to" << installpath; - - archive->close(); - QFile::remove(payloadfile); + if (isarchive) { + const KArchiveDirectory *dir = archive->directory(); + //if there is more than an item in the file, and we are requested to do so + //put contents in a subdirectory with the same name as the file + QString installpath; + if (uncompression == QLatin1String("subdir") && dir->entries().count() > 1) { + installpath = installdir + QLatin1Char('/') + QFileInfo(archive->fileName()).baseName(); + } else { + installpath = installdir; + } + + if (dir->copyTo(installpath)) { + installedFiles << archiveEntries(installpath, dir); + installedFiles << installpath + QLatin1Char('/'); + } else + qCWarning(KNEWSTUFFCORE) << "could not install" << entry.name() << "to" << installpath; + + archive->close(); + QFile::remove(payloadfile); + } } } - } - qCDebug(KNEWSTUFFCORE) << "isarchive: " << isarchive; - - //some wallpapers are compressed, some aren't - if ((!isarchive && standardResourceDirectory == QLatin1String("wallpaper")) || - (uncompression == QLatin1String("never") || (uncompression == QLatin1String("archive") && !isarchive))) { - // no decompress but move to target - - /// @todo when using KIO::get the http header can be accessed and it contains a real file name. - // FIXME: make naming convention configurable through *.knsrc? e.g. for kde-look.org image names - QUrl source = QUrl(entry.payload()); - qCDebug(KNEWSTUFFCORE) << "installing non-archive from " << source.url(); - QString installfile; - QString ext = source.fileName().section(QLatin1Char('.'), -1); - if (customName) { - installfile = entry.name(); - installfile += QLatin1Char('-') + entry.version(); - if (!ext.isEmpty()) { - installfile += QLatin1Char('.') + ext; - } - } else { - // TODO HACK This is a hack, the correct way of fixing it would be doing the KIO::get - // and using the http headers if they exist to get the file name, but as discussed in - // Randa this is not going to happen anytime soon (if ever) so go with the hack - if (source.url().startsWith(QLatin1String("http://newstuff.kde.org/cgi-bin/hotstuff-access?file="))) { - installfile = QUrlQuery(source).queryItemValue(QStringLiteral("file")); - int lastSlash = installfile.lastIndexOf(QLatin1Char('/')); - if (lastSlash >= 0) { - installfile = installfile.mid(lastSlash); + qCDebug(KNEWSTUFFCORE) << "isarchive: " << isarchive; + + //some wallpapers are compressed, some aren't + if ((!isarchive && standardResourceDirectory == QLatin1String("wallpaper")) || + (uncompression == QLatin1String("never") || (uncompression == QLatin1String("archive") && !isarchive))) { + // no decompress but move to target + + /// @todo when using KIO::get the http header can be accessed and it contains a real file name. + // FIXME: make naming convention configurable through *.knsrc? e.g. for kde-look.org image names + QUrl source = QUrl(entry.payload()); + qCDebug(KNEWSTUFFCORE) << "installing non-archive from " << source.url(); + QString installfile; + QString ext = source.fileName().section(QLatin1Char('.'), -1); + if (customName) { + installfile = entry.name(); + installfile += QLatin1Char('-') + entry.version(); + if (!ext.isEmpty()) { + installfile += QLatin1Char('.') + ext; + } + } else { + // TODO HACK This is a hack, the correct way of fixing it would be doing the KIO::get + // and using the http headers if they exist to get the file name, but as discussed in + // Randa this is not going to happen anytime soon (if ever) so go with the hack + if (source.url().startsWith(QLatin1String("http://newstuff.kde.org/cgi-bin/hotstuff-access?file="))) { + installfile = QUrlQuery(source).queryItemValue(QStringLiteral("file")); + int lastSlash = installfile.lastIndexOf(QLatin1Char('/')); + if (lastSlash >= 0) { + installfile = installfile.mid(lastSlash); + } + } + if (installfile.isEmpty()) { + installfile = source.fileName(); } } - if (installfile.isEmpty()) { - installfile = source.fileName(); + QString installpath = QDir(installdir).filePath(installfile); + + qCDebug(KNEWSTUFFCORE) << "Install to file " << installpath; + // FIXME: copy goes here (including overwrite checking) + // FIXME: what must be done now is to update the cache *again* + // in order to set the new payload filename (on root tag only) + // - this might or might not need to take uncompression into account + // FIXME: for updates, we might need to force an overwrite (that is, deleting before) + QFile file(payloadfile); + bool success = true; + const bool update = ((entry.status() == KNS3::Entry::Updateable) || (entry.status() == KNS3::Entry::Updating)); + + if (QFile::exists(installpath) && QDir::tempPath() != installdir) { + if (!update) { + Question question(Question::YesNoQuestion); + question.setQuestion(i18n("This file already exists on disk (possibly due to an earlier failed download attempt). Continuing means overwriting it. Do you wish to overwrite the existing file?") + QStringLiteral("\n'") + installpath + QLatin1Char('\'')); + question.setTitle(i18n("Overwrite File")); + if(question.ask() != Question::YesResponse) { + return QStringList(); + } + } + success = QFile::remove(installpath); } - } - QString installpath = QDir(installdir).filePath(installfile); - - qCDebug(KNEWSTUFFCORE) << "Install to file " << installpath; - // FIXME: copy goes here (including overwrite checking) - // FIXME: what must be done now is to update the cache *again* - // in order to set the new payload filename (on root tag only) - // - this might or might not need to take uncompression into account - // FIXME: for updates, we might need to force an overwrite (that is, deleting before) - QFile file(payloadfile); - bool success = true; - const bool update = ((entry.status() == KNS3::Entry::Updateable) || (entry.status() == KNS3::Entry::Updating)); - - if (QFile::exists(installpath) && QDir::tempPath() != installdir) { - if (!update) { - Question question(Question::YesNoQuestion); - question.setQuestion(i18n("This file already exists on disk (possibly due to an earlier failed download attempt). Continuing means overwriting it. Do you wish to overwrite the existing file?") + QStringLiteral("\n'") + installpath + QLatin1Char('\'')); - question.setTitle(i18n("Overwrite File")); - if(question.ask() != Question::YesResponse) { - return QStringList(); + if (success) { + //remove in case it's already present and in a temporary directory, so we get to actually use the path again + if (installpath.startsWith(QDir::tempPath())) { + file.remove(installpath); } + success = file.rename(installpath); + qCDebug(KNEWSTUFFCORE) << "move: " << file.fileName() << " to " << installpath; } - success = QFile::remove(installpath); - } - if (success) { - //remove in case it's already present and in a temporary directory, so we get to actually use the path again - if (installpath.startsWith(QDir::tempPath())) { - file.remove(installpath); + if (!success) { + emit signalInstallationError(i18n("Unable to move the file %1 to the intended destination %2", payloadfile, installpath)); + qCCritical(KNEWSTUFFCORE) << "Cannot move file '" << payloadfile << "' to destination '" << installpath << "'"; + return QStringList(); } - success = file.rename(installpath); - qCDebug(KNEWSTUFFCORE) << "move: " << file.fileName() << " to " << installpath; - } - if (!success) { - emit signalInstallationError(i18n("Unable to move the file %1 to the intended destination %2", payloadfile, installpath)); - qCCritical(KNEWSTUFFCORE) << "Cannot move file '" << payloadfile << "' to destination '" << installpath << "'"; - return QStringList(); + installedFiles << installpath; } - installedFiles << installpath; } } return installedFiles; @@ -595,64 +721,177 @@ void Installation::uninstall(EntryInternal entry) { - entry.setStatus(KNS3::Entry::Deleted); - - if (!uninstallCommand.isEmpty()) { + if (uncompression == QLatin1String("kpackage")) { const auto lst = entry.installedFiles(); - for (const QString &file : lst) { - QFileInfo info(file); - if (info.isFile()) { - QString fileArg(KShell::quoteArg(file)); - QString command(uninstallCommand); - command.replace(QLatin1String("%f"), fileArg); - - QStringList args = KShell::splitArgs(command); - const QString program = args.takeFirst(); - int exitcode = QProcess::execute(program, args); - - if (exitcode) { - emit signalInstallationError(i18n("The uninstallation process failed to successfully run the command %1", command)); - qCCritical(KNEWSTUFFCORE) << "Command failed" << command; + if (lst.length() == 1) { + const QString installedFile{lst.first()}; + if (QFileInfo(installedFile).isDir()) { + KPackage::PackageStructure structure; + KPackage::Package package(&structure); + package.setPath(installedFile); + if (package.isValid() && package.metadata().isValid()) { + QString serviceType = package.metadata().value(QStringLiteral("X-Plasma-ServiceType")); + if (serviceType.isEmpty() && !package.metadata().serviceTypes().isEmpty()) { + serviceType = package.metadata().serviceTypes().first(); + } + if (serviceType.isEmpty()) { + serviceType = property("kpackageType").toString(); + } + if (!serviceType.isEmpty()) { + KPackage::PackageStructure *structure = KPackage::PackageLoader::self()->loadPackageStructure(serviceType); + if (structure) { + KPackage::Package installer = KPackage::Package(structure); + if (!installer.hasValidStructure()) { + qWarning() << "Package serviceType" << serviceType << "not found"; + } + QString packageRoot = QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation) + QLatin1Char('/') + installer.defaultPackageRoot(); + KJob *removalJob = KPackageJob::uninstall(package.metadata().pluginId(), packageRoot, serviceType); + connect(removalJob, &KJob::result, this, [this,installedFile,installer,entry](KJob* job){ + EntryInternal newEntry = entry; + if (job->error() == KJob::NoError) { + newEntry.setStatus(KNS3::Entry::Deleted); + newEntry.setUnInstalledFiles(newEntry.installedFiles()); + newEntry.setInstalledFiles(QStringList()); + emit signalEntryChanged(newEntry); + } else { + emit signalInstallationFailed(i18n("Installation of %1 failed: %2", installedFile, job->errorText())); + } + }); + removalJob->start(); + } else { + // no package structure + emit signalInstallationFailed(i18n("The removal of %1 failed, as the installed package does not contain a correct KPackage structure.", installedFile)); + } + } else { + // no service type + emit signalInstallationFailed(i18n("The removal of %1 failed, as the installed package is not a supported type (did you forget to install the KPackage support plugin for this type of package?)", installedFile)); + } + } else { + // package or package metadata is invalid + emit signalInstallationFailed(i18n("The removal of %1 failed, as the installed package does not contain any useful meta information, which means it is not a valid KPackage.", entry.name())); + } + } else { + QMimeDatabase db; + QMimeType mimeType = db.mimeTypeForFile(installedFile); + if (mimeType.inherits(QStringLiteral("application/zip")) || + mimeType.inherits(QStringLiteral("application/x-compressed-tar")) || + mimeType.inherits(QStringLiteral("application/x-gzip")) || + mimeType.inherits(QStringLiteral("application/x-tar")) || + mimeType.inherits(QStringLiteral("application/x-bzip-compressed-tar")) || + mimeType.inherits(QStringLiteral("application/x-xz")) || + mimeType.inherits(QStringLiteral("application/x-lzma"))) { + // Then it's one of the downloaded files installed with an old version of knewstuff prior to + // the native kpackage support being added, and we need to do some inspection-and-removal work... + KPackage::PackageStructure structure; + KPackage::Package package(&structure); + const QString serviceType{property("kpackageType").toString()}; + package.setPath(installedFile); + if (package.isValid() && package.metadata().isValid()) { + // try and load the kpackage and sniff the expected location of its installation, and ask KPackage to remove that thing, if it's there + KPackage::PackageStructure *structure = KPackage::PackageLoader::self()->loadPackageStructure(serviceType); + if (structure) { + KPackage::Package installer = KPackage::Package(structure); + if (installer.hasValidStructure()) { + QString packageRoot = QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation) + QLatin1Char('/') + installer.defaultPackageRoot(); + qCDebug(KNEWSTUFFCORE) << "About to attempt to uninstall" << package.metadata().pluginId() << "from" << packageRoot; + const QString supposedInstallationDir{packageRoot + package.metadata().pluginId()}; + // Frankly, we don't care whether or not this next step succeeds, and it can just fizzle if it wants + // to. This is a cleanup step, and if it fails, it's just not really important. + KJob *removalJob = KPackageJob::uninstall(package.metadata().pluginId(), packageRoot, serviceType); + removalJob->start(); + } + } + } + // Also get rid of the downloaded file, and tell everything they've gone + if (QFile::remove(installedFile)) { + entry.setStatus(KNS3::Entry::Deleted); + entry.setUnInstalledFiles(entry.installedFiles()); + entry.setInstalledFiles(QStringList()); + emit signalEntryChanged(entry); + } else { + emit signalInstallationFailed(i18n("The removal of %1 failed, as the downloaded file %2 could not be automatically removed.", entry.name(), installedFile)); + } } else { - qCDebug(KNEWSTUFFCORE) << "Command executed successfully: " << command; + // Not sure what's installed, but it's not a KPackage, not a lot we can do with this... + emit signalInstallationFailed(i18n("The removal of %1 failed, due to the installed file not being a KPackage. The file in question was %2, and you can attempt to delete it yourself, if you are certain that it is not needed.", entry.name(), installedFile)); } } + } else { + emit signalInstallationFailed(i18n("The removal of %1 failed, as there seems to somehow be more than one thing installed, which is not supposed to be possible for KPackage based entries.", entry.name())); } - } - - const auto lst = entry.installedFiles(); - for (const QString &file : lst) { - if (file.endsWith(QLatin1Char('/'))) { - QDir dir; - bool worked = dir.rmdir(file); - if (!worked) { - // Maybe directory contains user created files, ignore it - continue; - } - } else if (file.endsWith(QLatin1String("/*"))) { - QDir dir(file.left(file.size()-2)); - bool worked = dir.removeRecursively(); - if (!worked) { - qCWarning(KNEWSTUFFCORE) << "Couldn't remove" << dir.path(); - continue; + } else { + entry.setStatus(KNS3::Entry::Deleted); + + if (!uninstallCommand.isEmpty()) { + const auto lst = entry.installedFiles(); + for (const QString &file : lst) { + QFileInfo info(file); + if (info.isFile()) { + QString fileArg(KShell::quoteArg(file)); + QString command(uninstallCommand); + command.replace(QLatin1String("%f"), fileArg); + + QStringList args = KShell::splitArgs(command); + const QString program = args.takeFirst(); + int exitcode = QProcess::execute(program, args); + + if (exitcode) { + emit signalInstallationError(i18n("The uninstallation process failed to successfully run the command %1", command)); + qCCritical(KNEWSTUFFCORE) << "Command failed" << command; + } else { + qCDebug(KNEWSTUFFCORE) << "Command executed successfully: " << command; + } + } } - } else { - QFileInfo info(file); - if (info.exists() || info.isSymLink()) { - bool worked = QFile::remove(file); + } + + const auto lst = entry.installedFiles(); + for (const QString &file : lst) { + if (file.endsWith(QLatin1Char('/'))) { + QDir dir; + bool worked = dir.rmdir(file); if (!worked) { - qWarning() << "unable to delete file " << file; - return; + // Maybe directory contains user created files, ignore it + continue; + } + } else if (file.endsWith(QLatin1String("/*"))) { + QDir dir(file.left(file.size()-2)); + bool worked = dir.removeRecursively(); + if (!worked) { + qCWarning(KNEWSTUFFCORE) << "Couldn't remove" << dir.path(); + continue; } } else { - qWarning() << "unable to delete file " << file << ". file does not exist."; + QFileInfo info(file); + if (info.exists() || info.isSymLink()) { + bool worked = QFile::remove(file); + if (!worked) { + qWarning() << "unable to delete file " << file; + return; + } + } else { + qWarning() << "unable to delete file " << file << ". file does not exist."; + } } } + entry.setUnInstalledFiles(entry.installedFiles()); + entry.setInstalledFiles(QStringList()); + emit signalEntryChanged(entry); } - entry.setUnInstalledFiles(entry.installedFiles()); - entry.setInstalledFiles(QStringList()); +} - emit signalEntryChanged(entry); +Installation::UncompressionOptions Installation::uncompressionSetting() const +{ + if (uncompression == QLatin1String("always")) { + return AlwaysUncompress; + } else if (uncompression == QLatin1String("archive")) { + return UncompressIfArchive; + } else if (uncompression == QLatin1String("subdir")) { + return UncompressIntoSubdir; + } else if (uncompression == QLatin1String("kpackage")) { + return UseKPackageUncompression; + } + return NeverUncompress; } void Installation::slotInstallationVerification(int result) diff --git a/src/core/jobs/kpackagejob.h b/src/core/jobs/kpackagejob.h new file mode 100644 --- /dev/null +++ b/src/core/jobs/kpackagejob.h @@ -0,0 +1,82 @@ +/* + Copyright (C) 2020 Dan Leinir Turthra Jensen + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library 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 + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library. If not, see . +*/ + +#ifndef KPACKAGEJOB_H +#define KPACKAGEJOB_H + +#include + +namespace KNSCore { + +/** + * @brief A job for performing basic actions on KPackage packages asynchronously + * + * The internals of KPackage's Package functions are synchronous, which makes it easy to work with in some cases, + * but has the unfortunate side effect of blocking the UI. This job will perform those operations in a separate + * thread, which allows you to perform the work in a fire-and-forget fashion (as suggested by KJob's documentation). + * + * @since 5.71 + */ +class KPackageJob : public KJob +{ + Q_OBJECT +public: + /** + * Create a job for installing the given package into the package root, and treat it as the given service type. + * + * @param sourcePackage The full path name to the package you wish to install (e.g. /tmp/downloaded-archive.tar.xz) + * @param packageRoot The full path name to the location the package should be installed into (e.g. /home/username/.share/plasma/desktoptheme/) + * @param serviceType The name of the type of KPackage you intend to install (e.g. Plasma/Theme) + * @return A job which you can use to track the completion of the process (there will be useful details in error() and errorText() on failures) + */ + static KPackageJob *install(const QString &sourcePackage, const QString &packageRoot, const QString &serviceType); + /** + * Create a job for updating the given package, or installing it if it is not already, the given package into the + * package root, and treat it as the given service type. + * + * @param sourcePackage The full path name to the package you wish to update (e.g. /tmp/downloaded-archive.tar.xz) + * @param packageRoot The full path name to the location the package should be installed into (e.g. /home/username/.share/plasma/desktoptheme/) + * @param serviceType The name of the type of KPackage you intend to update (e.g. Plasma/Theme) + * @return A job which you can use to track the completion of the process (there will be useful details in error() and errorText() on failures) + */ + static KPackageJob *update(const QString &sourcePackage, const QString &packageRoot, const QString &serviceType); + /** + * Create a job for removing the given installed package + * + * @param packageName The name to the package you wish to remove (this is the plugin name, not the full path name, e.g. The.Package.Name) + * @param packageRoot The full path name to the location the package is currently installed (e.g. /home/username/.share/plasma/desktoptheme/The.Package.Name) + * @param serviceType The name of the type of KPackage you intend to remove (e.g. Plasma/Theme) + * @return A job which you can use to track the completion of the process (there will be useful details in error() and errorText() on failures) + */ + static KPackageJob *uninstall(const QString &packageName, const QString &packageRoot, const QString &serviceType); + + virtual ~KPackageJob(); + + /** + * Start the process asynchronously + * @see KJob::start() + */ + Q_SLOT void start() override; +private: + explicit KPackageJob(QObject* parent = nullptr); + class Private; + Private* d; +}; + +} + +#endif//KPACKAGEJOB_H diff --git a/src/core/jobs/kpackagejob.cpp b/src/core/jobs/kpackagejob.cpp new file mode 100644 --- /dev/null +++ b/src/core/jobs/kpackagejob.cpp @@ -0,0 +1,182 @@ +/* + Copyright (C) 2020 Dan Leinir Turthra Jensen + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library 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 + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library. If not, see . +*/ + +#include "kpackagejob.h" + +#include + +#include + +#include +#include +#include + +#include +#include +#include +#include +#include + +using namespace KNSCore; + +enum Operation { + UnknownOperation, + InstallOperation, + UpdateOperation, + UninstallOperation +}; +class KPackageTask; +class KPackageJob::Private { +public: + Private() {} + + QString package; + QString packageRoot; + QString serviceType; + Operation operation{UnknownOperation}; + + KPackageTask* runnable{nullptr}; +}; + +class KPackageTask : public QObject, public QRunnable +{ + Q_OBJECT +public: + QString package; + QString packageRoot; + QString serviceType; + Operation operation{UnknownOperation}; + void run() override + { + qCDebug(KNEWSTUFFCORE) << "Attempting to perform an installation operation of type" << operation << "on the package" << package << "of type" << serviceType << "in the package root" << packageRoot; + KPackage::PackageStructure *structure = KPackage::PackageLoader::self()->loadPackageStructure(serviceType); + if (structure) { + qCDebug(KNEWSTUFFCORE) << "Service type understood"; + KPackage::Package installer = KPackage::Package(structure); + if (installer.hasValidStructure()) { + qCDebug(KNEWSTUFFCORE) << "Installer successfully created and has a valid structure"; + KJob *job{nullptr}; + switch(operation) + { + case InstallOperation: + job = installer.install(package, packageRoot); + break; + case UpdateOperation: + job = installer.update(package, packageRoot); + break; + case UninstallOperation: + job = installer.uninstall(package, packageRoot); + break; + case UnknownOperation: + default: + // This should really not be happening, can't create one of these without going through one + // of the functions below, so how'd you get it in this state? + break; + }; + if (job) { + qCDebug(KNEWSTUFFCORE) << "Created job, now let's wait for it to do its thing..."; + QEventLoop loop; + connect(job, &KJob::result, this, [this,job,&loop](){ + emit result(job); + loop.exit(0); + }); + loop.exec(); + } else { + qCWarning(KNEWSTUFFCORE) << "Failed to create a job to perform our task"; + emit error(3, i18n("Failed to create a job for the package management task. This is usually because the package is invalid. We attempted to operate on the package %1", package)); + } + } else { + qCWarning(KNEWSTUFFCORE) << "Failed to create package installer"; + emit error(2, i18n("Could not create a package installer for the service type %1: The installer does not have a valid structure", serviceType)); + } + } else { + qCWarning(KNEWSTUFFCORE) << "Service type was not understood"; + emit error(1, i18n("The service type %1 was not understood by the KPackage installer", serviceType)); + } + } + Q_SIGNAL void result(KJob* job); + Q_SIGNAL void error(int errorCode, const QString& errorText); +}; + +KPackageJob::KPackageJob(QObject* parent) + : KJob(parent) + , d(new Private) +{ +} + +KPackageJob::~KPackageJob() +{ + delete d; +} + +void KPackageJob::start() +{ + if (d->runnable) { + // refuse to start the task more than once + return; + } + d->runnable = new KPackageTask(); + d->runnable->package = d->package; + d->runnable->packageRoot = d->packageRoot; + d->runnable->serviceType = d->serviceType; + d->runnable->operation = d->operation; + connect(d->runnable, &KPackageTask::error, this, [this](int errorCode, const QString& errorText){ + setError(errorCode); + setErrorText(errorText); + }, Qt::QueuedConnection); + connect(d->runnable, &KPackageTask::result, this, [this](KJob* job){ + setError(job->error()); + setErrorText(job->errorText()); + emitResult(); + }, Qt::QueuedConnection); + QThreadPool::globalInstance()->start(d->runnable); +} + +KNSCore::KPackageJob * KNSCore::KPackageJob::install(const QString &sourcePackage, const QString &packageRoot, const QString &serviceType) +{ + KPackageJob* job = new KPackageJob(); + job->d->package = sourcePackage; + job->d->packageRoot = packageRoot; + job->d->serviceType = serviceType; + job->d->operation = InstallOperation; + QTimer::singleShot(0, job, &KPackageJob::start); + return job; +} + +KPackageJob * KPackageJob::update(const QString &sourcePackage, const QString &packageRoot, const QString &serviceType) +{ + KPackageJob* job = new KPackageJob(); + job->d->package = sourcePackage; + job->d->packageRoot = packageRoot; + job->d->serviceType = serviceType; + job->d->operation = UpdateOperation; + QTimer::singleShot(0, job, &KPackageJob::start); + return job; +} + +KPackageJob * KPackageJob::uninstall(const QString &packageName, const QString &packageRoot, const QString &serviceType) +{ + KPackageJob* job = new KPackageJob(); + job->d->package = packageName; + job->d->packageRoot = packageRoot; + job->d->serviceType = serviceType; + job->d->operation = UninstallOperation; + QTimer::singleShot(0, job, &KPackageJob::start); + return job; +} + +#include "kpackagejob.moc" diff --git a/src/downloaddialog.h b/src/downloaddialog.h --- a/src/downloaddialog.h +++ b/src/downloaddialog.h @@ -63,6 +63,7 @@ *
  • never: never try to extract the file
  • *
  • archive: if the file is an archive, uncompress it, otherwise just pass it on
  • *
  • subdir: logic as archive, but decompress into a subdirectory named after the payload filename
  • + *
  • kpackage: require that the downloaded file is a kpackage, and use the kpackage framework for handling installation and removal (since 5.70)
  • * * * You have different options to set the target install directory: @@ -72,6 +73,24 @@ * This is what QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation) + QLatin1Char('/') + name will return. * * + * \subsection KPackage Support + * + * To make use of the KPackage option described above, in addition to the Uncompress setting above, you should also specify + * the type of archive expected by KPackage. While it is possible to deduce this from the package metadata in many situations, + * it is not a requirement of the format that this information exists, and we need to have a fallback in the case it is not + * available there. As such, you will want to add a KPackageType entry to your knsrc file. The following example shows how this + * is done for Plasma themes: + * + *
    +   ProvidersUrl=https://autoconfig.kde.org/ocs/providers.xml
    +   Categories=Plasma Theme
    +   StandardResource=tmp
    +   TagFilter=ghns_excluded!=1,plasma##version==5
    +   DownloadTagFilter=plasma##version==5
    +   Uncompress=kpackage
    +   KPackageType=Plasma/Theme
    + * 
    + * * @since 4.4 */ class KNEWSTUFF_EXPORT DownloadDialog : public QDialog