pragma Singleton // 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 import QtQuick Singleton { id: root readonly property string helperPath: Quickshell.env("PANAMA_PERMISSIONS_HELPER") || Quickshell.shellDir + "/scripts/panama-permissions" property bool available: false // { 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: "" // 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 // 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"] 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) 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.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."; 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; } // 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 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() 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() } } }