v0.2.0: data-safety, unsaved-changes guard, FormCard UI, filesystem UX

- Preserve override keys we do not model so Save never drops data
- Prompt before discarding unsaved changes when switching applications
- Native FormCard layout for the permission list (grouped cards)
- Filesystem entries gain an access-mode selector and a folder browser
- Add resolution autotests (toggle render/save, filesystem split, key preservation)
This commit is contained in:
2026-07-01 14:46:24 -05:00
parent 3419a1acac
commit 5f37debd01
9 changed files with 559 additions and 265 deletions
+74 -2
View File
@@ -29,6 +29,33 @@ QString joinSorted(const QStringList &tokens)
return copy.join(QLatin1Char(';'));
}
// Split a filesystem token like "~/games:ro" into its access mode and path.
// A missing/unknown suffix means read-write (flatpak's default).
bool isFsMode(const QString &s)
{
return s == QLatin1String("ro") || s == QLatin1String("rw") || s == QLatin1String("create");
}
QString fsModeOf(const QString &token)
{
const int idx = token.lastIndexOf(QLatin1Char(':'));
if (idx > 0 && isFsMode(token.mid(idx + 1))) {
return token.mid(idx + 1);
}
return QStringLiteral("rw");
}
QString fsPathOf(const QString &token)
{
const int idx = token.lastIndexOf(QLatin1Char(':'));
if (idx > 0 && isFsMode(token.mid(idx + 1))) {
return token.left(idx);
}
return token;
}
QString fsToken(const QString &path, const QString &mode)
{
return (mode.isEmpty() || mode == QLatin1String("rw")) ? path : path + QLatin1Char(':') + mode;
}
// Normalise a KeyFile to a canonical string so two override sets can be
// compared regardless of group/key/token ordering.
QString normalize(const KeyFile &kf)
@@ -54,6 +81,23 @@ PermissionsController::PermissionsController(QObject *parent)
{
}
QVariantList PermissionsController::categories() const
{
QVariantList list;
for (const CategoryDef &cat : catalog()) {
if (cat.type == RowType::Portal && isGlobal()) {
continue; // portals are not applicable to the global override file
}
list.append(QVariantMap{
{QStringLiteral("id"), cat.id},
{QStringLiteral("title"), cat.title},
{QStringLiteral("description"), cat.description},
{QStringLiteral("type"), static_cast<int>(cat.type)},
});
}
return list;
}
bool PermissionsController::isFilesystemPreset(const QString &bareToken)
{
static const QSet<QString> presets = {
@@ -319,7 +363,8 @@ void PermissionsController::rebuild()
Row r;
r.catIndex = i;
r.kind = PathEntry;
r.primary = t;
r.primary = fsPathOf(t);
r.secondary = fsModeOf(t);
r.removable = true;
r.entryId = m_nextEntryId++;
m_rows.append(r);
@@ -730,7 +775,7 @@ KeyFile PermissionsController::buildOverrides() const
QSet<QString> desired;
for (const Row &r : m_rows) {
if (r.catIndex == i && !r.primary.trimmed().isEmpty()) {
desired.insert(r.primary.trimmed());
desired.insert(fsToken(r.primary.trimmed(), r.secondary));
}
}
// Port of filesystemsOther.updateFromProxyProperty (presets separated).
@@ -834,6 +879,33 @@ KeyFile PermissionsController::buildOverrides() const
kf.setValue(group, key, tokens.join(QLatin1Char(';')));
}
// Preserve any keys we do not model (e.g. newer flatpak options, or entries
// written by other tools) so saving never drops data. We claim the six
// Context keys and the whole Environment / bus-policy groups; everything else
// is copied through verbatim from what was on disk.
static const QSet<QString> claimedContextKeys = {
QStringLiteral("shared"),
QStringLiteral("sockets"),
QStringLiteral("devices"),
QStringLiteral("features"),
QStringLiteral("filesystems"),
QStringLiteral("persistent"),
};
static const QSet<QString> claimedGroups = {
QStringLiteral("Environment"),
QStringLiteral("Session Bus Policy"),
QStringLiteral("System Bus Policy"),
};
for (const QString &group : m_savedSnapshot.groups()) {
const bool wholeGroupClaimed = claimedGroups.contains(group);
for (const QString &key : m_savedSnapshot.keys(group)) {
const bool claimed = wholeGroupClaimed || (group == QStringLiteral("Context") && claimedContextKeys.contains(key));
if (!claimed) {
kf.setValue(group, key, m_savedSnapshot.value(group, key));
}
}
}
return kf;
}
+4
View File
@@ -11,6 +11,7 @@
#include <QQmlEngine>
#include <QSet>
#include <QString>
#include <QVariantList>
/**
* Owns the editable permission state for one application (or the global
@@ -34,6 +35,8 @@ class PermissionsController : public QAbstractListModel
Q_PROPERTY(bool isGlobal READ isGlobal NOTIFY appIdChanged)
Q_PROPERTY(bool modified READ modified NOTIFY modifiedChanged)
Q_PROPERTY(bool canUndo READ canUndo NOTIFY canUndoChanged)
// One entry per visible category: {id, title, description, type}.
Q_PROPERTY(QVariantList categories READ categories NOTIFY appIdChanged)
public:
enum RowKind {
@@ -119,6 +122,7 @@ public:
{
return m_canUndo;
}
QVariantList categories() const;
// --- Editing API, called from QML ---
Q_INVOKABLE void setToggleValue(int row, bool value);
+26 -4
View File
@@ -45,12 +45,37 @@ Kirigami.ApplicationWindow {
detailsDialog.open();
}
function requestSelect(appId: string): void {
if (appId === controller.appId) {
root.pageStack.currentIndex = 1;
return;
}
if (controller.modified) {
discardDialog.pendingAppId = appId;
discardDialog.open();
} else {
selectApp(appId);
}
}
function selectApp(appId: string): void {
controller.appId = appId;
// On narrow layouts, reveal the permissions column.
root.pageStack.currentIndex = 1;
}
Kirigami.PromptDialog {
id: discardDialog
property string pendingAppId
title: i18nc("@title", "Unsaved changes")
subtitle: i18n("You have unsaved changes. Discard them and switch applications?")
standardButtons: QQC2.Dialog.Discard | QQC2.Dialog.Cancel
onDiscarded: {
close();
root.selectApp(discardDialog.pendingAppId);
}
}
Component {
id: sidebarComponent
@@ -85,10 +110,7 @@ Kirigami.ApplicationWindow {
subtitle: appDelegate.isGlobal ? i18n("Default settings for all apps") : appDelegate.appId
}
onClicked: {
appList.currentIndex = index;
root.selectApp(appId);
}
onClicked: root.requestSelect(appId)
}
Kirigami.PlaceholderMessage {
+271 -257
View File
@@ -4,8 +4,11 @@ pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import QtQuick.Dialogs
import Qt.labs.qmlmodels
import org.kde.kirigami as Kirigami
import org.kde.kitemmodels as KItemModels
import org.kde.kirigamiaddons.formcard as FormCard
import io.github.toservetheking.FlatKontrol
Kirigami.ScrollablePage {
@@ -17,20 +20,23 @@ Kirigami.ScrollablePage {
title: hasSelection ? controller.appName : i18nc("@title", "Permissions")
// --- shared bits ---------------------------------------------------------
leftPadding: 0
rightPadding: 0
topPadding: Kirigami.Units.gridUnit
bottomPadding: Kirigami.Units.gridUnit
// A small status marker used across delegates.
component StatusBadge: Kirigami.Icon {
property int badgeStatus: 0
visible: badgeStatus > 0
property int state: 0
visible: state > 0
implicitWidth: Kirigami.Units.iconSizes.small
implicitHeight: Kirigami.Units.iconSizes.small
source: badgeStatus === 2 ? "document-edit" : "globe"
source: state === 2 ? "document-edit-symbolic" : "globe-symbolic"
HoverHandler {
id: hover
id: badgeHover
}
QQC2.ToolTip.visible: hover.hovered
QQC2.ToolTip.text: badgeStatus === 2 ? i18n("Set by you") : i18n("Set by a global override")
QQC2.ToolTip.visible: badgeHover.hovered
QQC2.ToolTip.text: state === 2 ? i18n("Set by you") : i18n("Set by a global override")
}
actions: [
@@ -70,259 +76,267 @@ Kirigami.ScrollablePage {
}
}
// --- the list ------------------------------------------------------------
ListView {
id: permsView
model: page.controller
Kirigami.PlaceholderMessage {
anchors.centerIn: parent
width: parent.width - Kirigami.Units.gridUnit * 4
visible: !page.hasSelection
icon.name: "preferences-desktop"
text: i18n("Select an application")
explanation: i18n("Choose an application to review and adjust its permissions.")
}
section.property: "categoryTitle"
section.delegate: Kirigami.ListSectionHeader {
required property string section
width: ListView.view.width
text: section
}
delegate: DelegateChooser {
role: "rowType"
// Toggle (share/sockets/devices/features/filesystem presets)
DelegateChoice {
roleValue: 0
delegate: QQC2.ItemDelegate {
id: toggleDelegate
required property int index
required property string label
required property string example
required property bool value
required property int status
width: ListView.view.width
hoverEnabled: true
onClicked: toggleSwitch.toggle()
contentItem: RowLayout {
spacing: Kirigami.Units.largeSpacing
ColumnLayout {
Layout.fillWidth: true
spacing: 0
QQC2.Label {
text: toggleDelegate.label
Layout.fillWidth: true
elide: Text.ElideRight
}
QQC2.Label {
text: toggleDelegate.example
visible: text.length > 0
opacity: 0.6
font: Kirigami.Theme.smallFont
Layout.fillWidth: true
elide: Text.ElideRight
}
}
StatusBadge {
badgeStatus: toggleDelegate.status
}
QQC2.Switch {
id: toggleSwitch
checked: toggleDelegate.value
onToggled: page.controller.setToggleValue(toggleDelegate.index, checked)
}
}
}
}
// Filesystem path entry
DelegateChoice {
roleValue: 1
delegate: page.entryDelegate
}
// Persistent (relative path) entry
DelegateChoice {
roleValue: 2
delegate: page.entryDelegate
}
// Environment variable
DelegateChoice {
roleValue: 3
delegate: QQC2.ItemDelegate {
id: varDelegate
required property int index
required property string value
required property string secondary
required property int status
required property bool removable
width: ListView.view.width
hoverEnabled: true
background: null
contentItem: RowLayout {
spacing: Kirigami.Units.smallSpacing
QQC2.TextField {
text: varDelegate.value
placeholderText: i18n("VARIABLE")
Layout.preferredWidth: Kirigami.Units.gridUnit * 10
onTextEdited: page.controller.setEntryPrimary(varDelegate.index, text)
}
QQC2.Label {
text: "="
}
QQC2.TextField {
text: varDelegate.secondary
placeholderText: i18n("value")
Layout.fillWidth: true
onTextEdited: page.controller.setEntrySecondary(varDelegate.index, text)
}
StatusBadge {
badgeStatus: varDelegate.status
}
QQC2.ToolButton {
icon.name: "edit-delete-remove"
visible: varDelegate.removable
onClicked: page.controller.removeEntry(varDelegate.index)
}
}
}
}
// D-Bus name policy
DelegateChoice {
roleValue: 4
delegate: QQC2.ItemDelegate {
id: busDelegate
required property int index
required property string value
required property string secondary
required property int status
required property bool removable
width: ListView.view.width
hoverEnabled: true
background: null
contentItem: RowLayout {
spacing: Kirigami.Units.smallSpacing
QQC2.TextField {
text: busDelegate.value
placeholderText: i18n("e.g. org.freedesktop.Notifications")
Layout.fillWidth: true
onTextEdited: page.controller.setEntryPrimary(busDelegate.index, text)
}
QQC2.ComboBox {
model: ["talk", "own"]
currentIndex: busDelegate.secondary === "own" ? 1 : 0
onActivated: page.controller.setEntrySecondary(busDelegate.index, currentValue)
}
StatusBadge {
badgeStatus: busDelegate.status
}
QQC2.ToolButton {
icon.name: "edit-delete-remove"
visible: busDelegate.removable
onClicked: page.controller.removeEntry(busDelegate.index)
}
}
}
}
// Portal (tri-state)
DelegateChoice {
roleValue: 5
delegate: QQC2.ItemDelegate {
id: portalDelegate
required property int index
required property string label
required property string example
required property int value
required property bool supported
required property string unsupportedReason
width: ListView.view.width
hoverEnabled: true
background: null
readonly property var states: [2, 4, 3] // Unset, Allowed, Disallowed
contentItem: RowLayout {
spacing: Kirigami.Units.largeSpacing
ColumnLayout {
Layout.fillWidth: true
spacing: 0
QQC2.Label {
text: portalDelegate.label
Layout.fillWidth: true
elide: Text.ElideRight
}
QQC2.Label {
text: portalDelegate.supported ? portalDelegate.example : portalDelegate.unsupportedReason
visible: text.length > 0
opacity: 0.6
font: Kirigami.Theme.smallFont
Layout.fillWidth: true
wrapMode: Text.WordWrap
}
}
QQC2.ComboBox {
enabled: portalDelegate.supported
model: [i18n("Unset"), i18n("Allowed"), i18n("Disallowed")]
currentIndex: portalDelegate.value === 4 ? 1 : (portalDelegate.value === 3 ? 2 : 0)
onActivated: page.controller.setPortalValue(portalDelegate.index, portalDelegate.states[currentIndex])
}
}
}
}
// "Add entry" affordance
DelegateChoice {
roleValue: 6
delegate: QQC2.ItemDelegate {
id: addDelegate
required property string categoryId
width: ListView.view.width
icon.name: "list-add"
text: i18nc("@action", "Add…")
onClicked: page.controller.addEntry(categoryId)
}
}
}
Kirigami.PlaceholderMessage {
anchors.centerIn: parent
width: parent.width - Kirigami.Units.gridUnit * 4
visible: !page.hasSelection
icon.name: "preferences-desktop"
text: i18n("Select an application")
explanation: i18n("Choose an application to review and adjust its permissions.")
}
// Shared delegate for path / relative-path entries.
property Component entryDelegate: Component {
QQC2.ItemDelegate {
id: pathDelegate
required property int index
required property string value
required property string categoryId
required property int status
required property bool removable
width: ListView.view.width
hoverEnabled: true
background: null
ColumnLayout {
spacing: Kirigami.Units.largeSpacing
visible: page.hasSelection
contentItem: RowLayout {
spacing: Kirigami.Units.smallSpacing
QQC2.TextField {
text: pathDelegate.value
placeholderText: pathDelegate.categoryId === "persistent" ? i18n("e.g. .thunderbird") : i18n("e.g. ~/games:ro")
Layout.fillWidth: true
onTextEdited: page.controller.setEntryPrimary(pathDelegate.index, text)
Repeater {
model: page.controller.categories
delegate: ColumnLayout {
id: section
required property var modelData
Layout.fillWidth: true
spacing: 0
FormCard.FormHeader {
title: section.modelData.title
}
StatusBadge {
badgeStatus: pathDelegate.status
}
QQC2.ToolButton {
icon.name: "edit-delete-remove"
visible: pathDelegate.removable
onClicked: page.controller.removeEntry(pathDelegate.index)
FormCard.FormCard {
KItemModels.KSortFilterProxyModel {
id: catModel
sourceModel: page.controller
filterRoleName: "categoryId"
// No category id is a substring of another, so this is an exact match.
filterString: section.modelData.id
}
Repeater {
model: catModel
delegate: DelegateChooser {
role: "rowType"
// Toggle
DelegateChoice {
roleValue: 0
delegate: FormCard.FormSwitchDelegate {
id: toggleD
required property int index
required property string label
required property string example
required property bool value
required property int status
readonly property int sourceRow: catModel.mapToSource(catModel.index(index, 0)).row
text: label
description: example
checked: value
icon.name: status === 2 ? "document-edit-symbolic" : (status === 1 ? "globe-symbolic" : "")
onToggled: page.controller.setToggleValue(sourceRow, checked)
}
}
// Filesystem path (with access mode + Browse)
DelegateChoice {
roleValue: 1
delegate: FormCard.AbstractFormDelegate {
id: fsD
required property int index
required property string value
required property string secondary
required property int status
required property bool removable
readonly property int sourceRow: catModel.mapToSource(catModel.index(index, 0)).row
background: null
FolderDialog {
id: folderDialog
onAccepted: {
const p = decodeURIComponent(selectedFolder.toString().replace(/^file:\/\//, ""));
fsField.text = p;
page.controller.setEntryPrimary(fsD.sourceRow, p);
}
}
contentItem: RowLayout {
spacing: Kirigami.Units.smallSpacing
QQC2.TextField {
id: fsField
text: fsD.value
placeholderText: i18n("e.g. ~/Downloads")
Layout.fillWidth: true
onTextEdited: page.controller.setEntryPrimary(fsD.sourceRow, text)
}
QQC2.ComboBox {
readonly property var modes: ["ro", "rw", "create"]
model: [i18n("Read-only"), i18n("Read/write"), i18n("Create")]
currentIndex: Math.max(0, modes.indexOf(fsD.secondary))
onActivated: page.controller.setEntrySecondary(fsD.sourceRow, modes[currentIndex])
}
QQC2.ToolButton {
icon.name: "document-open-folder"
QQC2.ToolTip.text: i18n("Browse…")
QQC2.ToolTip.visible: hovered
onClicked: folderDialog.open()
}
StatusBadge {
state: fsD.status
}
QQC2.ToolButton {
icon.name: "edit-delete-remove"
visible: fsD.removable
onClicked: page.controller.removeEntry(fsD.sourceRow)
}
}
}
}
// Persistent (home-relative) path
DelegateChoice {
roleValue: 2
delegate: FormCard.AbstractFormDelegate {
id: relD
required property int index
required property string value
required property int status
required property bool removable
readonly property int sourceRow: catModel.mapToSource(catModel.index(index, 0)).row
background: null
contentItem: RowLayout {
spacing: Kirigami.Units.smallSpacing
QQC2.TextField {
text: relD.value
placeholderText: i18n("e.g. .thunderbird")
Layout.fillWidth: true
onTextEdited: page.controller.setEntryPrimary(relD.sourceRow, text)
}
StatusBadge {
state: relD.status
}
QQC2.ToolButton {
icon.name: "edit-delete-remove"
visible: relD.removable
onClicked: page.controller.removeEntry(relD.sourceRow)
}
}
}
}
// Environment variable
DelegateChoice {
roleValue: 3
delegate: FormCard.AbstractFormDelegate {
id: varD
required property int index
required property string value
required property string secondary
required property int status
required property bool removable
readonly property int sourceRow: catModel.mapToSource(catModel.index(index, 0)).row
background: null
contentItem: RowLayout {
spacing: Kirigami.Units.smallSpacing
QQC2.TextField {
text: varD.value
placeholderText: i18n("VARIABLE")
Layout.preferredWidth: Kirigami.Units.gridUnit * 10
onTextEdited: page.controller.setEntryPrimary(varD.sourceRow, text)
}
QQC2.Label {
text: "="
}
QQC2.TextField {
text: varD.secondary
placeholderText: i18n("value")
Layout.fillWidth: true
onTextEdited: page.controller.setEntrySecondary(varD.sourceRow, text)
}
StatusBadge {
state: varD.status
}
QQC2.ToolButton {
icon.name: "edit-delete-remove"
visible: varD.removable
onClicked: page.controller.removeEntry(varD.sourceRow)
}
}
}
}
// D-Bus name policy
DelegateChoice {
roleValue: 4
delegate: FormCard.AbstractFormDelegate {
id: busD
required property int index
required property string value
required property string secondary
required property int status
required property bool removable
readonly property int sourceRow: catModel.mapToSource(catModel.index(index, 0)).row
background: null
contentItem: RowLayout {
spacing: Kirigami.Units.smallSpacing
QQC2.TextField {
text: busD.value
placeholderText: i18n("e.g. org.freedesktop.Notifications")
Layout.fillWidth: true
onTextEdited: page.controller.setEntryPrimary(busD.sourceRow, text)
}
QQC2.ComboBox {
model: ["talk", "own"]
currentIndex: busD.secondary === "own" ? 1 : 0
onActivated: page.controller.setEntrySecondary(busD.sourceRow, currentValue)
}
StatusBadge {
state: busD.status
}
QQC2.ToolButton {
icon.name: "edit-delete-remove"
visible: busD.removable
onClicked: page.controller.removeEntry(busD.sourceRow)
}
}
}
}
// Portal (tri-state)
DelegateChoice {
roleValue: 5
delegate: FormCard.FormComboBoxDelegate {
id: portalD
required property int index
required property string label
required property string example
required property int value
required property bool supported
required property string unsupportedReason
readonly property int sourceRow: catModel.mapToSource(catModel.index(index, 0)).row
readonly property var states: [2, 4, 3]
text: label
description: supported ? example : unsupportedReason
enabled: supported
model: [i18n("Unset"), i18n("Allowed"), i18n("Disallowed")]
currentIndex: value === 4 ? 1 : (value === 3 ? 2 : 0)
onActivated: page.controller.setPortalValue(sourceRow, states[currentIndex])
}
}
// "Add entry" affordance
DelegateChoice {
roleValue: 6
delegate: FormCard.FormButtonDelegate {
id: addD
required property string categoryId
text: i18nc("@action", "Add…")
icon.name: "list-add"
onClicked: page.controller.addEntry(categoryId)
}
}
}
}
}
}
}