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()
@@ -130,6 +130,12 @@ Singleton {
{ label: "SSH agent", detail: "Which keys are held for this session", page: "ssh-keys" },
{ label: "Known hosts", detail: "Machines this one has connected to before", page: "ssh-keys" },
{ label: "Public key", detail: "Copy the half you paste into a server", page: "ssh-keys" },
// The page makes keys now rather than only listing them, so the verbs
// people arrive with — make one, unload one, fix the mode ssh refuses —
// each have a name to be found by.
{ label: "Generate an SSH key", detail: "Create an ed25519 key with a passphrase, without opening a terminal", page: "ssh-keys" },
{ label: "Fix key permissions", detail: "Make a private key readable only by you, which is what ssh insists on", page: "ssh-keys" },
{ label: "Remove from agent", detail: "Stop a key being offered for the rest of this session", page: "ssh-keys" },
{ label: "Podman", detail: "Running containers, images and volumes", page: "containers" },
{ label: "Container logs", detail: "Follow what a container is printing", page: "containers" },
{ label: "Reclaim container space", detail: "Remove images and volumes nothing uses", page: "containers" },
@@ -176,6 +182,18 @@ Singleton {
{ label: "Screen sharing", detail: "Which applications may capture the screen", page: "privacy" },
{ label: "File history and trash", detail: "What is remembered and when it is cleared", page: "privacy" },
{ label: "Device security", detail: "Secure boot and firmware protections", page: "privacy" },
// Privacy stopped handing file history to GNOME and stopped listing two
// devices where the portal arbitrates six tables, so the subjects it
// now actually owns are findable by their own names. "Application
// permissions" appears twice on purpose: the Applications page answers
// what a Flatpak's sandbox exposes, this one answers what the portal
// recorded, and they are different questions with the same name.
{ label: "Background apps", detail: "Which applications may keep running after you close them", page: "privacy" },
{ label: "Screen sharing permission", detail: "Take back a screen-capture grant an application was given", page: "privacy" },
{ label: "Remote desktop permission", detail: "Take back a grant to control this desktop's pointer and keyboard", page: "privacy" },
{ label: "Clear recent files", detail: "Empty the list of documents this desktop remembers you opening", page: "privacy" },
{ label: "Thumbnails", detail: "The cached previews of your pictures and videos, and clearing them", page: "privacy" },
{ label: "Application permissions", detail: "What the desktop portal has recorded: camera, microphone, screen, background", page: "privacy" },
{ label: "Language", detail: "The system language, applied to programs started afterwards", page: "region" },
{ label: "Regional formats", detail: "How dates, times, and numbers are written", page: "region" },
{ label: "Online accounts", detail: "Sign in to mail, calendar, and contacts", page: "accounts" },
+90 -7
View File
@@ -2,10 +2,16 @@ pragma Singleton
// SSH keys, the agent holding them, and the hosts this machine has met.
//
// Nothing here ever sees a private key or a passphrase. Adding an encrypted key
// makes ssh-add prompt through the system's own askpass, which is where a
// passphrase belongs -- a settings page collecting one and passing it along
// would be a worse place for it to live.
// Nothing here ever sees a private key. A passphrase crosses this service in
// exactly one place -- creating a key -- and it crosses without ever being
// stored: it is held only for as long as it takes to write it to the helper's
// standard input, which is a channel no other process can read, and cleared in
// the same handler. It is never put in a command, because argv is public to
// every process on the machine, and never written to a file. The helper types it
// at ssh-keygen over a terminal it opens for the purpose.
//
// Adding an EXISTING encrypted key is different and collects nothing: ssh-add
// prompts through the system's own askpass, which is where that belongs.
import Quickshell
import Quickshell.Io
@@ -14,7 +20,8 @@ import QtQuick
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-ssh-keys"
readonly property string helperPath: Quickshell.env("PANAMA_SSH_KEYS_HELPER")
|| Quickshell.shellDir + "/scripts/panama-ssh-keys"
property bool available: false
property string directory: ""
@@ -24,10 +31,31 @@ Singleton {
property bool scanned: false
property string lastError: ""
readonly property bool busy: query.running || mutation.running
// Held between the call and the moment the helper starts, and no longer.
// Never read back out, never logged, never part of a command line.
property string pendingPassphrase: ""
readonly property bool busy: query.running || mutation.running || generator.running
// Generating is its own state: it can take a moment, it is the one action
// here that creates something, and a page wants to say so specifically
// rather than greying out the whole card.
readonly property bool generating: generator.running
readonly property int loadedCount: root.keys.filter(key => key.loaded === true).length
// Whether removing a key from this machine's agent actually sticks.
// gnome-keyring's agent enumerates whatever it finds in ~/.ssh, so a
// removal reports success and the key is back a second later. The helper
// measures this rather than assuming it, and refuses the removal with its
// reason -- which arrives here as lastError, in the helper's own words.
readonly property bool durableRemoval: root.agent?.durableRemoval === true
readonly property string agentKind: String(root.agent?.kind ?? "")
// Set for a moment after a public key reaches the clipboard, so the page can
// say what happened without having to know how long a clipboard lasts.
property string copiedKey: ""
// Keys readable by anyone but their owner. ssh refuses to use these, so a
// page that stayed quiet about it would leave someone wondering why a key
// that plainly exists is never offered.
@@ -69,6 +97,28 @@ Singleton {
// is allowed a long time before it is considered stuck.
function addToAgent(path: string): void { root.run(["agent-add", path]); }
// Unloading a key. On an agent where that does not stick the helper refuses
// and says why, and the page shows that sentence rather than a shorter one
// of its own -- the reason is the useful part.
function removeFromAgent(path: string): void { root.run(["agent-remove", path]); }
// ed25519, always. The passphrase goes down the helper's standard input and
// nowhere else; see the note at the top of this file.
function generate(name: string, comment: string, passphrase: string): void {
if (generator.running)
return;
root.lastError = "";
root.pendingPassphrase = passphrase;
generator.command = [root.helperPath, "generate", name, comment];
generator.stdinEnabled = true;
generator.running = true;
}
// 0600, so ssh will use the key at all. Takes the key's name rather than a
// path: the helper resolves it inside ~/.ssh and refuses anything that
// resolves elsewhere, which is not a check to hand to the caller.
function fixPermissions(name: string): void { root.run(["fix-permissions", name]); }
// The public half, onto the clipboard. Safe to copy by definition -- it is
// the thing you paste into a server. The path arrives as $1 rather than
// being spliced into shell source, so a name with a space or a quote in it
@@ -76,6 +126,7 @@ Singleton {
function copyPublicKey(publicPath: string): void {
if (publicPath === "" || copier.running)
return;
root.copiedKey = publicPath;
copier.command = ["sh", "-c", 'exec wl-copy < "$1"', "qs-ssh-keys", publicPath];
copier.running = true;
}
@@ -83,7 +134,39 @@ Singleton {
Component.onCompleted: root.refresh()
Process { id: copier }
Process {
id: copier
onExited: exitCode => { if (exitCode !== 0) root.copiedKey = ""; }
}
// The clipboard notice is transient, and says so by disappearing.
Timer {
running: root.copiedKey !== ""
interval: 12000
onTriggered: root.copiedKey = ""
}
// Its own Process because it is the only one with anything on stdin, and
// because the passphrase handover has to happen in onStarted -- there is
// nothing to write to before then.
Process {
id: generator
stdinEnabled: true
onStarted: {
generator.write(root.pendingPassphrase + "\n");
// Held for as long as it takes to hand over, and no longer.
root.pendingPassphrase = "";
// Closing stdin is what tells the helper the passphrase is complete.
generator.stdinEnabled = false;
}
// The helper answers with the fresh snapshot plus whatever went wrong,
// so a created key lands on the page without a second read.
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
onExited: root.pendingPassphrase = ""
}
Process {
id: query
+114
View File
@@ -0,0 +1,114 @@
pragma Singleton
// What this desktop remembers about what you opened.
//
// Two traces -- the recent-files list and the thumbnail cache -- both of them a
// normal and useful part of a desktop rather than a problem. This measures them
// when asked and clears them when asked, and does neither on its own.
//
// Nothing here measures on startup. Walking a thumbnail cache costs real time
// on a machine that has browsed a large picture library, and a settings page
// that has not been opened has no business spending it. `measured` says whether
// there is an answer yet, so the card can show its own state honestly instead of
// showing a confident zero.
//
// Trash is deliberately absent. It is measured and emptied by Disks, and one
// trash implementation is the right number to have.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
readonly property string helperPath: Quickshell.env("PANAMA_PRIVACY_HELPER")
|| Quickshell.shellDir + "/scripts/panama-privacy"
// real rather than int: a thumbnail cache on a machine with a large picture
// library goes past what a 32-bit int holds, and a byte count that wrapped
// negative is worse than no byte count.
property real recentsBytes: 0
property int recentsEntries: 0
property string recentsPath: ""
property bool recentsPresent: false
property real thumbnailsBytes: 0
property string thumbnailsPath: ""
property bool thumbnailsPresent: false
// False when the walk ran out of time, which makes the byte count a floor
// rather than an answer.
property bool thumbnailsComplete: true
// Whether there is an answer at all yet. Distinct from thumbnailsComplete,
// which is about the quality of an answer that exists.
property bool measured: false
property string lastError: ""
readonly property bool busy: reader.running || mutation.running
function measure(): void {
if (reader.running)
return;
reader.command = [root.helperPath, "traces"];
reader.running = true;
}
function absorb(text: string): void {
try {
const parsed = JSON.parse(text);
const recents = parsed.recents ?? ({});
const thumbnails = parsed.thumbnails ?? ({});
root.recentsBytes = Number(recents.bytes ?? 0);
root.recentsEntries = Number(recents.entries ?? 0);
root.recentsPath = String(recents.path ?? "");
root.recentsPresent = recents.present === true;
root.thumbnailsBytes = Number(thumbnails.bytes ?? 0);
root.thumbnailsPath = String(thumbnails.path ?? "");
root.thumbnailsPresent = thumbnails.present === true;
root.thumbnailsComplete = thumbnails.measured !== false;
root.lastError = String(parsed.error ?? "");
} catch (error) {
root.lastError = "Could not read what the desktop has remembered.";
console.warn("Traces: could not parse helper output:", error);
}
root.measured = true;
}
function run(verb: string): void {
if (mutation.running)
return;
root.lastError = "";
mutation.command = [root.helperPath, verb];
mutation.running = true;
}
// The list is emptied, never deleted: GTK recreates the file the moment
// anything opens a file anyway, and an empty valid document takes effect in
// every running application immediately.
function clearRecents(): void { root.run("clear-recents"); }
// The folder stays; what is inside it goes. The helper refuses to follow a
// link out of it.
function clearThumbnails(): void { root.run("clear-thumbnails"); }
Process {
id: reader
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
}
// Both clears answer with a freshly measured snapshot, so the card updates
// without a second read.
Process {
id: mutation
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
}
}