Discover external tool settings and refresh cached settings UI

This commit is contained in:
ajp_anton
2026-09-07 19:47:28 +00:00
parent 93607d749f
commit ed2bb7f92c
11 changed files with 202 additions and 3 deletions
+14
View File
@@ -19,6 +19,20 @@ operations run one at a time; other actions remain disabled until DNF finishes.
The module currently configures task-group shortcut behavior, touchpad hold-tap The module currently configures task-group shortcut behavior, touchpad hold-tap
timing and output, and the experimental fingerprint workaround. timing and output, and the experimental fingerprint workaround.
Installed packages are discovered independently of repository metadata, so
locally installed tools appear even before they are published. Tools with their
own settings window can register it by installing
`/usr/share/fedora-tools/settings/PACKAGE-NAME.json`:
```json
{"command": ["/usr/bin/example-tool", "--settings"]}
```
The Configure button launches this command as the current user, without a
shell or administrator privileges. This registration needs no tool-specific
change to the settings module. The package must provide `fedora-tools-tool`;
arbitrary installed applications are not included in this list.
Installing this module does not install or activate any other tool. Tools Installing this module does not install or activate any other tool. Tools
remain usable without the module. remain usable without the module.
@@ -1,6 +1,6 @@
Name: fedora-tools-settings Name: fedora-tools-settings
Version: 0.1.0 Version: 0.1.0
Release: 7%{?dist} Release: 11%{?dist}
Summary: Plasma System Settings module for Fedora Tools Summary: Plasma System Settings module for Fedora Tools
License: MIT License: MIT
@@ -36,7 +36,15 @@ tools after administrator authorization.
%build %build
%cmake %cmake
# RCC otherwise gives every revision built on the same changelog date the
# same timestamp, allowing Qt to reuse an older QML disk cache. Keep the
# resource stamp reproducible, but make it depend on the actual UI contents.
qml_checksum=$(find src/ui -type f -print0 | sort -z | xargs -0 sha256sum | sha256sum)
(
unset SOURCE_DATE_EPOCH
export QT_RCC_SOURCE_DATE_OVERRIDE=$((16#${qml_checksum:0:8}))
%cmake_build %cmake_build
)
%check %check
%ctest %ctest
@@ -48,6 +56,13 @@ install -Dpm 0644 %{SOURCE1} \
install -Dpm 0644 %{SOURCE2} \ install -Dpm 0644 %{SOURCE2} \
%{buildroot}%{_docdir}/%{name}/README.md %{buildroot}%{_docdir}/%{name}/README.md
%post
# Releases through 0.1.0-7 let privileged DNF processes inherit KAuth's
# restrictive umask. Restore DNF's standard permissions on files they created.
for file in environments.toml groups.toml modules.toml nevras.toml packages.toml system.toml; do
test ! -e "%{_prefix}/lib/sysimage/libdnf5/$file" || chmod 0644 "%{_prefix}/lib/sysimage/libdnf5/$file"
done
%files %files
%license %{_licensedir}/%{name}/LICENSE %license %{_licensedir}/%{name}/LICENSE
%doc %{_docdir}/%{name}/README.md %doc %{_docdir}/%{name}/README.md
@@ -59,6 +74,18 @@ install -Dpm 0644 %{SOURCE2} \
%{_datadir}/polkit-1/actions/se.ajpanton.fedoratools.policy %{_datadir}/polkit-1/actions/se.ajpanton.fedoratools.policy
%changelog %changelog
* Mon Sep 07 2026 fedora-tools contributors - 0.1.0-11
- Invalidate cached QML whenever the embedded settings pages change
* Sun Sep 06 2026 fedora-tools contributors - 0.1.0-10
- Discover configuration applications registered by installed tools
* Sun Sep 06 2026 fedora-tools contributors - 0.1.0-9
- Open the Panel Actions widget's native per-panel configuration
* Sat Sep 05 2026 fedora-tools contributors - 0.1.0-8
- Preserve standard permissions on DNF system-state files
* Sat Sep 05 2026 fedora-tools contributors - 0.1.0-7 * Sat Sep 05 2026 fedora-tools contributors - 0.1.0-7
- Use the DNF upgrade transaction for installed tools - Use the DNF upgrade transaction for installed tools
@@ -51,6 +51,19 @@ QAbstractItemModel *FedoraToolsKcm::tools()
return &m_tools; return &m_tools;
} }
void FedoraToolsKcm::configureExternalTool(const QString &packageName)
{
QStringList command = m_tools.configurationCommand(packageName);
if (command.isEmpty()) {
setMessage(i18n("No configuration application is registered for %1.", packageName), true);
return;
}
const QString executable = command.takeFirst();
if (!QProcess::startDetached(executable, command)) {
setMessage(i18n("Could not open settings for %1.", packageName), true);
}
}
bool FedoraToolsKcm::busy() const bool FedoraToolsKcm::busy() const
{ {
return m_busy; return m_busy;
@@ -53,6 +53,7 @@ public:
Q_INVOKABLE void refresh(bool refreshMetadata = false); Q_INVOKABLE void refresh(bool refreshMetadata = false);
Q_INVOKABLE void clearMessage(); Q_INVOKABLE void clearMessage();
Q_INVOKABLE void configureExternalTool(const QString &packageName);
Q_INVOKABLE void installTool(const QString &packageName); Q_INVOKABLE void installTool(const QString &packageName);
Q_INVOKABLE void removeTool(const QString &packageName); Q_INVOKABLE void removeTool(const QString &packageName);
Q_INVOKABLE void saveShortcutSettings(bool startWithFirst, bool initialShiftOpensNew, bool shiftCyclesBackward); Q_INVOKABLE void saveShortcutSettings(bool startWithFirst, bool initialShiftOpensNew, bool shiftCyclesBackward);
+11
View File
@@ -10,13 +10,23 @@
#include <QProcess> #include <QProcess>
#include <QRegularExpression> #include <QRegularExpression>
#include <sys/stat.h>
namespace namespace
{ {
constexpr int commandTimeoutMs = 10 * 60 * 1000; constexpr int commandTimeoutMs = 10 * 60 * 1000;
void setChildUmask(QProcess &process)
{
process.setChildProcessModifier([]() {
umask(0022);
});
}
KAuth::ActionReply commandReply(const QString &program, const QStringList &arguments) KAuth::ActionReply commandReply(const QString &program, const QStringList &arguments)
{ {
QProcess process; QProcess process;
setChildUmask(process);
process.setProcessChannelMode(QProcess::MergedChannels); process.setProcessChannelMode(QProcess::MergedChannels);
process.start(program, arguments); process.start(program, arguments);
if (!process.waitForStarted()) { if (!process.waitForStarted()) {
@@ -52,6 +62,7 @@ KAuth::ActionReply commandReply(const QString &program, const QStringList &argum
bool isPublishedTool(const QString &packageName) bool isPublishedTool(const QString &packageName)
{ {
QProcess process; QProcess process;
setChildUmask(process);
process.start(QStringLiteral("/usr/bin/dnf5"), process.start(QStringLiteral("/usr/bin/dnf5"),
{QStringLiteral("--repo=fedora-tools"), {QStringLiteral("--repo=fedora-tools"),
QStringLiteral("-q"), QStringLiteral("-q"),
+61 -1
View File
@@ -3,10 +3,55 @@
#include "toolmodel.h" #include "toolmodel.h"
#include <QMap> #include <QMap>
#include <QDebug>
#include <QFile>
#include <QFileInfo>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QStandardPaths>
#include <algorithm> #include <algorithm>
#include <utility> #include <utility>
namespace
{
QStringList readConfigurationCommand(const QString &packageName)
{
const QString path = QStandardPaths::locate(QStandardPaths::GenericDataLocation,
QStringLiteral("fedora-tools/settings/") + packageName + QStringLiteral(".json"));
if (path.isEmpty()) {
return {};
}
QFile file(path);
if (!file.open(QIODevice::ReadOnly)) {
qWarning() << "Cannot read tool configuration registration:" << path << file.errorString();
return {};
}
QJsonParseError error;
const auto document = QJsonDocument::fromJson(file.readAll(), &error);
const auto command = document.object().value(QStringLiteral("command")).toArray();
if (error.error != QJsonParseError::NoError || command.isEmpty()) {
qWarning() << "Invalid tool configuration registration:" << path;
return {};
}
QStringList result;
for (const auto &argument : command) {
if (!argument.isString()) {
qWarning() << "Invalid tool configuration argument:" << path;
return {};
}
result.append(argument.toString());
}
const QFileInfo executable(result.first());
if (!executable.isAbsolute() || !executable.isExecutable() || !executable.isFile()) {
qWarning() << "Tool configuration executable is unavailable:" << result.first();
return {};
}
return result;
}
}
ToolModel::ToolModel(QObject *parent) ToolModel::ToolModel(QObject *parent)
: QAbstractListModel(parent) : QAbstractListModel(parent)
{ {
@@ -43,9 +88,12 @@ QVariant ToolModel::data(const QModelIndex &index, int role) const
return tool.updateAvailable; return tool.updateAvailable;
case ConfigurableRole: case ConfigurableRole:
return tool.installed return tool.installed
&& (tool.packageName == QStringLiteral("plasma-fingerprint-workaround") && (!tool.configurationCommand.isEmpty()
|| tool.packageName == QStringLiteral("plasma-fingerprint-workaround")
|| tool.packageName == QStringLiteral("plasma-task-group-shortcuts") || tool.packageName == QStringLiteral("plasma-task-group-shortcuts")
|| tool.packageName == QStringLiteral("touchpad-hold-tap")); || tool.packageName == QStringLiteral("touchpad-hold-tap"));
case ExternalConfigurationRole:
return tool.installed && !tool.configurationCommand.isEmpty();
default: default:
return {}; return {};
} }
@@ -63,6 +111,7 @@ QHash<int, QByteArray> ToolModel::roleNames() const
{AvailableRole, "available"}, {AvailableRole, "available"},
{UpdateAvailableRole, "updateAvailable"}, {UpdateAvailableRole, "updateAvailable"},
{ConfigurableRole, "configurable"}, {ConfigurableRole, "configurable"},
{ExternalConfigurationRole, "externalConfiguration"},
}; };
} }
@@ -79,6 +128,7 @@ void ToolModel::setPackages(const QList<PackageRecord> &installed,
tool.architecture = package.architecture; tool.architecture = package.architecture;
tool.summary = package.summary; tool.summary = package.summary;
tool.installed = true; tool.installed = true;
tool.configurationCommand = readConfigurationCommand(package.name);
} }
for (const PackageRecord &package : available) { for (const PackageRecord &package : available) {
@@ -116,3 +166,13 @@ bool ToolModel::mayRemove(const QString &packageName) const
return tool.packageName == packageName && tool.installed; return tool.packageName == packageName && tool.installed;
}); });
} }
QStringList ToolModel::configurationCommand(const QString &packageName) const
{
for (const Tool &tool : m_tools) {
if (tool.packageName == packageName && tool.installed) {
return tool.configurationCommand;
}
}
return {};
}
+4
View File
@@ -6,6 +6,7 @@
#include <QAbstractListModel> #include <QAbstractListModel>
#include <QSet> #include <QSet>
#include <QStringList>
struct Tool struct Tool
{ {
@@ -14,6 +15,7 @@ struct Tool
QString availableVersion; QString availableVersion;
QString architecture; QString architecture;
QString summary; QString summary;
QStringList configurationCommand;
bool installed = false; bool installed = false;
bool available = false; bool available = false;
bool updateAvailable = false; bool updateAvailable = false;
@@ -34,6 +36,7 @@ public:
AvailableRole, AvailableRole,
UpdateAvailableRole, UpdateAvailableRole,
ConfigurableRole, ConfigurableRole,
ExternalConfigurationRole,
}; };
explicit ToolModel(QObject *parent = nullptr); explicit ToolModel(QObject *parent = nullptr);
@@ -47,6 +50,7 @@ public:
const QSet<QString> &updates = {}); const QSet<QString> &updates = {});
bool mayInstall(const QString &packageName) const; bool mayInstall(const QString &packageName) const;
bool mayRemove(const QString &packageName) const; bool mayRemove(const QString &packageName) const;
QStringList configurationCommand(const QString &packageName) const;
private: private:
QList<Tool> m_tools; QList<Tool> m_tools;
+5
View File
@@ -86,6 +86,7 @@ KCM.SimpleKCM {
required property bool available required property bool available
required property bool updateAvailable required property bool updateAvailable
required property bool configurable required property bool configurable
required property bool externalConfiguration
width: toolList.width width: toolList.width
@@ -138,6 +139,10 @@ KCM.SimpleKCM {
enabled: !kcm.busy enabled: !kcm.busy
onClicked: { onClicked: {
kcm.clearMessage() kcm.clearMessage()
if (toolCard.externalConfiguration) {
kcm.configureExternalTool(toolCard.packageName)
return
}
root.settingsPage = toolCard.packageName root.settingsPage = toolCard.packageName
if (root.settingsPage === "plasma-task-group-shortcuts") { if (root.settingsPage === "plasma-task-group-shortcuts") {
shortcutPage.loadSettings() shortcutPage.loadSettings()
@@ -16,3 +16,9 @@ add_executable(test-toolmodel
target_include_directories(test-toolmodel PRIVATE ../src) target_include_directories(test-toolmodel PRIVATE ../src)
target_link_libraries(test-toolmodel PRIVATE Qt6::Core Qt6::Test) target_link_libraries(test-toolmodel PRIVATE Qt6::Core Qt6::Test)
add_test(NAME toolmodel COMMAND test-toolmodel) add_test(NAME toolmodel COMMAND test-toolmodel)
add_executable(test-qmlresource test-qmlresource.cpp)
target_link_libraries(test-qmlresource PRIVATE Qt6::Core Qt6::Test)
target_compile_definitions(test-qmlresource PRIVATE KCM_PLUGIN_PATH="$<TARGET_FILE:kcm_fedora_tools>")
add_dependencies(test-qmlresource kcm_fedora_tools)
add_test(NAME qmlresource COMMAND test-qmlresource)
@@ -0,0 +1,30 @@
// SPDX-License-Identifier: MIT
#include <QDateTime>
#include <QFile>
#include <QFileInfo>
#include <QLibrary>
#include <QTest>
class QmlResourceTest : public QObject
{
Q_OBJECT
private Q_SLOTS:
void packagedUiHasItsOwnCacheStamp()
{
QLibrary plugin(QString::fromUtf8(KCM_PLUGIN_PATH));
QVERIFY2(plugin.load(), qPrintable(plugin.errorString()));
QFile ui(QStringLiteral(":/kcm/kcm_fedora_tools/main.qml"));
QVERIFY(ui.open(QIODevice::ReadOnly));
const auto source = ui.readAll();
QVERIFY(source.contains("configureExternalTool"));
QVERIFY(!source.contains("configurePanelActions"));
const auto stamp = QFileInfo(ui).lastModified().toSecsSinceEpoch();
QVERIFY(stamp > 0);
const auto packageDate = qgetenv("SOURCE_DATE_EPOCH").toLongLong();
if (packageDate > 0) {
QVERIFY2(stamp != packageDate, "QML resources must not share the RPM changelog timestamp across revisions");
}
}
};
QTEST_GUILESS_MAIN(QmlResourceTest)
#include "test-qmlresource.moc"
@@ -3,12 +3,40 @@
#include "toolmodel.h" #include "toolmodel.h"
#include <QTest> #include <QTest>
#include <QDir>
#include <QFile>
#include <QTemporaryDir>
class ToolModelTest : public QObject class ToolModelTest : public QObject
{ {
Q_OBJECT Q_OBJECT
private Q_SLOTS: private Q_SLOTS:
void discoversLocallyInstalledToolSettings()
{
QTemporaryDir directory;
QVERIFY(directory.isValid());
const QByteArray original = qgetenv("XDG_DATA_HOME");
qputenv("XDG_DATA_HOME", directory.path().toUtf8());
QVERIFY(QDir(directory.path()).mkpath(QStringLiteral("fedora-tools/settings")));
QFile registration(directory.filePath(QStringLiteral("fedora-tools/settings/local-tool.json")));
QVERIFY(registration.open(QIODevice::WriteOnly));
registration.write("{\"command\":[\"/bin/true\",\"--settings\"]}");
registration.close();
ToolModel model;
const PackageRecord package{QStringLiteral("local-tool"), QStringLiteral("1"), QStringLiteral("noarch"), QStringLiteral("Local tool")};
model.setPackages({package}, {});
QCOMPARE(model.rowCount(), 1);
QVERIFY(model.data(model.index(0), ToolModel::ConfigurableRole).toBool());
QVERIFY(model.data(model.index(0), ToolModel::ExternalConfigurationRole).toBool());
QVERIFY(!model.data(model.index(0), ToolModel::AvailableRole).toBool());
QCOMPARE(model.configurationCommand(package.name), QStringList({QStringLiteral("/bin/true"), QStringLiteral("--settings")}));
// Repository entries alone must not expose locally registered commands.
model.setPackages({}, {package});
QVERIFY(!model.data(model.index(0), ToolModel::ConfigurableRole).toBool());
QVERIFY(model.configurationCommand(package.name).isEmpty());
qputenv("XDG_DATA_HOME", original);
}
void mergesInstalledAndAvailablePackages() void mergesInstalledAndAvailablePackages()
{ {
ToolModel model; ToolModel model;