From a512176b4bdbf0f0471a9b9089f4a936c14e2732 Mon Sep 17 00:00:00 2001 From: Duong Do Minh Chau Date: Thu, 12 Nov 2020 16:38:44 +0700 Subject: Add options to hide some context menu entries This commit add options to hide the following context menu entries: - Add to Places - Copy Location - Duplicate Here - Open in New Tab and Open in New Tabs - Open in New Window - Sort By - View Mode The Services settings page is renamed to Context Menu ShowCopyMoveMenu option is moved from GeneralSettings to ContextMenuSettings BUG: 314594 --- .../contextmenu/contextmenusettingspage.cpp | 346 +++++++++++++++ src/settings/contextmenu/contextmenusettingspage.h | 69 +++ src/settings/contextmenu/servicemenu.knsrc | 9 + .../servicemenuinstaller/CMakeLists.txt | 15 + .../contextmenu/servicemenuinstaller/Messages.sh | 2 + .../servicemenuinstaller/servicemenuinstaller.cpp | 462 +++++++++++++++++++++ .../test/service_menu_deinstallation_test.rb | 112 +++++ .../test/service_menu_installation_test.rb | 106 +++++ src/settings/contextmenu/test/test_helper.rb | 18 + src/settings/contextmenu/test/test_run.rb | 11 + 10 files changed, 1150 insertions(+) create mode 100644 src/settings/contextmenu/contextmenusettingspage.cpp create mode 100644 src/settings/contextmenu/contextmenusettingspage.h create mode 100644 src/settings/contextmenu/servicemenu.knsrc create mode 100644 src/settings/contextmenu/servicemenuinstaller/CMakeLists.txt create mode 100755 src/settings/contextmenu/servicemenuinstaller/Messages.sh create mode 100644 src/settings/contextmenu/servicemenuinstaller/servicemenuinstaller.cpp create mode 100644 src/settings/contextmenu/test/service_menu_deinstallation_test.rb create mode 100644 src/settings/contextmenu/test/service_menu_installation_test.rb create mode 100644 src/settings/contextmenu/test/test_helper.rb create mode 100755 src/settings/contextmenu/test/test_run.rb (limited to 'src/settings/contextmenu') diff --git a/src/settings/contextmenu/contextmenusettingspage.cpp b/src/settings/contextmenu/contextmenusettingspage.cpp new file mode 100644 index 000000000..4f126d3e2 --- /dev/null +++ b/src/settings/contextmenu/contextmenusettingspage.cpp @@ -0,0 +1,346 @@ +/* + * SPDX-FileCopyrightText: 2009-2010 Peter Penz + * + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "contextmenusettingspage.h" + +#include "dolphin_generalsettings.h" +#include "dolphin_versioncontrolsettings.h" +#include "dolphin_contextmenusettings.h" +#include "settings/serviceitemdelegate.h" +#include "settings/servicemodel.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + const bool ShowDeleteDefault = false; + const char VersionControlServicePrefix[] = "_version_control_"; + const char DeleteService[] = "_delete"; + const char CopyToMoveToService[] ="_copy_to_move_to"; + const char AddToPlacesService[] = "_add_to_places"; + const char SortByService[] = "_sort_by"; + const char ViewModeService[] = "_view_mode"; + const char OpenInNewTabService[] = "_open_in_new_tab"; + const char OpenInNewWindowService[] = "_open_in_new_window"; + const char CopyLocationService[] = "_copy_location"; + const char DuplicateHereService[] = "_duplicate_here"; +} + +ContextMenuSettingsPage::ContextMenuSettingsPage(QWidget* parent) : + SettingsPageBase(parent), + m_initialized(false), + m_serviceModel(nullptr), + m_sortModel(nullptr), + m_listView(nullptr), + m_enabledVcsPlugins() +{ + QVBoxLayout* topLayout = new QVBoxLayout(this); + + QLabel* label = new QLabel(i18nc("@label:textbox", + "Select which services should " + "be shown in the context menu:"), this); + label->setWordWrap(true); + m_searchLineEdit = new QLineEdit(this); + m_searchLineEdit->setPlaceholderText(i18nc("@label:textbox", "Search...")); + connect(m_searchLineEdit, &QLineEdit::textChanged, this, [this](const QString &filter){ + m_sortModel->setFilterFixedString(filter); + }); + + m_listView = new QListView(this); + QScroller::grabGesture(m_listView->viewport(), QScroller::TouchGesture); + + auto *delegate = new ServiceItemDelegate(m_listView, m_listView); + m_serviceModel = new ServiceModel(this); + m_sortModel = new QSortFilterProxyModel(this); + m_sortModel->setSourceModel(m_serviceModel); + m_sortModel->setSortRole(Qt::DisplayRole); + m_sortModel->setSortLocaleAware(true); + m_sortModel->setFilterRole(Qt::DisplayRole); + m_sortModel->setFilterCaseSensitivity(Qt::CaseInsensitive); + m_listView->setModel(m_sortModel); + m_listView->setItemDelegate(delegate); + m_listView->setVerticalScrollMode(QListView::ScrollPerPixel); + connect(m_listView, &QListView::clicked, this, &ContextMenuSettingsPage::changed); + +#ifndef Q_OS_WIN + auto *downloadButton = new KNS3::Button(i18nc("@action:button", "Download New Services..."), + QStringLiteral("servicemenu.knsrc"), + this); + connect(downloadButton, &KNS3::Button::dialogFinished, this, [this](const KNS3::Entry::List &changedEntries) { + if (!changedEntries.isEmpty()) { + m_serviceModel->clear(); + loadServices(); + } + }); + +#endif + + topLayout->addWidget(label); + topLayout->addWidget(m_searchLineEdit); + topLayout->addWidget(m_listView); +#ifndef Q_OS_WIN + topLayout->addWidget(downloadButton); +#endif + + m_enabledVcsPlugins = VersionControlSettings::enabledPlugins(); + std::sort(m_enabledVcsPlugins.begin(), m_enabledVcsPlugins.end()); +} + +ContextMenuSettingsPage::~ContextMenuSettingsPage() { +} + +void ContextMenuSettingsPage::applySettings() +{ + if (!m_initialized) { + return; + } + + KConfig config(QStringLiteral("kservicemenurc"), KConfig::NoGlobals); + KConfigGroup showGroup = config.group("Show"); + + QStringList enabledPlugins; + + const QAbstractItemModel *model = m_listView->model(); + for (int i = 0; i < model->rowCount(); ++i) { + const QModelIndex index = model->index(i, 0); + const QString service = model->data(index, ServiceModel::DesktopEntryNameRole).toString(); + const bool checked = model->data(index, Qt::CheckStateRole).toBool(); + + if (service.startsWith(VersionControlServicePrefix)) { + if (checked) { + enabledPlugins.append(model->data(index, Qt::DisplayRole).toString()); + } + } else if (service == QLatin1String(DeleteService)) { + KSharedConfig::Ptr globalConfig = KSharedConfig::openConfig(QStringLiteral("kdeglobals"), KConfig::NoGlobals); + KConfigGroup configGroup(globalConfig, "KDE"); + configGroup.writeEntry("ShowDeleteCommand", checked); + configGroup.sync(); + } else if (service == QLatin1String(CopyToMoveToService)) { + ContextMenuSettings::setShowCopyMoveMenu(checked); + ContextMenuSettings::self()->save(); + } else if (service == QLatin1String(AddToPlacesService)) { + ContextMenuSettings::setShowAddToPlaces(checked); + ContextMenuSettings::self()->save(); + } else if (service == QLatin1String(SortByService)) { + ContextMenuSettings::setShowSortBy(checked); + ContextMenuSettings::self()->save(); + } else if (service == QLatin1String(ViewModeService)) { + ContextMenuSettings::setShowViewMode(checked); + ContextMenuSettings::self()->save(); + } else if (service == QLatin1String(OpenInNewTabService)) { + ContextMenuSettings::setShowOpenInNewTab(checked); + ContextMenuSettings::self()->save(); + } else if (service == QLatin1String(OpenInNewWindowService)) { + ContextMenuSettings::setShowOpenInNewWindow(checked); + ContextMenuSettings::self()->save(); + } else if (service == QLatin1String(CopyLocationService)) { + ContextMenuSettings::setShowCopyLocation(checked); + ContextMenuSettings::self()->save(); + } else if (service == QLatin1String(DuplicateHereService)) { + ContextMenuSettings::setShowDuplicateHere(checked); + ContextMenuSettings::self()->save(); + } else { + showGroup.writeEntry(service, checked); + } + } + + showGroup.sync(); + + if (m_enabledVcsPlugins != enabledPlugins) { + VersionControlSettings::setEnabledPlugins(enabledPlugins); + VersionControlSettings::self()->save(); + + KMessageBox::information(window(), + i18nc("@info", "Dolphin must be restarted to apply the " + "updated version control systems settings."), + QString(), // default title + QStringLiteral("ShowVcsRestartInformation")); + } +} + +void ContextMenuSettingsPage::restoreDefaults() +{ + QAbstractItemModel* model = m_listView->model(); + for (int i = 0; i < model->rowCount(); ++i) { + const QModelIndex index = model->index(i, 0); + const QString service = model->data(index, ServiceModel::DesktopEntryNameRole).toString(); + + const bool checked = !service.startsWith(VersionControlServicePrefix) + && service != QLatin1String(DeleteService) + && service != QLatin1String(CopyToMoveToService); + model->setData(index, checked, Qt::CheckStateRole); + } +} + +void ContextMenuSettingsPage::showEvent(QShowEvent* event) +{ + if (!event->spontaneous() && !m_initialized) { + loadServices(); + + loadVersionControlSystems(); + + // Add "Show 'Delete' command" as service + KSharedConfig::Ptr globalConfig = KSharedConfig::openConfig(QStringLiteral("kdeglobals"), KConfig::IncludeGlobals); + KConfigGroup configGroup(globalConfig, "KDE"); + addRow(QStringLiteral("edit-delete"), + i18nc("@option:check", "Delete"), + DeleteService, + configGroup.readEntry("ShowDeleteCommand", ShowDeleteDefault)); + + // Add "Show 'Copy To' and 'Move To' commands" as service + addRow(QStringLiteral("edit-copy"), + i18nc("@option:check", "'Copy To' and 'Move To' commands"), + CopyToMoveToService, + ContextMenuSettings::showCopyMoveMenu()); + + // Add other built-in actions + addRow(QStringLiteral("bookmark-new"), + i18nc("@option:check", "Add to Places"), + AddToPlacesService, + ContextMenuSettings::showAddToPlaces()); + addRow(QStringLiteral("view-sort"), + i18nc("@option:check", "Sort By"), + SortByService, + ContextMenuSettings::showSortBy()); + addRow(QStringLiteral("view-list-icons"), + i18nc("@option:check", "View Mode"), + ViewModeService, + ContextMenuSettings::showViewMode()); + addRow(QStringLiteral("folder-new"), + i18nc("@option:check", "'Open in New Tab' and 'Open in New Tabs'"), + OpenInNewTabService, + ContextMenuSettings::showOpenInNewTab()); + addRow(QStringLiteral("window-new"), + i18nc("@option:check", "Open in New Window"), + OpenInNewWindowService, + ContextMenuSettings::showOpenInNewWindow()); + addRow(QStringLiteral("edit-copy"), + i18nc("@option:check", "Copy Location"), + CopyLocationService, + ContextMenuSettings::showCopyLocation()); + addRow(QStringLiteral("edit-copy"), + i18nc("@option:check", "Duplicate Here"), + DuplicateHereService, + ContextMenuSettings::showDuplicateHere()); + + m_sortModel->sort(Qt::DisplayRole); + + m_initialized = true; + } + SettingsPageBase::showEvent(event); +} + +void ContextMenuSettingsPage::loadServices() +{ + const KConfig config(QStringLiteral("kservicemenurc"), KConfig::NoGlobals); + const KConfigGroup showGroup = config.group("Show"); + + // Load generic services + const KService::List entries = KServiceTypeTrader::self()->query(QStringLiteral("KonqPopupMenu/Plugin")); + for (const KService::Ptr &service : entries) { + const QString file = QStandardPaths::locate(QStandardPaths::GenericDataLocation, "kservices5/" % service->entryPath()); + const QList serviceActions = KDesktopFileActions::userDefinedServices(file, true); + + const KDesktopFile desktopFile(file); + const QString subMenuName = desktopFile.desktopGroup().readEntry("X-KDE-Submenu"); + + for (const KServiceAction &action : serviceActions) { + const QString serviceName = action.name(); + const bool addService = !action.noDisplay() && !action.isSeparator() && !isInServicesList(serviceName); + + if (addService) { + const QString itemName = subMenuName.isEmpty() + ? action.text() + : i18nc("@item:inmenu", "%1: %2", subMenuName, action.text()); + const bool checked = showGroup.readEntry(serviceName, true); + addRow(action.icon(), itemName, serviceName, checked); + } + } + } + + // Load service plugins that implement the KFileItemActionPlugin interface + const KService::List pluginServices = KServiceTypeTrader::self()->query(QStringLiteral("KFileItemAction/Plugin")); + for (const KService::Ptr &service : pluginServices) { + const QString desktopEntryName = service->desktopEntryName(); + if (!isInServicesList(desktopEntryName)) { + const bool checked = showGroup.readEntry(desktopEntryName, true); + addRow(service->icon(), service->name(), desktopEntryName, checked); + } + } + + // Load JSON-based plugins that implement the KFileItemActionPlugin interface + const auto jsonPlugins = KPluginLoader::findPlugins(QStringLiteral("kf5/kfileitemaction"), [](const KPluginMetaData& metaData) { + return metaData.serviceTypes().contains(QLatin1String("KFileItemAction/Plugin")); + }); + + for (const auto &jsonMetadata : jsonPlugins) { + const QString desktopEntryName = jsonMetadata.pluginId(); + if (!isInServicesList(desktopEntryName)) { + const bool checked = showGroup.readEntry(desktopEntryName, true); + addRow(jsonMetadata.iconName(), jsonMetadata.name(), desktopEntryName, checked); + } + } + + m_sortModel->sort(Qt::DisplayRole); + m_searchLineEdit->setFocus(Qt::OtherFocusReason); +} + +void ContextMenuSettingsPage::loadVersionControlSystems() +{ + const QStringList enabledPlugins = VersionControlSettings::enabledPlugins(); + + // Create a checkbox for each available version control plugin + const KService::List pluginServices = KServiceTypeTrader::self()->query(QStringLiteral("FileViewVersionControlPlugin")); + for (const auto &plugin : pluginServices) { + const QString pluginName = plugin->name(); + addRow(QStringLiteral("code-class"), + pluginName, + VersionControlServicePrefix + pluginName, + enabledPlugins.contains(pluginName)); + } + + m_sortModel->sort(Qt::DisplayRole); +} + +bool ContextMenuSettingsPage::isInServicesList(const QString &service) const +{ + for (int i = 0; i < m_serviceModel->rowCount(); ++i) { + const QModelIndex index = m_serviceModel->index(i, 0); + if (m_serviceModel->data(index, ServiceModel::DesktopEntryNameRole).toString() == service) { + return true; + } + } + return false; +} + +void ContextMenuSettingsPage::addRow(const QString &icon, + const QString &text, + const QString &value, + bool checked) +{ + m_serviceModel->insertRow(0); + + const QModelIndex index = m_serviceModel->index(0, 0); + m_serviceModel->setData(index, icon, Qt::DecorationRole); + m_serviceModel->setData(index, text, Qt::DisplayRole); + m_serviceModel->setData(index, value, ServiceModel::DesktopEntryNameRole); + m_serviceModel->setData(index, checked, Qt::CheckStateRole); +} diff --git a/src/settings/contextmenu/contextmenusettingspage.h b/src/settings/contextmenu/contextmenusettingspage.h new file mode 100644 index 000000000..3825e6f86 --- /dev/null +++ b/src/settings/contextmenu/contextmenusettingspage.h @@ -0,0 +1,69 @@ +/* + * SPDX-FileCopyrightText: 2009-2010 Peter Penz + * + * SPDX-License-Identifier: GPL-2.0-or-later + */ +#ifndef CONTEXTMENUSETTINGSPAGE_H +#define CONTEXTMENUSETTINGSPAGE_H + +#include "settings/settingspagebase.h" + +#include + +class QListView; +class QSortFilterProxyModel; +class ServiceModel; +class QLineEdit; + +/** + * @brief Configurations for services in the context menu. + */ +class ContextMenuSettingsPage : public SettingsPageBase +{ + Q_OBJECT + +public: + explicit ContextMenuSettingsPage(QWidget* parent); + ~ContextMenuSettingsPage() override; + + /** @see SettingsPageBase::applySettings() */ + void applySettings() override; + + /** @see SettingsPageBase::restoreDefaults() */ + void restoreDefaults() override; + +protected: + void showEvent(QShowEvent* event) override; + +private slots: + /** + * Loads locally installed services. + */ + void loadServices(); + +private: + /** + * Loads installed version control systems. + */ + void loadVersionControlSystems(); + + bool isInServicesList(const QString &service) const; + + /** + * Adds a row to the model of m_listView. + */ + void addRow(const QString &icon, + const QString &text, + const QString &value, + bool checked); + +private: + bool m_initialized; + ServiceModel *m_serviceModel; + QSortFilterProxyModel *m_sortModel; + QListView* m_listView; + QLineEdit *m_searchLineEdit; + QStringList m_enabledVcsPlugins; +}; + +#endif diff --git a/src/settings/contextmenu/servicemenu.knsrc b/src/settings/contextmenu/servicemenu.knsrc new file mode 100644 index 000000000..0d1c103f6 --- /dev/null +++ b/src/settings/contextmenu/servicemenu.knsrc @@ -0,0 +1,9 @@ +[KNewStuff2] +ProvidersUrl=https://download.kde.org/ocs/providers.xml +Categories=Dolphin Service Menus +ChecksumPolicy=ifpossible +SignaturePolicy=ifpossible +TargetDir=servicemenu-download +Uncompress=never +InstallationCommand=servicemenuinstaller install %f +UninstallCommand=servicemenuinstaller uninstall %f diff --git a/src/settings/contextmenu/servicemenuinstaller/CMakeLists.txt b/src/settings/contextmenu/servicemenuinstaller/CMakeLists.txt new file mode 100644 index 000000000..46b159079 --- /dev/null +++ b/src/settings/contextmenu/servicemenuinstaller/CMakeLists.txt @@ -0,0 +1,15 @@ +remove_definitions(-DTRANSLATION_DOMAIN=\"dolphin\") +add_definitions(-DTRANSLATION_DOMAIN=\"dolphin_servicemenuinstaller\") + +add_executable(servicemenuinstaller servicemenuinstaller.cpp) +target_link_libraries(servicemenuinstaller PRIVATE + Qt5::Core + Qt5::Gui + KF5::I18n + KF5::CoreAddons +) + +if(HAVE_PACKAGEKIT) + target_link_libraries(servicemenuinstaller PRIVATE PK::packagekitqt5) +endif() +install(TARGETS servicemenuinstaller ${KDE_INSTALL_TARGETS_DEFAULT_ARGS}) diff --git a/src/settings/contextmenu/servicemenuinstaller/Messages.sh b/src/settings/contextmenu/servicemenuinstaller/Messages.sh new file mode 100755 index 000000000..5012eead6 --- /dev/null +++ b/src/settings/contextmenu/servicemenuinstaller/Messages.sh @@ -0,0 +1,2 @@ +#! /usr/bin/env bash +$XGETTEXT `find . -name \*.cpp` -o $podir/dolphin_servicemenuinstaller.pot diff --git a/src/settings/contextmenu/servicemenuinstaller/servicemenuinstaller.cpp b/src/settings/contextmenu/servicemenuinstaller/servicemenuinstaller.cpp new file mode 100644 index 000000000..91da3d256 --- /dev/null +++ b/src/settings/contextmenu/servicemenuinstaller/servicemenuinstaller.cpp @@ -0,0 +1,462 @@ +/* + * SPDX-FileCopyrightText: 2019 Alexander Potashev + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../../../config-packagekit.h" + +Q_GLOBAL_STATIC_WITH_ARGS(QStringList, binaryPackages, ({QLatin1String("application/vnd.debian.binary-package"), + QLatin1String("application/x-rpm"), + QLatin1String("application/x-xz"), + QLatin1String("application/zstd")})) + +enum PackageOperation { + Install, + Uninstall +}; + +#ifdef HAVE_PACKAGEKIT +#include +#include +#include +#else +#include +#endif + +// @param msg Error that gets logged to CLI +Q_NORETURN void fail(const QString &str) +{ + qCritical() << str; + const QStringList args = {"--detailederror" ,i18n("Dolphin service menu installation failed"), str}; + QProcess::startDetached("kdialog", args); + + exit(1); +} + +QString getServiceMenusDir() +{ + const QString dataLocation = QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation); + return QDir(dataLocation).absoluteFilePath("kservices5/ServiceMenus"); +} + +#ifdef HAVE_PACKAGEKIT +void packageKitInstall(const QString &fileName) +{ + PackageKit::Transaction *transaction = PackageKit::Daemon::installFile(fileName, PackageKit::Transaction::TransactionFlagNone); + + const auto exitWithError = [=](PackageKit::Transaction::Error, const QString &details) { + fail(details); + }; + + QObject::connect(transaction, &PackageKit::Transaction::finished, + [=](PackageKit::Transaction::Exit status, uint) { + if (status == PackageKit::Transaction::ExitSuccess) { + exit(0); + } + // Fallback error handling + QTimer::singleShot(500, [=](){ + fail(i18n("Failed to install \"%1\", exited with status \"%2\"", + fileName, QVariant::fromValue(status).toString())); + }); + }); + QObject::connect(transaction, &PackageKit::Transaction::errorCode, exitWithError); +} + +void packageKitUninstall(const QString &fileName) +{ + const auto exitWithError = [=](PackageKit::Transaction::Error, const QString &details) { + fail(details); + }; + const auto uninstallLambda = [=](PackageKit::Transaction::Exit status, uint) { + if (status == PackageKit::Transaction::ExitSuccess) { + exit(0); + } + }; + + PackageKit::Transaction *transaction = PackageKit::Daemon::getDetailsLocal(fileName); + QObject::connect(transaction, &PackageKit::Transaction::details, + [=](const PackageKit::Details &details) { + PackageKit::Transaction *transaction = PackageKit::Daemon::removePackage(details.packageId()); + QObject::connect(transaction, &PackageKit::Transaction::finished, uninstallLambda); + QObject::connect(transaction, &PackageKit::Transaction::errorCode, exitWithError); + }); + + QObject::connect(transaction, &PackageKit::Transaction::errorCode, exitWithError); + // Fallback error handling + QObject::connect(transaction, &PackageKit::Transaction::finished, + [=](PackageKit::Transaction::Exit status, uint) { + if (status != PackageKit::Transaction::ExitSuccess) { + QTimer::singleShot(500, [=]() { + fail(i18n("Failed to uninstall \"%1\", exited with status \"%2\"", + fileName, QVariant::fromValue(status).toString())); + }); + } + }); + } +#endif + +Q_NORETURN void packageKit(PackageOperation operation, const QString &fileName) +{ +#ifdef HAVE_PACKAGEKIT + QFileInfo fileInfo(fileName); + if (!fileInfo.exists()) { + fail(i18n("The file does not exist!")); + } + const QString absPath = fileInfo.absoluteFilePath(); + if (operation == PackageOperation::Install) { + packageKitInstall(absPath); + } else { + packageKitUninstall(absPath); + } + QGuiApplication::exec(); // For event handling, no return after signals finish + fail(i18n("Unknown error when installing package")); +#else + Q_UNUSED(operation) + QDesktopServices::openUrl(QUrl(fileName)); + exit(0); +#endif +} + +struct UncompressCommand +{ + QString command; + QStringList args1; + QStringList args2; +}; + +enum ScriptExecution{ + Process, + Konsole +}; + +void runUncompress(const QString &inputPath, const QString &outputPath) +{ + QVector> mimeTypeToCommand; + mimeTypeToCommand.append({{"application/x-tar", "application/tar", "application/x-gtar", "multipart/x-tar"}, + UncompressCommand({"tar", {"-xf"}, {"-C"}})}); + mimeTypeToCommand.append({{"application/x-gzip", "application/gzip", + "application/x-gzip-compressed-tar", "application/gzip-compressed-tar", + "application/x-gzip-compressed", "application/gzip-compressed", + "application/tgz", "application/x-compressed-tar", + "application/x-compressed-gtar", "file/tgz", + "multipart/x-tar-gz", "application/x-gunzip", "application/gzipped", + "gzip/document"}, + UncompressCommand({"tar", {"-zxf"}, {"-C"}})}); + mimeTypeToCommand.append({{"application/bzip", "application/bzip2", "application/x-bzip", + "application/x-bzip2", "application/bzip-compressed", + "application/bzip2-compressed", "application/x-bzip-compressed", + "application/x-bzip2-compressed", "application/bzip-compressed-tar", + "application/bzip2-compressed-tar", "application/x-bzip-compressed-tar", + "application/x-bzip2-compressed-tar", "application/x-bz2"}, + UncompressCommand({"tar", {"-jxf"}, {"-C"}})}); + mimeTypeToCommand.append({{"application/zip", "application/x-zip", "application/x-zip-compressed", + "multipart/x-zip"}, + UncompressCommand({"unzip", {}, {"-d"}})}); + + const auto mime = QMimeDatabase().mimeTypeForFile(inputPath).name(); + + UncompressCommand command{}; + for (const auto &pair : qAsConst(mimeTypeToCommand)) { + if (pair.first.contains(mime)) { + command = pair.second; + break; + } + } + + if (command.command.isEmpty()) { + fail(i18n("Unsupported archive type %1: %2", mime, inputPath)); + } + + QProcess process; + process.start( + command.command, + QStringList() << command.args1 << inputPath << command.args2 << outputPath, + QIODevice::NotOpen); + if (!process.waitForStarted()) { + fail(i18n("Failed to run uncompressor command for %1", inputPath)); + } + + if (!process.waitForFinished()) { + fail( + i18n("Process did not finish in reasonable time: %1 %2", process.program(), process.arguments().join(" "))); + } + + if (process.exitStatus() != QProcess::NormalExit || process.exitCode() != 0) { + fail(i18n("Failed to uncompress %1", inputPath)); + } +} + +QString findRecursive(const QString &dir, const QString &basename) +{ + QDirIterator it(dir, QStringList{basename}, QDir::Files, QDirIterator::Subdirectories); + while (it.hasNext()) { + return QFileInfo(it.next()).canonicalFilePath(); + } + + return QString(); +} + +bool runScriptOnce(const QString &path, const QStringList &args, ScriptExecution execution) +{ + QProcess process; + process.setWorkingDirectory(QFileInfo(path).absolutePath()); + + const static bool konsoleAvailable = !QStandardPaths::findExecutable("konsole").isEmpty(); + if (konsoleAvailable && execution == ScriptExecution::Konsole) { + QString bashCommand = KShell::quoteArg(path) + ' '; + if (!args.isEmpty()) { + bashCommand.append(args.join(' ')); + } + bashCommand.append("|| $SHELL"); + // If the install script fails a shell opens and the user can fix the problem + // without an error konsole closes + process.start("konsole", QStringList() << "-e" << "bash" << "-c" << bashCommand, QIODevice::NotOpen); + } else { + process.start(path, args, QIODevice::NotOpen); + } + if (!process.waitForStarted()) { + fail(i18n("Failed to run installer script %1", path)); + } + + // Wait until installer exits, without timeout + if (!process.waitForFinished(-1)) { + qWarning() << "Failed to wait on installer:" << process.program() << process.arguments().join(" "); + return false; + } + + if (process.exitStatus() != QProcess::NormalExit || process.exitCode() != 0) { + qWarning() << "Installer script exited with error:" << process.program() << process.arguments().join(" "); + return false; + } + + return true; +} + +// If hasArgVariants is true, run "path". +// If hasArgVariants is false, run "path argVariants[i]" until successful. +bool runScriptVariants(const QString &path, bool hasArgVariants, const QStringList &argVariants, QString &errorText) +{ + QFile file(path); + if (!file.setPermissions(QFile::ReadOwner | QFile::WriteOwner | QFile::ExeOwner)) { + errorText = i18n("Failed to set permissions on %1: %2", path, file.errorString()); + return false; + } + + qInfo() << "[servicemenuinstaller]: Trying to run installer/uninstaller" << path; + if (hasArgVariants) { + for (const auto &arg : argVariants) { + if (runScriptOnce(path, {arg}, ScriptExecution::Process)) { + return true; + } + } + } else if (runScriptOnce(path, {}, ScriptExecution::Konsole)) { + return true; + } + + errorText = i18nc( + "%2 = comma separated list of arguments", + "Installer script %1 failed, tried arguments \"%2\".", path, argVariants.join(i18nc("Separator between arguments", "\", \""))); + return false; +} + +QString generateDirPath(const QString &archive) +{ + return QStringLiteral("%1-dir").arg(archive); +} + +bool cmdInstall(const QString &archive, QString &errorText) +{ + const auto serviceDir = getServiceMenusDir(); + if (!QDir().mkpath(serviceDir)) { + // TODO Cannot get error string because of this bug: https://bugreports.qt.io/browse/QTBUG-1483 + errorText = i18n("Failed to create path %1", serviceDir); + return false; + } + + if (archive.endsWith(QLatin1String(".desktop"))) { + // Append basename to destination directory + const auto dest = QDir(serviceDir).absoluteFilePath(QFileInfo(archive).fileName()); + if (QFileInfo::exists(dest)) { + QFile::remove(dest); + } + qInfo() << "Single-File Service-Menu" << archive << dest; + + QFile source(archive); + if (!source.copy(dest)) { + errorText = i18n("Failed to copy .desktop file %1 to %2: %3", archive, dest, source.errorString()); + return false; + } + } else { + if (binaryPackages->contains(QMimeDatabase().mimeTypeForFile(archive).name())) { + packageKit(PackageOperation::Install, archive); + } + const QString dir = generateDirPath(archive); + if (QFile::exists(dir)) { + if (!QDir(dir).removeRecursively()) { + errorText = i18n("Failed to remove directory %1", dir); + return false; + } + } + + if (QDir().mkdir(dir)) { + errorText = i18n("Failed to create directory %1", dir); + } + + runUncompress(archive, dir); + + // Try "install-it" first + QString installItPath; + const QStringList basenames1 = {"install-it.sh", "install-it"}; + for (const auto &basename : basenames1) { + const auto path = findRecursive(dir, basename); + if (!path.isEmpty()) { + installItPath = path; + break; + } + } + + if (!installItPath.isEmpty()) { + return runScriptVariants(installItPath, false, QStringList{}, errorText); + } + + // If "install-it" is missing, try "install" + QString installerPath; + const QStringList basenames2 = {"installKDE4.sh", "installKDE4", "install.sh", "install"}; + for (const auto &basename : basenames2) { + const auto path = findRecursive(dir, basename); + if (!path.isEmpty()) { + installerPath = path; + break; + } + } + + if (!installerPath.isEmpty()) { + // Try to run script without variants first + if (!runScriptVariants(installerPath, false, {}, errorText)) { + return runScriptVariants(installerPath, true, {"--local", "--local-install", "--install"}, errorText); + } + return true; + } + + fail(i18n("Failed to find an installation script in %1", dir)); + } + + return true; +} + +bool cmdUninstall(const QString &archive, QString &errorText) +{ + const auto serviceDir = getServiceMenusDir(); + if (archive.endsWith(QLatin1String(".desktop"))) { + // Append basename to destination directory + const auto dest = QDir(serviceDir).absoluteFilePath(QFileInfo(archive).fileName()); + QFile file(dest); + if (!file.remove()) { + errorText = i18n("Failed to remove .desktop file %1: %2", dest, file.errorString()); + return false; + } + } else { + if (binaryPackages->contains(QMimeDatabase().mimeTypeForFile(archive).name())) { + packageKit(PackageOperation::Uninstall, archive); + } + const QString dir = generateDirPath(archive); + + // Try "deinstall" first + QString deinstallPath; + const QStringList basenames1 = {"uninstall.sh", "uninstal", "deinstall.sh", "deinstall"}; + for (const auto &basename : basenames1) { + const auto path = findRecursive(dir, basename); + if (!path.isEmpty()) { + deinstallPath = path; + break; + } + } + + if (!deinstallPath.isEmpty()) { + const bool ok = runScriptVariants(deinstallPath, false, {}, errorText); + if (!ok) { + return ok; + } + } else { + // If "deinstall" is missing, try "install --uninstall" + QString installerPath; + const QStringList basenames2 = {"install-it.sh", "install-it", "installKDE4.sh", + "installKDE4", "install.sh", "install"}; + for (const auto &basename : basenames2) { + const auto path = findRecursive(dir, basename); + if (!path.isEmpty()) { + installerPath = path; + break; + } + } + + if (!installerPath.isEmpty()) { + const bool ok = runScriptVariants(installerPath, true, + {"--remove", "--delete", "--uninstall", "--deinstall"}, errorText); + if (!ok) { + return ok; + } + } else { + fail(i18n("Failed to find an uninstallation script in %1", dir)); + } + } + + QDir dirObject(dir); + if (!dirObject.removeRecursively()) { + errorText = i18n("Failed to remove directory %1", dir); + return false; + } + } + + return true; +} + +int main(int argc, char *argv[]) +{ + QGuiApplication app(argc, argv); + + QCommandLineParser parser; + parser.addPositionalArgument(QStringLiteral("command"), i18nc("@info:shell", "Command to execute: install or uninstall.")); + parser.addPositionalArgument(QStringLiteral("path"), i18nc("@info:shell", "Path to archive.")); + parser.process(app); + + const QStringList args = parser.positionalArguments(); + if (args.isEmpty()) { + fail(i18n("Command is required.")); + } + if (args.size() == 1) { + fail(i18n("Path to archive is required.")); + } + + const QString cmd = args[0]; + const QString archive = args[1]; + + QString errorText; + if (cmd == QLatin1String("install")) { + if (!cmdInstall(archive, errorText)) { + fail(errorText); + } + } else if (cmd == QLatin1String("uninstall")) { + if (!cmdUninstall(archive, errorText)) { + fail(errorText); + } + } else { + fail(i18n("Unsupported command %1", cmd)); + } + + return 0; +} diff --git a/src/settings/contextmenu/test/service_menu_deinstallation_test.rb b/src/settings/contextmenu/test/service_menu_deinstallation_test.rb new file mode 100644 index 000000000..bf44b7b7f --- /dev/null +++ b/src/settings/contextmenu/test/service_menu_deinstallation_test.rb @@ -0,0 +1,112 @@ +#!/usr/bin/env ruby + +# SPDX-FileCopyrightText: 2019 Harald Sitter +# +# SPDX-License-Identifier: GPL-2.0-or-later + +require_relative 'test_helper' + +require 'tmpdir' + +class ServiceMenuDeinstallationTest < Test::Unit::TestCase + def setup + @tmpdir = Dir.mktmpdir("dolphintest-#{self.class.to_s.tr(':', '_')}") + @pwdir = Dir.pwd + Dir.chdir(@tmpdir) + + ENV['XDG_DATA_HOME'] = File.join(@tmpdir, 'data') + end + + def teardown + Dir.chdir(@pwdir) + FileUtils.rm_rf(@tmpdir) + + ENV.delete('XDG_DATA_HOME') + end + + def test_run_deinstall + service_dir = File.join(Dir.pwd, 'share/servicemenu-download') + archive_base = "#{service_dir}/foo.zip" + archive_dir = "#{archive_base}-dir/foo-1.1/" + FileUtils.mkpath(archive_dir) + File.write("#{archive_dir}/deinstall.sh", <<-DEINSTALL_SH) +#!/bin/sh +set -e +cat deinstall.sh +touch #{@tmpdir}/deinstall.sh-run + DEINSTALL_SH + File.write("#{archive_dir}/install.sh", <<-INSTALL_SH) +#!/bin/sh +set -e +cat install.sh +touch #{@tmpdir}/install.sh-run + INSTALL_SH + + assert(system('servicemenuinstaller', 'uninstall', archive_base)) + + # deinstaller should be run + # installer should not be run + # archive_dir should have been correctly removed + + assert_path_exist('deinstall.sh-run') + assert_path_not_exist('install.sh-run') + assert_path_not_exist(archive_dir) + end + + def test_run_install_with_arg + service_dir = File.join(Dir.pwd, 'share/servicemenu-download') + archive_base = "#{service_dir}/foo.zip" + archive_dir = "#{archive_base}-dir/foo-1.1/" + FileUtils.mkpath(archive_dir) + + File.write("#{archive_dir}/install.sh", <<-INSTALL_SH) +#!/bin/sh +if [ "$@" = "--uninstall" ]; then + touch #{@tmpdir}/install.sh-run + exit 0 +fi +exit 1 + INSTALL_SH + + assert(system('servicemenuinstaller', 'uninstall', archive_base)) + + assert_path_not_exist('deinstall.sh-run') + assert_path_exist('install.sh-run') + assert_path_not_exist(archive_dir) + end + + # no scripts in sight + def test_run_fail + service_dir = File.join(Dir.pwd, 'share/servicemenu-download') + archive_base = "#{service_dir}/foo.zip" + archive_dir = "#{archive_base}-dir/foo-1.1/" + FileUtils.mkpath(archive_dir) + + refute(system('servicemenuinstaller', 'uninstall', archive_base)) + + # I am unsure if deinstallation really should keep the files around. But + # that's how it behaved originally so it's supposedly intentional + # - sitter, 2019 + assert_path_exist(archive_dir) + end + + # For desktop files things are a bit special. There is one in .local/share/servicemenu-download + # and another in the actual ServiceMenus dir. The latter gets removed by the + # script, the former by KNS. + def test_run_desktop + service_dir = File.join(Dir.pwd, 'share/servicemenu-download') + downloaded_file = "#{service_dir}/foo.desktop" + FileUtils.mkpath(service_dir) + FileUtils.touch(downloaded_file) + + menu_dir = "#{ENV['XDG_DATA_HOME']}/kservices5/ServiceMenus/" + installed_file = "#{menu_dir}/foo.desktop" + FileUtils.mkpath(menu_dir) + FileUtils.touch(installed_file) + + assert(system('servicemenuinstaller', 'uninstall', downloaded_file)) + + assert_path_exist(downloaded_file) + assert_path_not_exist(installed_file) + end +end diff --git a/src/settings/contextmenu/test/service_menu_installation_test.rb b/src/settings/contextmenu/test/service_menu_installation_test.rb new file mode 100644 index 000000000..7c05a40e3 --- /dev/null +++ b/src/settings/contextmenu/test/service_menu_installation_test.rb @@ -0,0 +1,106 @@ +#!/usr/bin/env ruby + +# SPDX-FileCopyrightText: 2019 Harald Sitter +# +# SPDX-License-Identifier: GPL-2.0-or-later + +require_relative 'test_helper' + +require 'tmpdir' + +class ServiceMenuInstallationTest < Test::Unit::TestCase + def setup + @tmpdir = Dir.mktmpdir("dolphintest-#{self.class.to_s.tr(':', '_')}") + @pwdir = Dir.pwd + Dir.chdir(@tmpdir) + + ENV['XDG_DATA_HOME'] = File.join(@tmpdir, 'data') + end + + def teardown + Dir.chdir(@pwdir) + FileUtils.rm_rf(@tmpdir) + + ENV.delete('XDG_DATA_HOME') + end + + def test_run_install + service_dir = File.join(Dir.pwd, 'share/servicemenu-download') + FileUtils.mkpath(service_dir) + archive = "#{service_dir}/foo.tar" + + archive_dir = 'foo' # relative so tar cf is relative without fuzz + FileUtils.mkpath(archive_dir) + File.write("#{archive_dir}/install-it.sh", <<-INSTALL_IT_SH) +#!/bin/sh +touch #{@tmpdir}/install-it.sh-run +INSTALL_IT_SH + File.write("#{archive_dir}/install.sh", <<-INSTALL_SH) +#!/bin/sh +touch #{@tmpdir}/install.sh-run + INSTALL_SH + assert(system('tar', '-cf', archive, archive_dir)) + + assert(system('servicemenuinstaller', 'install', archive)) + + tar_dir = "#{service_dir}/foo.tar-dir" + tar_extract_dir = "#{service_dir}/foo.tar-dir/foo" + assert_path_exist(tar_dir) + assert_path_exist(tar_extract_dir) + assert_path_exist("#{tar_extract_dir}/install-it.sh") + assert_path_exist("#{tar_extract_dir}/install.sh") + end + + def test_run_install_with_arg + service_dir = File.join(Dir.pwd, 'share/servicemenu-download') + FileUtils.mkpath(service_dir) + archive = "#{service_dir}/foo.tar" + + archive_dir = 'foo' # relative so tar cf is relative without fuzz + FileUtils.mkpath(archive_dir) + File.write("#{archive_dir}/install.sh", <<-INSTALL_SH) +#!/bin/sh +if [ "$@" = "--install" ]; then + touch #{@tmpdir}/install.sh-run + exit 0 +fi +exit 1 + INSTALL_SH + assert(system('tar', '-cf', archive, archive_dir)) + + assert(system('servicemenuinstaller', 'install', archive)) + + tar_dir = "#{service_dir}/foo.tar-dir" + tar_extract_dir = "#{service_dir}/foo.tar-dir/foo" + assert_path_exist(tar_dir) + assert_path_exist(tar_extract_dir) + assert_path_not_exist("#{tar_extract_dir}/install-it.sh") + assert_path_exist("#{tar_extract_dir}/install.sh") + end + + def test_run_fail + service_dir = File.join(Dir.pwd, 'share/servicemenu-download') + FileUtils.mkpath(service_dir) + archive = "#{service_dir}/foo.tar" + + archive_dir = 'foo' # relative so tar cf is relative without fuzz + FileUtils.mkpath(archive_dir) + assert(system('tar', '-cf', archive, archive_dir)) + + refute(system('servicemenuinstaller', 'install', archive)) + end + + def test_run_desktop + service_dir = File.join(Dir.pwd, 'share/servicemenu-download') + downloaded_file = "#{service_dir}/foo.desktop" + FileUtils.mkpath(service_dir) + FileUtils.touch(downloaded_file) + + installed_file = "#{ENV['XDG_DATA_HOME']}/kservices5/ServiceMenus/foo.desktop" + + assert(system('servicemenuinstaller', 'install', downloaded_file)) + + assert_path_exist(downloaded_file) + assert_path_exist(installed_file) + end +end diff --git a/src/settings/contextmenu/test/test_helper.rb b/src/settings/contextmenu/test/test_helper.rb new file mode 100644 index 000000000..b4e4dded2 --- /dev/null +++ b/src/settings/contextmenu/test/test_helper.rb @@ -0,0 +1,18 @@ +# SPDX-FileCopyrightText: 2019 Harald Sitter +# +# SPDX-License-Identifier: GPL-2.0-or-later + +$LOAD_PATH.unshift(File.absolute_path('../', __dir__)) # ../ + +def __test_method_name__ + return @method_name if defined?(:@method_name) + index = 0 + caller = '' + until caller.start_with?('test_') + caller = caller_locations(index, 1)[0].label + index += 1 + end + caller +end + +require 'test/unit' diff --git a/src/settings/contextmenu/test/test_run.rb b/src/settings/contextmenu/test/test_run.rb new file mode 100755 index 000000000..ab298a0b0 --- /dev/null +++ b/src/settings/contextmenu/test/test_run.rb @@ -0,0 +1,11 @@ +#!/usr/bin/env ruby + +# SPDX-FileCopyrightText: 2019 Harald Sitter +# +# SPDX-License-Identifier: GPL-2.0-or-later +# This is a fancy wrapper around test_helper to prevent the collector from +# loading the helper twice as it would occur if we ran the helper directly. + +require_relative 'test_helper' + +Test::Unit::AutoRunner.run(true, File.absolute_path(__dir__)) -- cgit v1.3