From e170f15429ffded8163b7e13af42278c3da47c78 Mon Sep 17 00:00:00 2001 From: Alexander Lohnau Date: Sat, 1 Jul 2023 07:11:02 +0200 Subject: [PATCH] kcm/runners: Allow simpler installation of DBus runners While the install.sh script that is part of the python templates worked great, it still is visually annoying to have a terminal window open when we just need to copy/generate two files. With a krunner-plugininstallerrc file, consumers can provide instructions on how to generate the dbus service and where to find the desktop file for the runner. This is intended for python runners, but can work with any other language or even prebuild binaries. In the future, we might want to add the possibility to add dependencies. But for now, the current state should suffice. Also, we could adjust the installation without having to edit the KRunner template and lots of existing KRunner plugins that are already published. Since there are a few moving parts involved, an autotest was added to ensure the files end up in the correct destination. This also checks the dynamically generated content of the service and tests the one given line of the desktop file --- .reuse/dep5 | 4 + CMakeLists.txt | 4 + .../plugins/folder/autotests/CMakeLists.txt | 4 - kcms/keyboard/CMakeLists.txt | 1 - kcms/runners/plugininstaller/CMakeLists.txt | 16 ++- .../KRunnerPluginInstallerRcJob.cpp | 108 ++++++++++++++++++ .../KRunnerPlugininstallerRcJob.h | 24 ++++ .../ScriptConfirmationDialog.h | 23 +++- .../plugininstaller/autotests/CMakeLists.txt | 4 + .../autotests/plugininstallerrctest.cpp | 68 +++++++++++ .../testplugin/krunner-plugininstallerrc | 2 + .../autotests/testplugin/main.py | 0 .../testplugin/org.kde.testplugin.desktop | 2 + kcms/runners/plugininstaller/main.cpp | 13 ++- 14 files changed, 258 insertions(+), 15 deletions(-) create mode 100644 kcms/runners/plugininstaller/KRunnerPluginInstallerRcJob.cpp create mode 100644 kcms/runners/plugininstaller/KRunnerPlugininstallerRcJob.h create mode 100644 kcms/runners/plugininstaller/autotests/CMakeLists.txt create mode 100644 kcms/runners/plugininstaller/autotests/plugininstallerrctest.cpp create mode 100644 kcms/runners/plugininstaller/autotests/testplugin/krunner-plugininstallerrc create mode 100644 kcms/runners/plugininstaller/autotests/testplugin/main.py create mode 100644 kcms/runners/plugininstaller/autotests/testplugin/org.kde.testplugin.desktop diff --git a/.reuse/dep5 b/.reuse/dep5 index 6d1808dff6..ecc04c0557 100644 --- a/.reuse/dep5 +++ b/.reuse/dep5 @@ -6,3 +6,7 @@ Source: https://invent.kde.org/plasma/plasma-desktop/ Files: emojier/data/*.dict Copyright: Unicode, Inc. License: Unicode-DFS-2016 + +Files: kcms/runners/plugininstaller/autotests +Copyright: Alexander Lohnau +License: CC0-1.0 diff --git a/CMakeLists.txt b/CMakeLists.txt index e0a0d521ea..d83d4cda63 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -73,6 +73,10 @@ find_package(KF6 ${KF6_MIN_VERSION} REQUIRED COMPONENTS IconThemes XmlGui ) +if(BUILD_TESTING) + find_package(Qt6Test ${QT_MIN_VERSION} CONFIG REQUIRED) + include(ECMAddTests) +endif() # TODO re-enable once KAccounts is ported to Qt6 # find_package(AccountsQt6 ${ACCOUNTSQT_DEP_VERSION} CONFIG) diff --git a/containments/desktop/plugins/folder/autotests/CMakeLists.txt b/containments/desktop/plugins/folder/autotests/CMakeLists.txt index d17cc32e39..71f828baf2 100644 --- a/containments/desktop/plugins/folder/autotests/CMakeLists.txt +++ b/containments/desktop/plugins/folder/autotests/CMakeLists.txt @@ -1,7 +1,3 @@ -find_package(Qt6 ${QT_MIN_VERSION} CONFIG REQUIRED Test) - -include(ECMAddTests) - include_directories(${CMAKE_CURRENT_SOURCE_DIR}/..) ecm_add_tests( diff --git a/kcms/keyboard/CMakeLists.txt b/kcms/keyboard/CMakeLists.txt index 01f39b709a..a97ee0a182 100644 --- a/kcms/keyboard/CMakeLists.txt +++ b/kcms/keyboard/CMakeLists.txt @@ -145,7 +145,6 @@ configure_file(config-keyboard.h.cmake ${CMAKE_CURRENT_BINARY_DIR}/config-keyboa # Unit tests if(BUILD_TESTING) - find_package(Qt6Test ${QT_MIN_VERSION} CONFIG REQUIRED) add_subdirectory( tests ) endif() diff --git a/kcms/runners/plugininstaller/CMakeLists.txt b/kcms/runners/plugininstaller/CMakeLists.txt index 16c0c618d8..0f95ffb73e 100644 --- a/kcms/runners/plugininstaller/CMakeLists.txt +++ b/kcms/runners/plugininstaller/CMakeLists.txt @@ -2,18 +2,20 @@ # SPDX-License-Identifier: BSD-3-Clause add_executable(krunner-plugininstaller - main.cpp - AbstractJob.cpp - ScriptJob.cpp - ZypperRPMJob.cpp) + main.cpp + AbstractJob.cpp + ScriptJob.cpp + ZypperRPMJob.cpp + KRunnerPluginInstallerRcJob.cpp +) if(HAVE_PACKAGEKIT) target_sources(krunner-plugininstaller PUBLIC PackageKitJob.cpp) endif() target_link_libraries(krunner-plugininstaller + Qt::Widgets KF6::CoreAddons KF6::I18n - Qt::Widgets KF6::Service KF6::KIOCore KF6::KIOWidgets @@ -22,4 +24,8 @@ if(HAVE_PACKAGEKIT) target_link_libraries(krunner-plugininstaller PK::packagekitqt6) endif() +if (BUILD_TESTING) + add_subdirectory(autotests) +endif() + install(TARGETS krunner-plugininstaller DESTINATION ${KDE_INSTALL_TARGETS_DEFAULT_ARGS}) diff --git a/kcms/runners/plugininstaller/KRunnerPluginInstallerRcJob.cpp b/kcms/runners/plugininstaller/KRunnerPluginInstallerRcJob.cpp new file mode 100644 index 0000000000..485f66ed1b --- /dev/null +++ b/kcms/runners/plugininstaller/KRunnerPluginInstallerRcJob.cpp @@ -0,0 +1,108 @@ +/* + SPDX-FileCopyrightText: 2023 Alexander Lohnau + SPDX-License-Identifier: LGPL-2.0-or-later +*/ +#include "KRunnerPlugininstallerRcJob.h" + +#include "ScriptConfirmationDialog.h" +#include +#include +#include +#include +#include + +static QString installDBusServicePath(const QString &dbusService) +{ + const QString dataLocation = QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation); + QDir dbusDir(dataLocation + "/dbus-1/services/"); + dbusDir.mkpath((".")); + return dbusDir.filePath(dbusService + ".service"); +} + +static QString installMetaDataPath(const QString &filePath) +{ + const QString dataLocation = QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation); + QDir krunnerPluginsDir(dataLocation + "/krunner/dbusplugins/"); + krunnerPluginsDir.mkpath((".")); + // Get the filename in case the .krunner-plugininstallerrc has specified the file to be in a subdir + return krunnerPluginsDir.filePath(QFileInfo(filePath).fileName()); +} + +static InstallerInfo getInstallerInfo(const QFileInfo dirInfo) +{ + const QDir rootDir = dirInfo.absoluteDir(); + QString installerConfigFile = dirInfo.filePath(); + Q_ASSERT(installerConfigFile.endsWith("krunner-plugininstallerrc")); + + KConfig cfg(installerConfigFile); + KConfigGroup grp = cfg.group(""); + const QString exec = grp.readEntry("Exec").replace(QStringLiteral("%{PROJECTDIR}"), dirInfo.absolutePath()); + const QString metaDataFile = grp.readEntry("MetaDataFile"); + KDesktopFile desktopFile(rootDir.absoluteFilePath(metaDataFile)); + QString dbusService = desktopFile.desktopGroup().readEntry("X-Plasma-DBusRunner-Service"); + + return {exec, dbusService, rootDir.absoluteFilePath(metaDataFile)}; +} + +void KRunnerPluginInstallerRcJob::executeOperation(const QFileInfo &fileInfo, const QString & /*mimeType*/, bool install) +{ + const InstallerInfo info = getInstallerInfo(fileInfo); + if (info.exec.isEmpty() || info.desktopFilePath.isEmpty() || info.desktopFilePath.isEmpty()) { + Q_EMIT error("The provided installer file is invalid. Please contact the author."); + return; + } + if (install) { + if (m_skipConfirmDialog || ScriptConfirmationDialog(QString(), install, fileInfo.absolutePath(), info).exec() == QDialog::Accepted) { + doInstall(info); + } else { + Q_EMIT error(QString()); + } + } else { + doUninstall(info); + } +} + +void KRunnerPluginInstallerRcJob::doInstall(const InstallerInfo &info) +{ + QFile serviceFile(installDBusServicePath(info.dbusService)); + + if (serviceFile.open(QIODevice::WriteOnly)) { + serviceFile.write( + QLatin1String("# autogenerated by krunner-plugininstaller\n[D-BUS Service]\nName=%1\nExec=%2\n").arg(info.dbusService, info.exec).toLocal8Bit()); + serviceFile.close(); + qInfo() << "Wrote autostart file" << serviceFile.fileName(); + } else { + Q_EMIT error(serviceFile.errorString()); + return; + } + + const QString targetFilePath = installMetaDataPath(info.desktopFilePath); + // In case we update an entry, simply remove a previous file + if (QFileInfo::exists(targetFilePath)) { + qInfo() << "Trying to remove old desktop file" << targetFilePath; + QFile::remove(targetFilePath); + } + + if (!QFile::copy(info.desktopFilePath, targetFilePath)) { + qWarning() << "Failed to copy " << info.desktopFilePath << targetFilePath; + Q_EMIT error(i18n("Failed to copy file %1 to %2", info.desktopFilePath, targetFilePath)); + } else { + qInfo() << "Copied desktop file to" << targetFilePath; + Q_EMIT finished(); + } +} +void KRunnerPluginInstallerRcJob::doUninstall(const InstallerInfo &info) +{ + QFile dbusFile(installDBusServicePath(info.dbusService)); + QFile metaDataFile(installMetaDataPath(info.desktopFilePath)); + + if (metaDataFile.remove()) { + if (dbusFile.remove()) { + Q_EMIT finished(); + } else { + Q_EMIT error(xi18nc("@info", "Failed to remove file %1:%2", dbusFile.fileName(), dbusFile.errorString())); + } + } else { + Q_EMIT error(xi18nc("@info", "Failed to remove file %1:%2", metaDataFile.fileName(), metaDataFile.errorString())); + } +} diff --git a/kcms/runners/plugininstaller/KRunnerPlugininstallerRcJob.h b/kcms/runners/plugininstaller/KRunnerPlugininstallerRcJob.h new file mode 100644 index 0000000000..b4d402be66 --- /dev/null +++ b/kcms/runners/plugininstaller/KRunnerPlugininstallerRcJob.h @@ -0,0 +1,24 @@ +/* + SPDX-FileCopyrightText: 2023 Alexander Lohnau + SPDX-License-Identifier: LGPL-2.0-or-later +*/ + +#pragma once + +#include "AbstractJob.h" +struct InstallerInfo; + +class KRunnerPluginInstallerRcJob : public AbstractJob +{ +public: + explicit KRunnerPluginInstallerRcJob(bool skipConfigmDialog) + : m_skipConfirmDialog(skipConfigmDialog) + { + } + void executeOperation(const QFileInfo &fileInfo, const QString &mimeType, bool install) override; + +private: + void doInstall(const InstallerInfo &info); + void doUninstall(const InstallerInfo &info); + const bool m_skipConfirmDialog; +}; diff --git a/kcms/runners/plugininstaller/ScriptConfirmationDialog.h b/kcms/runners/plugininstaller/ScriptConfirmationDialog.h index 429eb8bc02..5aa0065f81 100644 --- a/kcms/runners/plugininstaller/ScriptConfirmationDialog.h +++ b/kcms/runners/plugininstaller/ScriptConfirmationDialog.h @@ -4,6 +4,8 @@ SPDX-License-Identifier: LGPL-2.0-or-later */ +#include +#include #include #include #include @@ -14,11 +16,21 @@ #include #include #include +#include +struct InstallerInfo { + QString exec; + QString dbusService; + QString desktopFilePath; +}; class ScriptConfirmationDialog : public QDialog { public: - ScriptConfirmationDialog(const QString &installerPath, bool install, const QString &dir, QWidget *parent = nullptr) + ScriptConfirmationDialog(const QString &installerPath, + bool install, + const QString &dir, + const std::optional &info = std::nullopt, + QWidget *parent = nullptr) : QDialog(parent) { const auto readmes = QDir(dir).entryList({QStringLiteral("README*")}); @@ -27,7 +39,10 @@ public: const bool noInstaller = installerPath.isEmpty(); QVBoxLayout *layout = new QVBoxLayout(this); QString msg; - if (!install && noInstaller && readmes.isEmpty()) { + if (info) { + Q_ASSERT(install); // We do not need the warnings for uninstall + msg = xi18nc("@info", "This plugin executes the following command for starting the \"%1\" service:%2", info->dbusService, info->exec); + } else if (!install && noInstaller && readmes.isEmpty()) { msg = xi18nc("@info", "This plugin does not provide an uninstallation script. Please contact the author. " "You can try to uninstall the plugin manually." @@ -71,9 +86,9 @@ public: connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); QString okText; - if (noInstaller && !install) { + if (noInstaller && !install && !info) { okText = i18nc("@action:button", "Mark entry as uninstalled"); - } else if (noInstaller) { + } else if (noInstaller && !info) { okText = i18nc("@action:button", "Mark entry as installed"); } else { okText = i18nc("@action:button", "Accept Risk And Continue"); diff --git a/kcms/runners/plugininstaller/autotests/CMakeLists.txt b/kcms/runners/plugininstaller/autotests/CMakeLists.txt new file mode 100644 index 0000000000..933d9ae410 --- /dev/null +++ b/kcms/runners/plugininstaller/autotests/CMakeLists.txt @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: 2022 Alexander Lohnau +# SPDX-License-Identifier: BSD-3-Clause +ecm_add_test(plugininstallerrctest.cpp) +target_link_libraries(plugininstallerrctest Qt::Test) diff --git a/kcms/runners/plugininstaller/autotests/plugininstallerrctest.cpp b/kcms/runners/plugininstaller/autotests/plugininstallerrctest.cpp new file mode 100644 index 0000000000..6fea05711d --- /dev/null +++ b/kcms/runners/plugininstaller/autotests/plugininstallerrctest.cpp @@ -0,0 +1,68 @@ +/* + SPDX-FileCopyrightText: 2023 Alexander Lohnau + SPDX-License-Identifier: LGPL-2.0-or-later +*/ +#include +#include +#include +#include +#include +#include + +bool fileContainsLine(const QString &fileName, const QString &line) +{ + QFile file(fileName); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + return false; + } + + QTextStream in(&file); + while (!in.atEnd()) { + if (in.readLine() == line) { + return true; + } + } + + return false; +} + +class PluginInstallerRcTest : public QObject +{ + Q_OBJECT +private Q_SLOTS: + void initTestCase() + { + QStandardPaths::setTestModeEnabled(true); + qputenv("PLUGININSTALLER_TEST_MODE", "true"); + } + void testInstall() + { + const QString executable = QFINDTESTDATA("krunner-plugininstaller"); + QProcess installProcess; + installProcess.start(executable, {"install", QFINDTESTDATA("testplugin"), "--no-confirm"}); + + installProcess.waitForFinished(); + qDebug() << installProcess.readAllStandardError(); + QCOMPARE(installProcess.exitCode(), 0); + + const QString dataLocation = QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation); + const QString desktopLocation = dataLocation + "/krunner/dbusplugins/org.kde.testplugin.desktop"; + const QString serviceLocation = dataLocation + "/dbus-1/services/org.kde.testplugin.service"; + QVERIFY(QFileInfo::exists(desktopLocation)); + QVERIFY(QFileInfo::exists(serviceLocation)); + QVERIFY(fileContainsLine(desktopLocation, "X-Plasma-DBusRunner-Service=org.kde.testplugin")); + QVERIFY(fileContainsLine(serviceLocation, QLatin1String("Exec=python3 %1/main.py").arg(QFINDTESTDATA("testplugin")))); + + QProcess uninstallProcess; + uninstallProcess.start(executable, {"uninstall", QFINDTESTDATA("testplugin")}); + + uninstallProcess.waitForFinished(); + qDebug() << uninstallProcess.readAllStandardError(); + QVERIFY(!QFileInfo::exists(desktopLocation)); + QVERIFY(!QFileInfo::exists(serviceLocation)); + } +}; + +QTEST_MAIN(PluginInstallerRcTest) + +#include "plugininstallerrctest.moc" diff --git a/kcms/runners/plugininstaller/autotests/testplugin/krunner-plugininstallerrc b/kcms/runners/plugininstaller/autotests/testplugin/krunner-plugininstallerrc new file mode 100644 index 0000000000..fc97491bc9 --- /dev/null +++ b/kcms/runners/plugininstaller/autotests/testplugin/krunner-plugininstallerrc @@ -0,0 +1,2 @@ +MetaDataFile=org.kde.testplugin.desktop +Exec=python3 %{PROJECTDIR}/main.py diff --git a/kcms/runners/plugininstaller/autotests/testplugin/main.py b/kcms/runners/plugininstaller/autotests/testplugin/main.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/kcms/runners/plugininstaller/autotests/testplugin/org.kde.testplugin.desktop b/kcms/runners/plugininstaller/autotests/testplugin/org.kde.testplugin.desktop new file mode 100644 index 0000000000..a0d71ac87f --- /dev/null +++ b/kcms/runners/plugininstaller/autotests/testplugin/org.kde.testplugin.desktop @@ -0,0 +1,2 @@ +[Desktop Entry] +X-Plasma-DBusRunner-Service=org.kde.testplugin diff --git a/kcms/runners/plugininstaller/main.cpp b/kcms/runners/plugininstaller/main.cpp index 4b48a04c0b..37e859942e 100644 --- a/kcms/runners/plugininstaller/main.cpp +++ b/kcms/runners/plugininstaller/main.cpp @@ -1,6 +1,5 @@ /* SPDX-FileCopyrightText: 2020 Alexander Lohnau - SPDX-License-Identifier: LGPL-2.0-or-later */ @@ -10,10 +9,13 @@ #include #include #include +#include #include #include +#include #include +#include "KRunnerPlugininstallerRcJob.h" #include "config-workspace.h" #if HAVE_PACKAGEKIT #include "PackageKitJob.h" @@ -38,9 +40,15 @@ int main(int argc, char *argv[]) QApplication app(argc, argv); app.setQuitOnLastWindowClosed(false); + if (qEnvironmentVariableIsSet("PLUGININSTALLER_TEST_MODE")) { + QStandardPaths::setTestModeEnabled(true); + } + 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.")); + QCommandLineOption noConfirm(QStringLiteral("no-confirm"), i18nc("@info:shell", "Do not show a visual confirmation dialog")); + parser.addOption(noConfirm); parser.process(app); const QStringList args = parser.positionalArguments(); @@ -82,6 +90,9 @@ int main(int argc, char *argv[]) #else fail(i18nc("@info", "No PackageKit support")); #endif + } else if (const QString rcPath = QDir(file).filePath("krunner-plugininstallerrc"); fileInfo.isDir() && QFileInfo::exists(rcPath)) { + fileInfo = QFileInfo(rcPath); + job.reset(new KRunnerPluginInstallerRcJob(parser.isSet(noConfirm))); } else { job.reset(new ScriptJob()); }