diff --git a/src/com.github.tchx84.Flatseal.data.gresource.xml b/src/com.github.tchx84.Flatseal.data.gresource.xml index 7afde932..ca5f3bae 100644 --- a/src/com.github.tchx84.Flatseal.data.gresource.xml +++ b/src/com.github.tchx84.Flatseal.data.gresource.xml @@ -6,6 +6,7 @@ widgets/appInfoViewer.ui widgets/applicationRow.ui widgets/busNameRow.ui + widgets/conditionalStatusIcon.ui widgets/docsViewer.ui widgets/globalInfoViewer.ui widgets/globalRow.ui diff --git a/src/com.github.tchx84.Flatseal.src.gresource.xml b/src/com.github.tchx84.Flatseal.src.gresource.xml index 84232173..c22925bf 100644 --- a/src/com.github.tchx84.Flatseal.src.gresource.xml +++ b/src/com.github.tchx84.Flatseal.src.gresource.xml @@ -7,6 +7,7 @@ widgets/appInfoViewer.js widgets/applicationRow.js widgets/busNameRow.js + widgets/conditionalStatusIcon.js widgets/detailsButton.js widgets/docsViewer.js widgets/globalInfoViewer.js diff --git a/src/models/permissions.js b/src/models/permissions.js index acd67d60..1cfa36fc 100644 --- a/src/models/permissions.js +++ b/src/models/permissions.js @@ -52,6 +52,14 @@ const MODELS = { unsupported: new FlatpakUnsupportedModel(), }; +/* Models that support conditional permissions */ +const CONDITIONAL_MODELS = [ + MODELS.shared, + MODELS.sockets, + MODELS.devices, + MODELS.features, +]; + function generate_index() { const index = {}; @@ -102,6 +110,13 @@ function generate() { const statusProperty = `${property}-status`; properties[statusProperty] = GObject.ParamSpec.string( statusProperty, statusProperty, statusProperty, FLAGS, FlatsealOverrideStatus.ORIGINAL); + + /* conditional requests */ + if (CONDITIONAL_MODELS.includes(model)) { + const conditionalProperty = `${property}-conditional`; + properties[conditionalProperty] = GObject.ParamSpec.string( + conditionalProperty, conditionalProperty, conditionalProperty, FLAGS, ''); + } }); }); @@ -192,12 +207,30 @@ var FlatpakPermissionsModel = GObject.registerClass({ .split(';'); values.forEach(option => { - /* Flatseal does not support conditionals, but skips them - * to avoid corrupting the overrides file. */ - if (option.startsWith(CONDITIONAL_PREFIX)) + let isConditional = false; + let bareOption = option; + + if (option.startsWith(CONDITIONAL_PREFIX)) { + const parts = option.slice(CONDITIONAL_PREFIX.length).split(':'); + + /* A valid conditional has both an option and a + * condition after "if:". Ignore incomplete + * entries instead of treating them as + * conditionals. */ + if (parts.length >= 2) { + isConditional = true; + [bareOption] = parts; + } + } + + /* Conditionals from overrides (per-app or global) + * are dropped. They can't be written back without + * risking a change in their meaning, so they are + * neither loaded nor displayed. */ + if (isConditional && overrides) return; - model = this.constructor._find(`${group}_${key}_${option.replace('!', '')}`); + model = this.constructor._find(`${group}_${key}_${bareOption.replace('!', '')}`); if (model === null) model = this.constructor._find(`${group}_${key}`); @@ -205,8 +238,19 @@ var FlatpakPermissionsModel = GObject.registerClass({ if (model === null && overrides && !global) model = MODELS.unsupported; - if (model !== null) - model.loadFromKeyFile(group, key, option, overrides, global); + /* Only the four models in CONDITIONAL_MODELS + * support conditionals. A conditional for any + * other permission is dropped instead of being + * loaded as a plain, unconditional permission. */ + if (isConditional && !CONDITIONAL_MODELS.includes(model)) + return; + + if (model !== null) { + model.loadFromKeyFile(group, key, bareOption, overrides, global); + + if (isConditional) + model.markConditional(bareOption, option); + } }); }); }); @@ -270,6 +314,7 @@ var FlatpakPermissionsModel = GObject.registerClass({ GObject.signal_handler_block(this, this._notifyHandlerId); Object.values(MODELS).forEach(model => model.updateStatusProperty(this)); + CONDITIONAL_MODELS.forEach(model => model.updateConditionalProperty(this)); GObject.signal_handler_unblock(this, this._notifyHandlerId); } @@ -394,6 +439,8 @@ var FlatpakPermissionsModel = GObject.registerClass({ entry['groupStyle'] = model.constructor.getStyle(); entry['groupDescription'] = model.constructor.getDescription(); entry['statusProperty'] = `${property}-status`; + entry['conditionalProperty'] = CONDITIONAL_MODELS.includes(model) + ? `${property}-conditional` : null; entry['serializeFunc'] = model.constructor.serialize; entry['deserializeFunc'] = model.constructor.deserialize; diff --git a/src/models/shared.js b/src/models/shared.js index e5bea163..fc96fb55 100644 --- a/src/models/shared.js +++ b/src/models/shared.js @@ -150,6 +150,28 @@ var FlatpakSharedModel = GObject.registerClass({ }); } + /* Flatpak drops a conditional if a bare grant for the same + * option is applied afterward, so this being set doesn't + * guarantee the permission is actually granted at runtime. */ + markConditional(option, rawValue) { + this._conditionals.set(option, rawValue); + } + + updateConditionalProperty(proxy) { + Object.entries(this.getPermissions()).forEach(([property, permission]) => { + const {option} = permission; + const conditionalProperty = `${property}-conditional`; + let value = this._conditionals.get(option) || ''; + + /* The conditional marker should not show on the */ + /* overriden conditional permisions. */ + if (this._getStatusForPermission(option) !== FlatsealOverrideStatus.ORIGINAL) + value = ''; + + proxy.set_property(conditionalProperty, value); + }); + } + updateProxyProperty(proxy) { const originals = [...this._originals] .filter(o => !this.constructor._isOverriden(this._globals, o)) @@ -190,6 +212,10 @@ var FlatpakSharedModel = GObject.registerClass({ const group = this.constructor.getGroup(); const key = this.constructor.getKey(); + /* This only writes from _overrides (the on/off state), it does + * not write out anything from _conditionals. Saving can + * therefore drop an existing conditional entry from the + * override file. Write-back isn't implemented yet. */ this._overrides.forEach(value => { let _value = value; @@ -208,5 +234,6 @@ var FlatpakSharedModel = GObject.registerClass({ this._overrides = new Set(); this._globals = new Set(); this._originals = new Set(); + this._conditionals = new Map(); } }); diff --git a/src/style.css b/src/style.css index a96bda31..907d8c5f 100644 --- a/src/style.css +++ b/src/style.css @@ -20,6 +20,14 @@ row .status.user { row .status.global { color: @insensitive_fg_color; } +row .conditional { + padding: 8px; + background-color: transparent; + background-image: -gtk-icontheme("dialog-question-symbolic"); + background-repeat: no-repeat; + background-position: center; + background-size: 16px; +} row .content .bus .info, row .content .variable .info, diff --git a/src/widgets/conditionalStatusIcon.js b/src/widgets/conditionalStatusIcon.js new file mode 100644 index 00000000..c853960e --- /dev/null +++ b/src/widgets/conditionalStatusIcon.js @@ -0,0 +1,65 @@ +/* exported FlatsealConditionalStatusIcon */ + +/* conditionalStatusIcon.js + * + * Copyright 2026 Malika Odeny Asman + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +const {GObject, Gtk} = imports.gi; + + +var FlatsealConditionalStatusIcon = GObject.registerClass({ + GTypeName: 'FlatsealConditionalStatusIcon', + Template: 'resource:///com/github/tchx84/Flatseal/widgets/conditionalStatusIcon.ui', + Properties: { + value: GObject.ParamSpec.string( + 'value', + 'value', + 'value', + GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT, + '', + ), + }, +}, class FlatsealConditionalStatusIcon extends Gtk.Image { + _init() { + super._init({}); + this._value = ''; + } + + set value(value) { + if (this._value === value) + return; + + this._value = value; + + if (value === '') { + this.set_tooltip_text(''); + this.visible = false; + return; + } + + const condition = value + .split(':') + .slice(2) + .join(':'); + this.set_tooltip_text(_('Only granted if: %s').format(condition)); + this.visible = true; + } + + get value() { + return this._value; + } +}); diff --git a/src/widgets/conditionalStatusIcon.ui b/src/widgets/conditionalStatusIcon.ui new file mode 100644 index 00000000..55a43bcc --- /dev/null +++ b/src/widgets/conditionalStatusIcon.ui @@ -0,0 +1,8 @@ + + + + diff --git a/src/widgets/permissionSwitchRow.js b/src/widgets/permissionSwitchRow.js index bfa97776..0c37ab49 100644 --- a/src/widgets/permissionSwitchRow.js +++ b/src/widgets/permissionSwitchRow.js @@ -3,6 +3,7 @@ /* permissionSwitchRow.js * * Copyright 2020 Martin Abente Lahaye + * Copyright 2026 Malika Odeny Asman * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -20,6 +21,7 @@ const {GObject, Adw} = imports.gi; const {FlatsealOverrideStatusIcon} = imports.widgets.overrideStatusIcon; +const {FlatsealConditionalStatusIcon} = imports.widgets.conditionalStatusIcon; var FlatsealPermissionSwitchRow = GObject.registerClass({ @@ -36,6 +38,9 @@ var FlatsealPermissionSwitchRow = GObject.registerClass({ this._statusIcon = new FlatsealOverrideStatusIcon(); this._statusBox.append(this._statusIcon); + + this._conditionalIcon = new FlatsealConditionalStatusIcon(); + this._statusBox.append(this._conditionalIcon); } _update() { @@ -53,6 +58,10 @@ var FlatsealPermissionSwitchRow = GObject.registerClass({ return this._statusIcon; } + get conditional() { + return this._conditionalIcon; + } + get supported() { return this.sensitive; } diff --git a/src/widgets/window.js b/src/widgets/window.js index 21e67123..445f139a 100644 --- a/src/widgets/window.js +++ b/src/widgets/window.js @@ -284,6 +284,11 @@ var FlatsealWindow = GObject.registerClass({ return; this._permissions.bind_property(p.statusProperty, row.status, 'status', _bindFlags); + + if (!row.conditional || !p.conditionalProperty) + return; + + this._permissions.bind_property(p.conditionalProperty, row.conditional, 'value', _bindFlags); }); } diff --git a/tests/content/globalConditional/flatpak/overrides/global b/tests/content/globalConditional/flatpak/overrides/global new file mode 100644 index 00000000..7ed1f4f5 --- /dev/null +++ b/tests/content/globalConditional/flatpak/overrides/global @@ -0,0 +1,2 @@ +[Context] +features=if:devel:true; diff --git a/tests/content/system/flatpak/app/com.test.Conditional/current/active/metadata b/tests/content/system/flatpak/app/com.test.Conditional/current/active/metadata index 274fccc4..d4f91b24 100644 --- a/tests/content/system/flatpak/app/com.test.Conditional/current/active/metadata +++ b/tests/content/system/flatpak/app/com.test.Conditional/current/active/metadata @@ -5,4 +5,4 @@ sdk=org.gnome.Sdk/x86_64/master command=test [Context] -sockets=x11;if:x11:!has-wayland; +sockets=x11;if:x11:!has-wayland;if:wayland:true; diff --git a/tests/content/user/flatpak/overrides/com.test.Conditional b/tests/content/user/flatpak/overrides/com.test.Conditional index f1c4aa7b..500dedbb 100644 --- a/tests/content/user/flatpak/overrides/com.test.Conditional +++ b/tests/content/user/flatpak/overrides/com.test.Conditional @@ -1,2 +1,2 @@ [Context] -devices=all;if:all:!has-input-device; +devices=all;if:all:!has-input-device;if:kvm:!has-input-device; diff --git a/tests/content/user/flatpak/overrides/com.test.Unsupported b/tests/content/user/flatpak/overrides/com.test.Unsupported index 973a8d09..e757196b 100644 --- a/tests/content/user/flatpak/overrides/com.test.Unsupported +++ b/tests/content/user/flatpak/overrides/com.test.Unsupported @@ -1,3 +1,3 @@ [Context] shared=unsupported -unsupported=always +unsupported=always;if:unsupported-permission:!has-unsupported-permission diff --git a/tests/src/testModels.js b/tests/src/testModels.js index 7f3c0416..866f3a6d 100644 --- a/tests/src/testModels.js +++ b/tests/src/testModels.js @@ -67,6 +67,7 @@ const _user = GLib.build_filenamev(['..', 'tests', 'content', 'user', 'flatpak'] const _global = GLib.build_filenamev(['..', 'tests', 'content', 'global', 'flatpak']); const _globalNegated = GLib.build_filenamev(['..', 'tests', 'content', 'globalNegated', 'flatpak']); const _globalResetMode = GLib.build_filenamev(['..', 'tests', 'content', 'globalResetMode', 'flatpak']); +const _globalConditional = GLib.build_filenamev(['..', 'tests', 'content', 'globalConditional', 'flatpak']); const _statuses = GLib.build_filenamev(['..', 'tests', 'content', 'statuses', 'flatpak']); const _tmp = GLib.build_filenamev([GLib.DIR_SEPARATOR_S, 'tmp']); const _none = GLib.build_filenamev([GLib.DIR_SEPARATOR_S, 'dev', 'null']); @@ -609,6 +610,28 @@ describe('Model', function() { update(); }); + it('ignores unsupported conditional permissions', function(done) { + GLib.setenv('FLATPAK_USER_DIR', _user, true); + permissionsDefault.appId = _unsupportedAppId; + + GLib.setenv('FLATPAK_USER_DIR', _tmp, true); + permissionsDefault.set_property('filesystems-other', ''); + + GLib.timeout_add(GLib.PRIORITY_HIGH, delay + 1, () => { + expect(has( + _unsupportedOverride, 'Context', 'unsupported', + 'if:unsupported-permission:!has-unsupported-permission')).toBe(false); + expect(has( + _unsupportedOverride, 'Context', 'unsupported', + 'unsupported-permission')).toBe(false); + + done(); + return GLib.SOURCE_REMOVE; + }); + + update(); + }); + it('signals changes with overrides', function(done) { spyOn(permissionsDefault, 'emit'); @@ -1461,10 +1484,28 @@ describe('Model', function() { update(); }); - it('ignores conditional permissions', function() { + it('marks conditional permissions from original metadata', function() { + GLib.setenv('FLATPAK_USER_DIR', _user, true); permissionsDefault.appId = _conditionalAppId; - expect(permissionsDefault.sockets_x11).toBe(true); + expect(permissionsDefault.sockets_x11_conditional).toBe('if:x11:!has-wayland'); + + expect(permissionsDefault.sockets_wayland).toBe(true); + expect(permissionsDefault.sockets_wayland_conditional).toBe('if:wayland:true'); + }); + + it('drops conditional permissions from overrides', function() { + GLib.setenv('FLATPAK_USER_DIR', _user, true); + permissionsDefault.appId = _conditionalAppId; + + expect(permissionsDefault.devices_kvm).toBe(false); + expect(permissionsDefault.devices_kvm_conditional).toBe(''); + + GLib.setenv('FLATPAK_USER_DIR', _globalConditional, true); + permissionsDefault.appId = _conditionalAppId; + + expect(permissionsDefault.features_devel).toBe(false); + expect(permissionsDefault.features_devel_conditional).toBe(''); }); it('does not write conditional permissions back', function(done) { @@ -1491,6 +1532,24 @@ describe('Model', function() { update(); }); + it('does not mark overridden conditional permissions', function(done) { + GLib.setenv('FLATPAK_USER_DIR', _tmp, true); + permissionsDefault.appId = _conditionalAppId; + + expect(permissionsDefault.sockets_x11_conditional).toBe('if:x11:!has-wayland'); + + permissionsDefault.set_property('sockets-x11', false); + + GLib.timeout_add(GLib.PRIORITY_HIGH, delay + 1, () => { + expect(permissionsDefault.sockets_x11_status).toBe('user'); + expect(permissionsDefault.sockets_x11_conditional).toBe(''); + done(); + return GLib.SOURCE_REMOVE; + }); + + update(); + }); + it('handles malformed overrides', function() { spyOn(permissionsDefault, 'emit');