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
This commit is contained in:
Alexander Lohnau 2023-07-01 07:11:02 +02:00
parent c7fc01c22c
commit e170f15429
14 changed files with 258 additions and 15 deletions

View file

@ -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

View file

@ -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)

View file

@ -1,7 +1,3 @@
find_package(Qt6 ${QT_MIN_VERSION} CONFIG REQUIRED Test)
include(ECMAddTests)
include_directories(${CMAKE_CURRENT_SOURCE_DIR}/..)
ecm_add_tests(

View file

@ -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()

View file

@ -5,15 +5,17 @@ add_executable(krunner-plugininstaller
main.cpp
AbstractJob.cpp
ScriptJob.cpp
ZypperRPMJob.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})

View file

@ -0,0 +1,108 @@
/*
SPDX-FileCopyrightText: 2023 Alexander Lohnau <alexander.lohnau@gmx.de>
SPDX-License-Identifier: LGPL-2.0-or-later
*/
#include "KRunnerPlugininstallerRcJob.h"
#include "ScriptConfirmationDialog.h"
#include <KDesktopFile>
#include <QApplication>
#include <QDebug>
#include <QDir>
#include <QFileInfo>
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:<nl/>%2", dbusFile.fileName(), dbusFile.errorString()));
}
} else {
Q_EMIT error(xi18nc("@info", "Failed to remove file %1:<nl/>%2", metaDataFile.fileName(), metaDataFile.errorString()));
}
}

View file

@ -0,0 +1,24 @@
/*
SPDX-FileCopyrightText: 2023 Alexander Lohnau <alexander.lohnau@gmx.de>
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;
};

View file

@ -4,6 +4,8 @@
SPDX-License-Identifier: LGPL-2.0-or-later
*/
#include <KConfig>
#include <KConfigGroup>
#include <KLocalizedString>
#include <QDesktopServices>
#include <QDialog>
@ -14,11 +16,21 @@
#include <QPushButton>
#include <QUrl>
#include <QVBoxLayout>
#include <optional>
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<InstallerInfo> &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:<nl/>%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.<nl/>"
@ -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");

View file

@ -0,0 +1,4 @@
# SPDX-FileCopyrightText: 2022 Alexander Lohnau <alexander.lohnau@gmx.de>
# SPDX-License-Identifier: BSD-3-Clause
ecm_add_test(plugininstallerrctest.cpp)
target_link_libraries(plugininstallerrctest Qt::Test)

View file

@ -0,0 +1,68 @@
/*
SPDX-FileCopyrightText: 2023 Alexander Lohnau <alexander.lohnau@gmx.de>
SPDX-License-Identifier: LGPL-2.0-or-later
*/
#include <QFileInfo>
#include <QObject>
#include <QProcess>
#include <QStandardPaths>
#include <QTest>
#include <QTextStream>
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"

View file

@ -0,0 +1,2 @@
MetaDataFile=org.kde.testplugin.desktop
Exec=python3 %{PROJECTDIR}/main.py

View file

@ -0,0 +1,2 @@
[Desktop Entry]
X-Plasma-DBusRunner-Service=org.kde.testplugin

View file

@ -1,6 +1,5 @@
/*
SPDX-FileCopyrightText: 2020 Alexander Lohnau <alexander.lohnau@gmx.de>
SPDX-License-Identifier: LGPL-2.0-or-later
*/
@ -10,10 +9,13 @@
#include <QApplication>
#include <QCommandLineParser>
#include <QDebug>
#include <QDir>
#include <QFileInfo>
#include <QMimeDatabase>
#include <QStandardPaths>
#include <QUrl>
#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());
}