Show every answer the portal remembers, and give SSH keys their missing half

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-24 19:26:56 -04:00
parent 4ec8bd94d9
commit 6f0ce639d9
25 changed files with 3622 additions and 408 deletions
+82 -15
View File
@@ -1,12 +1,26 @@
pragma Singleton
// Which applications may use the camera and microphone.
// What the desktop portal has recorded, for the six subjects it arbitrates.
//
// 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.
//
// Two shapes of permission, and the difference matters to the page:
//
// simple camera, microphone, background. A plain yes or no, so a switch
// can honour what it shows.
// revoke-only screencast, remote-desktop. Each grant is a remembered session
// -- which monitor, which input devices -- and nothing here can
// rebuild one, so these can be dropped and not switched. The
// helper has no code path that writes them at all; this service
// refuses before it gets there, and neither refusal is the only
// one.
//
// location is listed and nothing more. geoclue is absent on this machine, so
// the table is normally empty and the page leaves the section out entirely.
import Quickshell
import Quickshell.Io
@@ -15,10 +29,16 @@ import QtQuick
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-permissions"
readonly property string helperPath: Quickshell.env("PANAMA_PERMISSIONS_HELPER")
|| Quickshell.shellDir + "/scripts/panama-permissions"
property bool available: false
property var devices: []
// { camera: [{ app, allowed, grants, raw }], microphone: [...], ... }
// Every table is present even when empty, so the page can say "nothing has
// asked" rather than quietly leaving a subject out.
property var tables: ({})
property bool scanned: false
property string lastError: ""
@@ -26,13 +46,47 @@ Singleton {
// 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)
// The tables whose value is a plain yes or no, and the ones that can only be
// revoked. Read from the helper so the two never drift apart, with the
// helper's own answer as the fallback before the first read lands.
property var simpleTables: ["camera", "microphone", "background"]
property var revokeOnlyTables: ["screencast", "remote-desktop"]
readonly property int grantedCount: root.devices.reduce(
(total, device) => total + (device.applications ?? []).filter(app => app.allowed).length, 0)
function rowsFor(table: string): var {
const rows = root.tables[table];
return Array.isArray(rows) ? rows : [];
}
function countFor(table: string): int {
return root.rowsFor(table).length;
}
function isSimple(table: string): bool {
return (root.simpleTables ?? []).indexOf(table) >= 0;
}
function isRevokeOnly(table: string): bool {
return (root.revokeOnlyTables ?? []).indexOf(table) >= 0;
}
readonly property int grantedCount: {
let total = 0;
for (const name in root.tables)
total += root.rowsFor(name).filter(row => row.allowed === true).length;
return total;
}
// The devices half of the store, in the shape the applications list has
// always read it in. Kept so that "this app has a privacy rule" keeps
// working there without that page having to learn about tables.
readonly property var devices: {
const known = root.tables;
const rows = name => Array.isArray(known[name]) ? known[name] : [];
return [
{ "id": "camera", "label": "Camera", "applications": rows("camera") },
{ "id": "microphone", "label": "Microphone", "applications": rows("microphone") },
];
}
function refresh(): void {
if (query.running)
@@ -45,7 +99,11 @@ Singleton {
try {
const parsed = JSON.parse(text);
root.available = parsed.available === true;
root.devices = Array.isArray(parsed.devices) ? parsed.devices : [];
root.tables = (parsed.tables && typeof parsed.tables === "object") ? parsed.tables : ({});
if (Array.isArray(parsed.simpleTables))
root.simpleTables = parsed.simpleTables;
if (Array.isArray(parsed.revokeOnlyTables))
root.revokeOnlyTables = parsed.revokeOnlyTables;
root.lastError = String(parsed.error ?? "");
} catch (error) {
root.lastError = "Could not read the portal's permissions.";
@@ -62,14 +120,23 @@ Singleton {
mutation.running = true;
}
function setAllowed(device: string, app: string, allowed: bool): void {
root.run(["set", device, app, allowed ? "allow" : "deny"]);
// Only for the tables whose stored value is a plain yes or no. A screencast
// grant reaching this is a bug in the caller, and it is refused here rather
// than forwarded, so no switch can be wired to something the portal will not
// honour.
function setPermission(table: string, app: string, allowed: bool): void {
if (!root.isSimple(table)) {
root.lastError = "That permission describes a whole session, so it can only be revoked.";
return;
}
root.run(["set", table, app, allowed ? "true" : "false"]);
}
// 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]);
// next time it wants this. Works for every table, including the ones a
// switch cannot touch.
function revoke(table: string, app: string): void {
root.run(["forget", table, app]);
}
Component.onCompleted: root.refresh()