Files

160 lines
6.2 KiB
QML

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 application 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()
}
}
}