160 lines
6.2 KiB
QML
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()
|
|
}
|
|
}
|
|
}
|