Discover external tool settings and refresh cached settings UI
This commit is contained in:
@@ -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
|
||||||
%cmake_build
|
# 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
|
||||||
|
)
|
||||||
|
|
||||||
%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);
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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 {};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user