// Privacy. // // Three questions, in the order anyone actually asks them: what can see or hear // me right now, what was allowed to before, and what does this machine remember // about what I did. // // The first two used to be separate cards -- live PipeWire indicators in one, // portal permissions in another -- which split one subject in half and left the // screencast and remote-desktop tables, the two most consequential grants the // portal records, showing nowhere at all. They are one card now, live strip on // top and the tables under it. // // The third used to be a door: a card headed "Owned by Fedora" whose only row // opened GNOME's privacy panel, because file history and trash retention are // gsd-housekeeping preferences and gsd-housekeeping does not run here. That was // true about the SETTINGS and beside the point about the DATA. Clearing recent // files, thumbnails and the trash needs no daemon's cooperation, so Panama does // it, and the door is gone. // // What is deliberately not here: file-history and trash RETENTION switches, // which really would store a preference and change nothing. import QtQuick import qs.config import qs.services SettingsPage { id: root objectName: "privacy" title: "Privacy" lede: "What can see you, what remembers you, and what this machine's hardware promises." // The stored-secret list is collapsed until asked for, and one item at a // time can be waiting on a confirmed Forget. property bool showingSecrets: false // Which stored item's Forget is armed, and nothing else. It used to be read // by the Copy button too, which turned Copy into "Cancel" on any row whose // Forget was waiting -- so the press that meant "copy this password" meant // "never mind" instead, and the button that did it was the one labelled // Copy a moment earlier. Copy is only ever Copy now. property string confirmingItem: "" // "table/app" for the portal grant whose Revoke is armed, and the id of the // trace row whose Clear is armed. One each -- arming a second disarms the // first, which is what makes a misclick cheap. property string confirmingRevoke: "" property string confirmingTrace: "" // The portal's tables, in the order they matter, with the words for each -- // what a row means, and what an empty section means. Which CONTROL a row // gets is not decided here: the service answers that, from the helper's own // table list, so a page cannot grow a switch for something the permission // store refuses to write. Location is read-only and appears only when it // has entries, because geoclue is not running on this machine. readonly property var portalSections: [ { table: "camera", label: "Camera", detail: "", empty: "No app has asked for the camera. The portal records an answer the first time each one asks." }, { table: "microphone", label: "Microphone", detail: "", empty: "No app has asked for the microphone. The portal records an answer the first time each one asks." }, { table: "screencast", label: "Screen sharing", detail: "Can capture the screen", empty: "No app holds a screen-capture grant." }, { table: "remote-desktop", label: "Remote desktop", detail: "Can move the pointer and type", empty: "No app holds a remote-control grant." }, { table: "location", label: "Location", detail: "Recorded by the portal", empty: "" } ] function askedCount(count: int): string { if (count === 0) return ""; return count + (count === 1 ? " app has asked" : " apps have asked"); } // What a stored secret is FOR, from its attributes. Never its value. function describe(item: var): string { const attributes = item?.attributes ?? {}; const parts = []; for (const key of ["user", "username", "account", "server", "host", "domain", "service", "application"]) { if (attributes[key]) parts.push(String(attributes[key])); } if (parts.length > 0) return parts.join(" · "); const schema = String(item?.schema ?? ""); return schema !== "" ? schema : "No further detail stored"; } // The trash row is Storage's measurement, not a second one. Disks already // walks ~/.local/share/Trash and already knows how to empty it through gio, // and two implementations of "how big is the trash" would disagree the // first time one of them was changed. readonly property var trashCleanable: { for (const item of Disks.cleanables) { if (String(item.id ?? "") === "trash") return item; } return null; } Component.onCompleted: { if (!DeviceSecurity.scanned) DeviceSecurity.refresh(); if (!Keyring.scanned) Keyring.refresh(); if (!Permissions.scanned) Permissions.refresh(); // Two du walks over ~/.local/share/recently-used.xbel and // ~/.cache/thumbnails. Cheap enough to do on open; the trash figure // comes from Storage's own scan, which is not. if (!Traces.measured) Traces.measure(); if (!Disks.cleanablesMeasured) Disks.measureCleanables(); } // ── What can see and hear you ──────────────────────────────────────────── SettingsCard { title: "Camera, microphone & screen" subtitle: Permissions.available ? "What is in use now, and the answers applications gave the desktop portal when they asked." : (Permissions.lastError || "The desktop portal's permission store is not running, so the grants below cannot be read.") Row { width: parent.width spacing: 10 bottomPadding: 12 PrivacyLiveTile { width: (parent.width - 20) / 3 glyph: "\u{F0100}" label: "Camera" active: PrivacyState.cameraActive app: PrivacyState.cameraApp } PrivacyLiveTile { width: (parent.width - 20) / 3 glyph: "\u{F036C}" label: "Microphone" active: PrivacyState.microphoneActive app: PrivacyState.microphoneApp } PrivacyLiveTile { width: (parent.width - 20) / 3 glyph: "\u{F0379}" label: "Screen sharing" active: PrivacyState.screenSharingActive app: PrivacyState.screenSharingApp } } Repeater { model: root.portalSections delegate: Column { id: section required property var modelData readonly property string table: String(section.modelData.table) readonly property var rows: Permissions.rowsFor(section.table) // The service decides, not the page: `simple` is a table the // permission store will let something write, `revokeOnly` is one // it will only let something delete. A table that is neither // gets no buttons at all. readonly property bool simple: Permissions.isSimple(section.table) readonly property bool revokeOnly: Permissions.isRevokeOnly(section.table) width: parent.width // Location is the one section that disappears when empty: there // is no geoclue on this machine, so an empty Location heading // would be a category invented by the page. visible: section.table !== "location" || section.rows.length > 0 SectionLabel { text: String(section.modelData.label) count: root.askedCount(section.rows.length) } TextRow { width: section.width visible: section.rows.length === 0 label: "Nothing has asked yet" detail: String(section.modelData.empty) divider: false } Repeater { model: section.rows delegate: SettingRow { id: grantRow required property var modelData required property int index readonly property string app: String(grantRow.modelData.app ?? "") readonly property bool confirming: root.confirmingRevoke === section.table + "/" + grantRow.app width: section.width label: grantRow.app // `grants` is how many separate entries the store folded // into this row -- screencast keeps one per remembered // session, so an app that has shared four times is four // entries and one question. detail: grantRow.confirming ? "The recorded answer goes away, and this app is asked again the next time it tries." : (!section.simple && !section.revokeOnly ? (grantRow.modelData.allowed === true ? "Allowed" : "Withheld") : String(section.modelData.detail) + (Number(grantRow.modelData.grants ?? 0) > 1 ? " · " + grantRow.modelData.grants + " remembered sessions" : "")) controlWidth: section.simple ? 175 : (section.revokeOnly ? 165 : 10) divider: grantRow.index < section.rows.length - 1 Row { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter spacing: 8 // camera and microphone: forget the answer, or // change it. Both are writes the store accepts. SettingsButton { anchors.verticalCenter: parent.verticalCenter visible: section.simple text: "Ask again" enabled: !Permissions.busy onClicked: Permissions.revoke(section.table, grantRow.app) } SettingsToggle { anchors.verticalCenter: parent.verticalCenter visible: section.simple checked: grantRow.modelData.allowed === true onToggled: value => Permissions.setPermission( section.table, grantRow.app, value) } // screencast and remote-desktop: two presses, and // no toggle at all. The value is a structured // GVariant this page cannot compose, so revoking is // the only thing it can honestly offer. SettingsButton { anchors.verticalCenter: parent.verticalCenter visible: section.revokeOnly && grantRow.confirming text: "Revoke it" tone: "danger" enabled: !Permissions.busy onClicked: { root.confirmingRevoke = ""; Permissions.revoke(section.table, grantRow.app); } } SettingsButton { anchors.verticalCenter: parent.verticalCenter visible: section.revokeOnly text: grantRow.confirming ? "Keep" : "Revoke…" enabled: !Permissions.busy onClicked: root.confirmingRevoke = grantRow.confirming ? "" : section.table + "/" + grantRow.app } } } } Item { width: 1; height: 4 } } } // The limit, stated rather than left to be discovered. Saying "your // camera is protected" when a system package can open /dev/video0 // would be a claim this page cannot back up. TextRow { label: "Only sandboxed apps are covered" detail: "These are the answers Flatpak apps gave the permission portal. Programs installed as system packages are not sandboxed, never ask, and reach these devices directly." divider: false } } // ── Background ─────────────────────────────────────────────────────────── // Its own card because it is a different question -- not who can watch you, // but who keeps running when you thought you had closed them -- and because // it is the longest table the portal keeps. SettingsCard { id: backgroundCard readonly property var rows: Permissions.rowsFor("background") visible: Permissions.available title: "Run in the background" subtitle: backgroundCard.rows.length === 0 ? "No app has asked to keep running with no window open." : backgroundCard.rows.length + " app" + (backgroundCard.rows.length === 1 ? " is" : "s are") + " allowed to keep running with no window open." Repeater { model: backgroundCard.rows delegate: SettingRow { id: backgroundRow required property var modelData required property int index width: parent.width label: String(backgroundRow.modelData.app ?? "") controlWidth: 70 divider: backgroundRow.index < backgroundCard.rows.length - 1 SettingsToggle { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter checked: backgroundRow.modelData.allowed === true onToggled: value => Permissions.setPermission( "background", String(backgroundRow.modelData.app ?? ""), value) } } } } // ── Saved passwords & secrets ──────────────────────────────────────────── // // The keyring is unlocked at sign-in by PAM, so this normally just confirms // that. It earns its place on the rare occasion it is not: a locked keyring // breaks saved passwords everywhere at once, and does it without ever // saying the word "keyring" -- you get a mail account that will not // authenticate and a git push that cannot find its key. SettingsCard { visible: Keyring.scanned title: "Saved passwords & secrets" subtitle: !Keyring.available ? "No secret service is answering, so saved passwords are unavailable." : Keyring.locked ? "The login keyring is locked. Applications that need a saved password will appear to fail for unrelated reasons until it is unlocked." : "Values never leave the keyring except onto your clipboard, one at a time, when you ask." ActionRow { visible: Keyring.available && Keyring.locked label: "Login keyring" detail: "Unlock to restore access to stored passwords and keys" action: Keyring.unlocking ? "Waiting…" : "Unlock…" enabled: !Keyring.unlocking divider: Keyring.replacementDaemon || Keyring.lastError !== "" onTriggered: Keyring.unlock() } TextRow { visible: !(Keyring.available && Keyring.locked) label: "Login keyring" detail: Keyring.available ? "Unlocked with your password at sign-in, the same way GNOME does it" : "No secret service is answering on this session" value: Keyring.available ? "Unlocked" : "Unavailable" divider: Keyring.available || Keyring.replacementDaemon || Keyring.lastError !== "" } // Only shown when it is true, because it is a diagnostic rather than a // setting: it means the daemon holding your secrets is not the one PAM // started, so whatever unlocked it will not survive a restart. SettingRow { visible: Keyring.replacementDaemon label: "Keyring service" detail: "The original keyring service was replaced during this session, usually after it crashed. Signing out and back in restores the one PAM unlocks." value: "Replaced" divider: Keyring.available || Keyring.lastError !== "" } SettingRow { visible: Keyring.lastError !== "" label: "Keyring problem" detail: Keyring.lastError divider: Keyring.available } // Collapsed until asked. Opening the Privacy page should not enumerate // someone's saved passwords as a side effect. ActionRow { visible: Keyring.available && !Keyring.locked label: "Saved items" detail: Keyring.listed ? Keyring.storedCount + " stored across " + Keyring.collections.length + " keyring" + (Keyring.collections.length === 1 ? "" : "s") : "Read the keyring and list what is in it" action: root.showingSecrets ? "Hide" : (Keyring.listing ? "Reading…" : "Show") enabled: !Keyring.listing divider: root.showingSecrets onTriggered: { if (root.showingSecrets) { root.showingSecrets = false; root.confirmingItem = ""; return; } root.showingSecrets = true; if (!Keyring.listed) Keyring.list(); } } Repeater { model: root.showingSecrets && Keyring.listed ? Keyring.collections : [] delegate: Column { id: collectionBlock required property var modelData width: parent.width TextRow { width: collectionBlock.width label: String(collectionBlock.modelData.label ?? "") detail: collectionBlock.modelData.locked ? "Locked, so its contents cannot be listed" : (collectionBlock.modelData.items ?? []).length + " stored" value: "" divider: false } Repeater { model: collectionBlock.modelData.items ?? [] delegate: SettingRow { id: secretRow required property var modelData required property int index readonly property string itemPath: String(secretRow.modelData.path ?? "") readonly property bool confirming: root.confirmingItem === secretRow.itemPath readonly property bool copied: Keyring.copiedPath === secretRow.itemPath width: collectionBlock.width label: String(secretRow.modelData.label ?? "") // Attributes, never the value: what the secret is FOR is // the part that identifies it. detail: root.describe(secretRow.modelData) controlWidth: 300 divider: secretRow.index < (collectionBlock.modelData.items ?? []).length - 1 Row { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter spacing: 8 // Said next to the row it happened on, and true: // the helper leaves a watcher that clears the // clipboard 45 seconds later, and only if the // clipboard is still holding what it put there. Text { anchors.verticalCenter: parent.verticalCenter visible: secretRow.copied text: "Copied — clears in 45 s" color: Theme.ok font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall } // Copy is only ever Copy. It used to turn into // Cancel while the row next to it was awaiting a // confirmed Forget, which put the word "Cancel" // where the user had just learnt to find "Copy". SettingsButton { anchors.verticalCenter: parent.verticalCenter text: "Copy" enabled: !Keyring.working onClicked: Keyring.copy(secretRow.itemPath) } SettingsButton { // Two presses, always. Forgetting a stored // password cannot be undone, and the button // sits next to Copy where a misclick is cheap. anchors.verticalCenter: parent.verticalCenter text: secretRow.confirming ? "Forget it" : "Forget…" tone: secretRow.confirming ? "danger" : "normal" enabled: !Keyring.working onClicked: { if (!secretRow.confirming) { root.confirmingItem = secretRow.itemPath; return; } root.confirmingItem = ""; Keyring.forget(secretRow.itemPath); } } } } } Item { width: 1; height: 6 } } } TextRow { visible: root.showingSecrets && Keyring.listed && Keyring.storedCount === 0 label: "Nothing stored yet" detail: "Applications that save a password or token will appear here." value: "" divider: false } } // ── Traces ─────────────────────────────────────────────────────────────── // // Panama's own, now. Each row clears one thing, once, when asked -- there // is no "clean up your machine" button here and no number coloured red, // because none of this is a problem to be solved. It is a list of what is // remembered, with a way to stop remembering it. SettingsCard { title: "Traces" subtitle: "What this machine remembers about what you did. Each row clears itself, once, when you say so." SettingRow { id: recentsRow readonly property bool confirming: root.confirmingTrace === "recents" label: "Recent files" detail: recentsRow.confirming ? "This empties the list. Applications start filling it again as you open things." : "The recents list GTK apps show" + (Traces.measured ? " · " + Traces.recentsEntries + " entr" + (Traces.recentsEntries === 1 ? "y" : "ies") : "") controlWidth: 210 Row { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter spacing: 8 Text { anchors.verticalCenter: parent.verticalCenter visible: !recentsRow.confirming && Traces.measured text: Disks.formatBytes(Traces.recentsBytes) color: Theme.fgDim font.family: Theme.fontFamily font.features: Theme.tabularFigures font.pixelSize: Theme.fontSize } SettingsButton { anchors.verticalCenter: parent.verticalCenter visible: recentsRow.confirming text: "Clear it" tone: "danger" enabled: !Traces.busy onClicked: { root.confirmingTrace = ""; Traces.clearRecents(); } } SettingsButton { anchors.verticalCenter: parent.verticalCenter text: recentsRow.confirming ? "Keep" : "Clear…" enabled: !Traces.busy onClicked: root.confirmingTrace = recentsRow.confirming ? "" : "recents" } } } SettingRow { id: thumbnailsRow readonly property bool confirming: root.confirmingTrace === "thumbnails" label: "Thumbnails" detail: thumbnailsRow.confirming ? "This empties the cache. File managers rebuild each preview the next time they show it." : "Previews of images you have seen · rebuilt as you browse" controlWidth: 210 Row { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter spacing: 8 Text { anchors.verticalCenter: parent.verticalCenter visible: !thumbnailsRow.confirming && Traces.measured // "over" when the walk ran out of budget: the number is a // floor then, not an answer, and saying so costs one word. text: (Traces.thumbnailsComplete ? "" : "over ") + Disks.formatBytes(Traces.thumbnailsBytes) color: Theme.fgDim font.family: Theme.fontFamily font.features: Theme.tabularFigures font.pixelSize: Theme.fontSize } SettingsButton { anchors.verticalCenter: parent.verticalCenter visible: thumbnailsRow.confirming text: "Clear it" tone: "danger" enabled: !Traces.busy onClicked: { root.confirmingTrace = ""; Traces.clearThumbnails(); } } SettingsButton { anchors.verticalCenter: parent.verticalCenter text: thumbnailsRow.confirming ? "Keep" : "Clear…" enabled: !Traces.busy onClicked: root.confirmingTrace = thumbnailsRow.confirming ? "" : "thumbnails" } } } SettingRow { id: trashRow readonly property bool confirming: root.confirmingTrace === "trash" visible: root.trashCleanable !== null label: "Trash" detail: trashRow.confirming ? "Emptying is permanent — the files do not go anywhere else first." : "Files you deleted — the same Trash that Storage cleans" controlWidth: 210 Row { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter spacing: 8 Text { anchors.verticalCenter: parent.verticalCenter visible: !trashRow.confirming && Disks.cleanablesMeasured text: Disks.formatBytes(Number(root.trashCleanable?.bytes ?? 0)) color: Theme.fgDim font.family: Theme.fontFamily font.features: Theme.tabularFigures font.pixelSize: Theme.fontSize } SettingsButton { anchors.verticalCenter: parent.verticalCenter visible: trashRow.confirming text: "Empty it" tone: "danger" enabled: Disks.cleaningId === "" onClicked: { root.confirmingTrace = ""; Disks.clean("trash"); } } SettingsButton { anchors.verticalCenter: parent.verticalCenter text: trashRow.confirming ? "Keep" : "Empty…" enabled: Disks.cleaningId === "" onClicked: root.confirmingTrace = trashRow.confirming ? "" : "trash" } } } // A pointer, not a control: the history belongs to Vicinae, which owns // the database Super+V browses. Offering a Clear here would mean // reaching into another program's storage behind its back. TextRow { label: "Clipboard history" detail: "Kept by Vicinae, which owns the database Super+V browses. Panama reads that history and does not clear it." value: "" divider: Traces.lastError !== "" } SettingRow { visible: Traces.lastError !== "" label: "That did not work" detail: Traces.lastError divider: false } } // ── Device security ────────────────────────────────────────────────────── SettingsCard { title: "Device security" subtitle: DeviceSecurity.attentionCount === 0 ? "Everything below is in its recommended state." : DeviceSecurity.attentionCount + " item" + (DeviceSecurity.attentionCount === 1 ? "" : "s") + " below may deserve attention." Repeater { model: DeviceSecurity.facts TextRow { id: factRow required property var modelData label: factRow.modelData.label detail: factRow.modelData.detail value: factRow.modelData.value } } ActionRow { label: "Check again" detail: "Re-reads the firmware, the kernel and the filesystem" action: "Refresh" divider: false onTriggered: DeviceSecurity.refresh() } } // ── Elsewhere ──────────────────────────────────────────────────────────── // The screen-lock timings used to be duplicated onto this page, which meant // two sliders writing one preference and no way to tell which one you had // last touched. They live with the idle rules they belong to; this points. SettingsCard { title: "Elsewhere" subtitle: "Two neighbours of this page, kept where the rest of their settings are." ActionRow { label: "Screen lock timing" detail: "Lives with the idle rules it belongs to" action: "Open Power & Lock" onTriggered: ShellState.openSettings("power") } ActionRow { label: "Per-app notifications" detail: "Which apps may notify, and how much they may show on a locked screen" action: "Open Notifications" divider: false onTriggered: ShellState.openSettings("notifications") } } }