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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user