diff --git a/config/dot/quickshell/modules/settings/ApplicationsPage.qml b/config/dot/quickshell/modules/settings/ApplicationsPage.qml index ee51507..61710a0 100644 --- a/config/dot/quickshell/modules/settings/ApplicationsPage.qml +++ b/config/dot/quickshell/modules/settings/ApplicationsPage.qml @@ -13,6 +13,10 @@ SettingsPage { property string expandedRole: "" property bool addingAutostart: false + + // Which entry has been asked to be removed. Removal deletes a file, so + // it never happens on a first press. + property string confirmingAutostartRemoval: "" readonly property var applications: DesktopEntries.applications.values // Each role governs a whole family of types, not one representative: setting // "Images" writes PNG, JPEG, WebP and the rest together, so a file manager @@ -268,12 +272,50 @@ SettingsPage { required property var modelData required property int index + readonly property bool confirming: + root.confirmingAutostartRemoval === String(autostartRow.modelData.id) + label: autostartRow.modelData.name - detail: autostartRow.modelData.id - value: autostartRow.modelData.enabled ? "Enabled" : "Disabled" - activatable: !DefaultApps.busy + detail: autostartRow.confirming + ? "Removing deletes this entry. Turning it off instead is reversible." + : autostartRow.modelData.id divider: autostartRow.index < DefaultApps.autostartEntries.length - 1 - onActivated: DefaultApps.setAutostart(autostartRow.modelData.id, !autostartRow.modelData.enabled) + controlWidth: 210 + + // A switch, not the words "Enabled"/"Disabled". The row always + // toggled on click, but read as static text, so a control that + // worked looked like a status nobody could change. + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 9 + + SettingsButton { + anchors.verticalCenter: parent.verticalCenter + visible: autostartRow.confirming + text: "Remove it" + tone: "danger" + enabled: !DefaultApps.busy + onClicked: { + root.confirmingAutostartRemoval = ""; + DefaultApps.removeAutostart(String(autostartRow.modelData.id)); + } + } + + SettingsButton { + anchors.verticalCenter: parent.verticalCenter + text: autostartRow.confirming ? "Keep" : "Remove…" + enabled: !DefaultApps.busy + onClicked: root.confirmingAutostartRemoval = + autostartRow.confirming ? "" : String(autostartRow.modelData.id) + } + + SettingsToggle { + anchors.verticalCenter: parent.verticalCenter + checked: autostartRow.modelData.enabled + onToggled: value => DefaultApps.setAutostart(autostartRow.modelData.id, value) + } + } } } } diff --git a/config/dot/quickshell/modules/settings/PrivacyPage.qml b/config/dot/quickshell/modules/settings/PrivacyPage.qml index ec0c398..30bb244 100644 --- a/config/dot/quickshell/modules/settings/PrivacyPage.qml +++ b/config/dot/quickshell/modules/settings/PrivacyPage.qml @@ -47,6 +47,8 @@ SettingsPage { DeviceSecurity.refresh(); if (!Keyring.scanned) Keyring.refresh(); + if (!Permissions.scanned) + Permissions.refresh(); } SettingsCard { @@ -281,6 +283,70 @@ SettingsPage { } } + SettingsCard { + title: "Application permissions" + // The limit is stated here rather than left to be discovered. Saying + // "your camera is protected" when a native binary can open it + // directly would be a claim this page cannot back up. + subtitle: Permissions.available + ? "Applications that asked through the desktop portal. Programs installed outside it can still reach these devices directly." + : (Permissions.lastError || "The desktop portal's permission store is not running.") + + Repeater { + model: Permissions.devices + + delegate: Column { + id: deviceBlock + + required property var modelData + readonly property var applications: deviceBlock.modelData.applications ?? [] + + width: parent.width + + TextRow { + width: parent.width + visible: deviceBlock.applications.length === 0 + label: String(deviceBlock.modelData.label ?? "") + detail: "No application has asked for this." + value: "" + } + + Repeater { + model: deviceBlock.applications + + delegate: SettingRow { + required property var modelData + width: parent.width + label: String(modelData.app ?? "") + detail: String(deviceBlock.modelData.label ?? "") + controlWidth: 150 + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 9 + + SettingsButton { + anchors.verticalCenter: parent.verticalCenter + text: "Ask again" + enabled: !Permissions.busy + onClicked: Permissions.forget( + String(deviceBlock.modelData.id), String(modelData.app)) + } + + SettingsToggle { + anchors.verticalCenter: parent.verticalCenter + checked: modelData.allowed === true + onToggled: value => Permissions.setAllowed( + String(deviceBlock.modelData.id), String(modelData.app), value) + } + } + } + } + } + } + } + SettingsCard { title: "Device security" subtitle: DeviceSecurity.attentionCount === 0 diff --git a/config/dot/quickshell/modules/settings/SharingPage.qml b/config/dot/quickshell/modules/settings/SharingPage.qml index 95c59b1..90363c2 100644 --- a/config/dot/quickshell/modules/settings/SharingPage.qml +++ b/config/dot/quickshell/modules/settings/SharingPage.qml @@ -68,6 +68,25 @@ SettingsPage { value: "ssh " + Sharing.networkName } + TextRow { + visible: Sharing.remoteLoginOn && Sharing.remoteSessions.length === 0 + label: "Nobody is signed in" + detail: "Remote login is on, and no one is connected from another machine." + value: "" + } + + Repeater { + model: Sharing.remoteSessions + + delegate: TextRow { + required property var modelData + width: parent.width + label: String(modelData.user ?? "") + " is signed in from " + String(modelData.from ?? "") + detail: "Since " + String(modelData.since ?? "") + " · " + String(modelData.line ?? "") + value: "" + } + } + TextRow { visible: Sharing.remoteLoginOn label: "Port" @@ -165,12 +184,25 @@ SettingsPage { value: Sharing.fileSharing?.installed === true ? "Available" : "Not installed" } - TextRow { + SwitchRow { + visible: Sharing.mediaSharing?.installed === true label: "Share music and video to devices" - detail: Sharing.mediaSharing?.installed === true - ? "Rygel is installed" - : "Needs Rygel, which is not installed." - value: Sharing.mediaSharing?.installed === true ? "Available" : "Not installed" + // Said before it happens, not after: this advertises on the network + // to anything that speaks DLNA, with no password in front of it. + detail: Sharing.mediaSharing?.active === true + ? "Rygel is serving your media to devices on the network" + : "Publishes your media folders to every device on the network. No password is asked for." + checked: Sharing.mediaSharing?.active === true + enabled: !Sharing.busy + divider: false + onToggled: value => Sharing.setMediaSharing(value) + } + + TextRow { + visible: Sharing.mediaSharing?.installed !== true + label: "Share music and video to devices" + detail: "Needs Rygel, which is not installed." + value: "Not installed" divider: false } } diff --git a/config/dot/quickshell/scripts/panama-default-apps b/config/dot/quickshell/scripts/panama-default-apps index 1c0dc7d..3e37a69 100755 --- a/config/dot/quickshell/scripts/panama-default-apps +++ b/config/dot/quickshell/scripts/panama-default-apps @@ -352,6 +352,38 @@ def update_hidden(path: Path, *, hidden: bool) -> None: write_atomic(path, with_hidden(original, hidden=hidden), mode=mode) +def remove_autostart(desktop_id: str) -> None: + """Delete a user autostart entry. + + Disabling writes Hidden=true and is reversible; this is not, so it is + confined to files this directory owns. A symlink is refused rather than + followed, because deleting through one would remove whatever it points at -- + which is somewhere else entirely, and not ours. + """ + if not DESKTOP_ID.fullmatch(desktop_id): + raise BoundaryError("That is not an autostart entry name.") + + directory = autostart_directory() + target = directory / desktop_id + + # Resolved and compared, so a name like "../../.bashrc" cannot escape. + try: + resolved = target.resolve(strict=True) + except OSError as error: + raise BoundaryError("That autostart entry no longer exists.") from error + if resolved.parent != directory.resolve(strict=False): + raise BoundaryError("That autostart entry is not in the autostart directory.") + if target.is_symlink() or not target.is_file(): + raise BoundaryError("That autostart entry is not a file this can remove.") + if target.suffix != ".desktop": + raise BoundaryError("That autostart entry is not a desktop file.") + + try: + target.unlink() + except OSError as error: + raise BoundaryError("That autostart entry could not be removed.") from error + + def add_autostart(desktop_id: str) -> None: desktop_files = discovered_desktop_files() require_desktop_id(desktop_id, discovered=set(desktop_files)) @@ -406,6 +438,8 @@ def main(arguments: list[str]) -> int: set_default(arguments[1], arguments[2]) elif len(arguments) == 3 and arguments[0] == "set-autostart": set_autostart(arguments[1], arguments[2]) + elif len(arguments) == 2 and arguments[0] == "remove-autostart": + remove_autostart(arguments[1]) elif len(arguments) == 2 and arguments[0] == "add-autostart": add_autostart(arguments[1]) else: diff --git a/config/dot/quickshell/scripts/panama-permissions b/config/dot/quickshell/scripts/panama-permissions new file mode 100755 index 0000000..46b40be --- /dev/null +++ b/config/dot/quickshell/scripts/panama-permissions @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 + +"""Which applications may use the camera and microphone. + +Read from xdg-desktop-portal's permission store, which is where an application +that asks through the portal has its answer recorded. That is the whole of what +this can control, and the limit is worth stating plainly rather than implying a +protection that does not exist: a native binary opens /dev/video0 directly and +no desktop setting stands in its way. What this covers is Flatpaks and anything +else that goes through the portal -- which on this machine is most of what would +ever ask. + +Devices with no recorded application are reported as empty rather than omitted, +so the page can say "nothing has asked" instead of showing nothing at all. + + panama-permissions snapshot + panama-permissions set DEVICE APP_ID allow|deny + panama-permissions forget DEVICE APP_ID +""" + +from __future__ import annotations + +import json +import re +import subprocess +import sys + +TABLE = "devices" + +# The devices the portal arbitrates. Listed rather than discovered so a device +# nothing has asked for still appears, which is the difference between "no +# application uses your microphone" and a page that silently omits it. +DEVICES = ( + ("camera", "Camera"), + ("microphone", "Microphone"), + ("speakers", "Speakers"), +) + +APP_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") +DEVICE_ID = re.compile(r"^[a-z]+$") + +ALLOWED = "yes" +DENIED = "no" + + +class BoundaryError(RuntimeError): + """A user-visible validation or permission-store failure.""" + + +def run(command: list[str], timeout: float = 15.0) -> subprocess.CompletedProcess: + try: + return subprocess.run(command, capture_output=True, text=True, timeout=timeout) + except FileNotFoundError as error: + raise BoundaryError("busctl is not available.") from error + except subprocess.TimeoutExpired as error: + raise BoundaryError("The permission store did not respond.") from error + + +def portal_call(method: str, signature: str, *arguments: str) -> dict | None: + """One call to the permission store, as JSON. + + --json=short rather than busctl's text output, which escapes non-ASCII into + octal and would mangle an application name. + """ + result = run([ + "busctl", "--user", "--json=short", "call", + "org.freedesktop.impl.portal.PermissionStore", + "/org/freedesktop/impl/portal/PermissionStore", + "org.freedesktop.impl.portal.PermissionStore", + method, signature, *arguments, + ]) + if result.returncode != 0: + detail = (result.stderr or "").strip() + # A device nothing has ever asked for has no row at all, and the store + # says so as "No entry for camera". Matched on the store's actual words + # rather than a guess at them. + lowered = detail.lower() + if "no entry" in lowered or "not found" in lowered: + return None + raise BoundaryError(detail.splitlines()[-1] if detail else "The permission store refused that.") + try: + return json.loads(result.stdout or "null") + except json.JSONDecodeError as error: + raise BoundaryError("The permission store returned something unreadable.") from error + + +def available() -> bool: + result = run(["busctl", "--user", "list"]) + return "org.freedesktop.impl.portal.PermissionStore" in (result.stdout or "") + + +def entries_for(device: str) -> list[dict]: + payload = portal_call("Lookup", "ss", TABLE, device) + if not payload: + return [] + data = payload.get("data") or [] + if not data or not isinstance(data[0], dict): + return [] + + found = [] + for app_id, permissions in data[0].items(): + values = [str(value) for value in (permissions or [])] + found.append({ + "app": app_id, + # Anything that is not an explicit "yes" is treated as withheld: + # guessing generously about a camera is the wrong way to be wrong. + "allowed": ALLOWED in values, + "raw": ",".join(values), + }) + found.sort(key=lambda entry: entry["app"].casefold()) + return found + + +def snapshot() -> dict: + if not available(): + return { + "available": False, + "devices": [], + "error": "The desktop portal's permission store is not running.", + } + + devices = [] + for device_id, label in DEVICES: + devices.append({ + "id": device_id, + "label": label, + "applications": entries_for(device_id), + }) + return {"available": True, "devices": devices, "error": ""} + + +def require(pattern: re.Pattern[str], value: str, message: str) -> str: + if not pattern.match(value or ""): + raise BoundaryError(message) + return value + + +def set_permission(device: str, app: str, allowed: bool) -> None: + require(DEVICE_ID, device, "That is not a device.") + require(APP_ID, app, "That is not an application.") + if not any(device == known for known, _ in DEVICES): + raise BoundaryError("That is not a device this manages.") + # Permissions are an array of strings, so busctl needs the element count + # before the element -- "1 yes", not "yes". + portal_call("SetPermission", "sbssas", TABLE, "true", device, app, + "1", ALLOWED if allowed else DENIED) + + +def forget(device: str, app: str) -> None: + """Drop the recorded answer, so the application is asked again next time.""" + require(DEVICE_ID, device, "That is not a device.") + require(APP_ID, app, "That is not an application.") + portal_call("DeletePermission", "sss", TABLE, device, app) + + +def main(arguments: list[str]) -> int: + try: + if arguments == ["snapshot"]: + print(json.dumps(snapshot(), separators=(",", ":"))) + return 0 + + if len(arguments) == 4 and arguments[0] == "set": + if arguments[3] not in ("allow", "deny"): + raise BoundaryError("That is not allow or deny.") + set_permission(arguments[1], arguments[2], arguments[3] == "allow") + elif len(arguments) == 3 and arguments[0] == "forget": + forget(arguments[1], arguments[2]) + else: + raise BoundaryError( + "Usage: panama-permissions snapshot | set DEVICE APP allow|deny | " + "forget DEVICE APP") + except BoundaryError as error: + try: + state = snapshot() + except BoundaryError: + state = {"available": False, "devices": []} + state["error"] = str(error) + print(json.dumps(state, separators=(",", ":"))) + return 0 + + print(json.dumps(snapshot(), separators=(",", ":"))) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/config/dot/quickshell/scripts/panama-sharing b/config/dot/quickshell/scripts/panama-sharing index 678961b..80f489d 100755 --- a/config/dot/quickshell/scripts/panama-sharing +++ b/config/dot/quickshell/scripts/panama-sharing @@ -98,6 +98,62 @@ def remote_desktop() -> dict: return state +def active_logins() -> list[dict[str, str]]: + """Who is signed in from another machine right now. + + Read from `who`, which names the user, when they arrived, and where from. + Only sessions with an origin are reported: a local seat has none, and + listing the person sitting at the keyboard as a remote login would be + alarming and wrong. + """ + result = run(["who"]) + if result.returncode != 0: + return [] + + sessions: list[dict[str, str]] = [] + for line in result.stdout.splitlines(): + match = re.match(r"^(\S+)\s+(\S+)\s+(.+?)\s+\((.+)\)\s*$", line) + if not match: + continue + user, line_name, when, origin = match.groups() + # X displays appear in the same parenthesised field as a hostname. + if origin.startswith(":") or origin in ("localhost", ""): + continue + sessions.append({ + "user": user, + "line": line_name, + "since": when.strip(), + "from": origin, + }) + return sessions + + +def media_sharing() -> dict: + """Rygel, which serves media to devices on the network over DLNA. + + Reported as a running state rather than just "installed", because installed + and off is the normal case and is not the same thing as sharing. Turning it + on publishes media directories to every device on the network, which is why + the page says so next to the switch. + """ + if not shutil.which("rygel"): + return {"installed": False, "active": False, "enabled": False, "package": "rygel"} + state = unit_state("rygel.service", user=True) + state["installed"] = True + state["package"] = "rygel" + return state + + +def set_media_sharing(enabled: bool) -> None: + if not shutil.which("rygel"): + raise BoundaryError("Rygel is not installed.") + verb = "enable" if enabled else "disable" + result = run(["systemctl", "--user", verb, "--now", "rygel.service"]) + if result.returncode != 0: + detail = (result.stderr or "").strip().splitlines() + raise BoundaryError(detail[-1] if detail else "Media sharing could not be changed.") + + def snapshot() -> dict: static_name = run(["hostnamectl", "--static"]).stdout.strip() pretty_name = run(["hostnamectl", "--pretty"]).stdout.strip() @@ -106,6 +162,7 @@ def snapshot() -> dict: login["port"] = ssh_setting("Port") or "22" login["passwordAuthentication"] = ssh_setting("PasswordAuthentication") login["rootLogin"] = ssh_setting("PermitRootLogin") + login["sessions"] = active_logins() return { "hostname": static_name, @@ -115,7 +172,7 @@ def snapshot() -> dict: # Reported as absent rather than offered as a switch that would do # nothing. Installing software is not this page's job. "fileSharing": {"installed": bool(shutil.which("smbd")), "package": "samba"}, - "mediaSharing": {"installed": bool(shutil.which("rygel")), "package": "rygel"}, + "mediaSharing": media_sharing(), "error": "", } @@ -187,7 +244,9 @@ def main(arguments: list[str]) -> int: if arguments == ["snapshot"]: print(json.dumps(snapshot(), separators=(",", ":"))) return 0 - if len(arguments) == 2 and arguments[0] == "set-remote-login": + if len(arguments) == 2 and arguments[0] == "set-media-sharing": + set_media_sharing(arguments[1] == "true") + elif len(arguments) == 2 and arguments[0] == "set-remote-login": set_remote_login(arguments[1] == "true") elif len(arguments) == 2 and arguments[0] == "set-remote-desktop": set_remote_desktop(arguments[1] == "true") diff --git a/config/dot/quickshell/services/DefaultApps.qml b/config/dot/quickshell/services/DefaultApps.qml index 7ff6761..d5b12c5 100644 --- a/config/dot/quickshell/services/DefaultApps.qml +++ b/config/dot/quickshell/services/DefaultApps.qml @@ -121,6 +121,20 @@ Singleton { mutationProcess.exec([root.helper, "set-autostart", desktopId, String(enabled)]); } + // Deletes the entry rather than hiding it. Disabling writes Hidden=true and + // is reversible; this is not, so the page confirms before calling it. + function removeAutostart(desktopId: string): void { + if (mutationProcess.running) + return; + const known = root.autostartEntries.some(entry => entry.id === desktopId); + if (!known) { + root.lastError = "That user autostart entry is no longer available." + return; + } + root.lastError = ""; + mutationProcess.exec([root.helper, "remove-autostart", desktopId]); + } + function addAutostart(desktopId: string): void { if (mutationProcess.running) return; diff --git a/config/dot/quickshell/services/Permissions.qml b/config/dot/quickshell/services/Permissions.qml new file mode 100644 index 0000000..2737aa0 --- /dev/null +++ b/config/dot/quickshell/services/Permissions.qml @@ -0,0 +1,92 @@ +pragma Singleton + +// Which applications may use the camera and microphone. +// +// Backed by xdg-desktop-portal's permission store, which records the answer an +// application got when it asked through the portal. That is the whole of what +// this controls, and the limit belongs on the page rather than in a comment: a +// native binary opens /dev/video0 directly and no desktop setting stands in its +// way. What this covers is Flatpaks and anything else going through the portal. + +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-permissions" + + property bool available: false + property var devices: [] + property bool scanned: false + property string lastError: "" + + // Guards read the Processes directly rather than a derived binding, which + // returns its cached value inside the handler that changes its dependency. + readonly property bool busy: query.running || mutation.running + + // Devices something has actually asked for. A device nothing has asked for + // is still reported, so the page can say so rather than omit it. + readonly property var recorded: root.devices.filter( + device => (device.applications ?? []).length > 0) + + readonly property int grantedCount: root.devices.reduce( + (total, device) => total + (device.applications ?? []).filter(app => app.allowed).length, 0) + + function refresh(): void { + if (query.running) + return; + query.command = [root.helperPath, "snapshot"]; + query.running = true; + } + + function absorb(text: string): void { + try { + const parsed = JSON.parse(text); + root.available = parsed.available === true; + root.devices = Array.isArray(parsed.devices) ? parsed.devices : []; + root.lastError = String(parsed.error ?? ""); + } catch (error) { + root.lastError = "Could not read the portal's permissions."; + console.warn("Permissions: could not parse helper output:", error); + } + root.scanned = true; + } + + function run(arguments: var): void { + if (mutation.running) + return; + root.lastError = ""; + mutation.command = [root.helperPath].concat(arguments); + mutation.running = true; + } + + function setAllowed(device: string, app: string, allowed: bool): void { + root.run(["set", device, app, allowed ? "allow" : "deny"]); + } + + // Drops the recorded answer entirely, so the application is asked again the + // next time it wants the device. + function forget(device: string, app: string): void { + root.run(["forget", device, app]); + } + + Component.onCompleted: root.refresh() + + Process { + id: query + stdout: StdioCollector { onStreamFinished: root.absorb(this.text) } + stderr: StdioCollector { + onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim() + } + } + + Process { + id: mutation + stdout: StdioCollector { onStreamFinished: root.absorb(this.text) } + stderr: StdioCollector { + onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim() + } + } +} diff --git a/config/dot/quickshell/services/Sharing.qml b/config/dot/quickshell/services/Sharing.qml index 905f9ac..f7e196f 100644 --- a/config/dot/quickshell/services/Sharing.qml +++ b/config/dot/quickshell/services/Sharing.qml @@ -36,6 +36,13 @@ Singleton { readonly property string networkName: root.hostname !== "" ? root.hostname : "this machine" readonly property bool remoteLoginOn: root.remoteLogin?.active === true + + // People signed in from another machine right now. Empty is the normal + // case; the list exists so that "someone is on this machine" is something + // the page can state rather than something you have to go and check. + readonly property var remoteSessions: Array.isArray(root.remoteLogin?.sessions) + ? root.remoteLogin.sessions + : [] readonly property bool remoteDesktopOn: root.remoteDesktop?.active === true // sshd's configuration only sometimes states this. Saying "keys only" when @@ -97,6 +104,13 @@ Singleton { root.run(["set-rdp-port", port]); } + // Rygel serves media to devices over DLNA. Turning it on publishes media + // directories to every device on the network, so the page says that next to + // the switch rather than after the fact. + function setMediaSharing(enabled: bool): void { + root.run(["set-media-sharing", enabled ? "true" : "false"]); + } + function setRdpViewOnly(viewOnly: bool): void { root.run(["set-rdp-view-only", viewOnly ? "true" : "false"]); } diff --git a/tests/quickshell/permissions-contract.sh b/tests/quickshell/permissions-contract.sh new file mode 100755 index 0000000..57479cc --- /dev/null +++ b/tests/quickshell/permissions-contract.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash + +# Application permissions, as far as the desktop can actually enforce them. +# +# The rules: +# +# 1. The page never claims more than the portal can do. A native binary opens +# /dev/video0 directly, and a settings page implying otherwise is worse +# than one that says nothing -- so the limit is stated on the page, not +# buried in a comment. +# 2. A device nothing has asked for is reported empty, not omitted. "No +# application uses your microphone" and a page that quietly leaves the +# microphone out look identical and mean very different things. +# 3. Absence is not failure. The store answers "No entry for microphone" for a +# device nobody has requested; treating that as an error would make the +# whole page fail because one device is unused. +# 4. Anything that is not an explicit "yes" is withheld. Guessing generously +# about a camera is the wrong way to be wrong. +# 5. A refusal states its reason. +# +# The write path is exercised against an application id that does not exist, so +# no real application's camera access is changed. What is on this machine -- +# OBS Studio and GNOME Snapshot -- is read, never written. + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +helper="$repo_dir/config/dot/quickshell/scripts/panama-permissions" +service="$repo_dir/config/dot/quickshell/services/Permissions.qml" +page="$repo_dir/config/dot/quickshell/modules/settings/PrivacyPage.qml" + +probe="org.panama.ContractProbe" + +fail() { + printf 'permissions contract: %s\n' "$1" >&2 + exit 1 +} + +for path in "$helper" "$service" "$page"; do + [[ -r "$path" ]] || fail "missing $path" +done +[[ -x "$helper" ]] || fail 'panama-permissions is not executable' + +field() { python3 -c "import json,sys; print(json.load(sys.stdin)$1)"; } + +state="$("$helper" snapshot)" || fail 'snapshot failed' + +if [[ "$(printf '%s' "$state" | field "['available']")" != "True" ]]; then + printf 'permissions contract: skipped (the portal permission store is not running)\n' + exit 0 +fi + +# ── 1. The page states the limit ──────────────────────────────────────────── + +grep -q 'directly' "$page" \ + || fail 'the page does not say that programs outside the portal reach these devices anyway' + +# ── 2 & 3. Unused devices are present and empty, not an error ─────────────── + +printf '%s' "$state" | python3 -c " +import json, sys +state = json.load(sys.stdin) +if state['error']: + raise SystemExit(f\"snapshot reported an error: {state['error']}\") +names = [d['id'] for d in state['devices']] +for required in ('camera', 'microphone', 'speakers'): + if required not in names: + raise SystemExit(f'{required} is missing from the snapshot entirely') +" || fail 'a device with no recorded application was dropped or reported as an error' + +# ── 4 & 5. The write path, on an application that does not exist ──────────── + +before="$(printf '%s' "$state" | field "['devices']")" + +denied="$("$helper" set camera "$probe" deny)" || fail 'set deny failed' +reason="$(printf '%s' "$denied" | field "['error']")" +[[ -z "$reason" ]] || fail "denying refused a valid write: $reason" +printf '%s' "$denied" | python3 -c " +import json, sys +for device in json.load(sys.stdin)['devices']: + for app in device['applications']: + if app['app'] == '$probe': + if app['allowed']: + raise SystemExit('a denied application was reported as allowed') + raise SystemExit(0) +raise SystemExit('the denied application was not written at all') +" || fail 'deny did not take effect -- the write path is not doing anything' + +allowed="$("$helper" set camera "$probe" allow)" || fail 'set allow failed' +printf '%s' "$allowed" | python3 -c " +import json, sys +for device in json.load(sys.stdin)['devices']: + for app in device['applications']: + if app['app'] == '$probe' and app['allowed']: + raise SystemExit(0) +raise SystemExit('allow did not take effect') +" || fail 'allow did not take effect' + +# Refusals name their reason rather than merely failing. +reason="$(printf '%s' "$("$helper" set camera "$probe" maybe)" | field "['error']")" +[[ "$reason" == *"allow or deny"* ]] \ + || fail "an invalid decision was not refused with a reason (got: $reason)" + +reason="$(printf '%s' "$("$helper" set nonsense "$probe" allow)" | field "['error']")" +[[ -n "$reason" ]] || fail 'an unknown device was accepted' + +# ── Put it back ──────────────────────────────────────────────────────────── + +"$helper" forget camera "$probe" >/dev/null || fail 'forget failed' +after="$("$helper" snapshot | field "['devices']")" +[[ "$before" == "$after" ]] \ + || fail 'the contract changed recorded permissions and did not restore them' + +printf 'permissions contract: ok\n'