Files
Panama/config/dot/quickshell/modules/settings/PrivacyPage.qml
T
Gabriel Brown f6b970da21 Turn the rows that only reported things into controls
Autostart entries showed "Enabled" or "Disabled" as plain text. The row did
toggle on click the whole time, so this is an affordance rather than a missing
capability -- but a control that reads as static text is one nobody knows they
have. It is a switch now, with removal alongside it behind a confirmation:
disabling writes Hidden=true and can be undone, deleting the file cannot.

remove-autostart is confined to files the autostart directory owns. It resolves
the path and compares the parent, so a name like "../../.bashrc" cannot escape,
and it refuses symlinks rather than following them -- deleting through one would
remove whatever it points at, which is somewhere else and not ours. Each refusal
was tested against a fixture directory, including a symlink aimed at
/etc/hostname, which survived.

Sharing says who is signed in from another machine: user, origin and since when.
An empty list on this machine proves nothing, so the parser was checked against
sample `who` output -- it picks out remote sessions and leaves out local seats
and the :0 display, which would otherwise report the person at the keyboard as a
remote login.

Media sharing was "Available" and nothing else: rygel installed, rygel.service
disabled, no way to change that from here. It is a switch now, and it says what
it does before you touch it rather than afterwards -- turning it on publishes
media folders to every device on the network with no password in front of them.

Per-application camera and microphone permissions come from the portal's
permission store, which is where an application that asked through the portal
has its answer recorded. The page states the limit plainly instead of implying a
protection that does not exist: a program installed outside the portal opens the
device directly and nothing here stands in its way. Anything that is not an
explicit "yes" is treated as withheld, because guessing generously about a
camera is the wrong way to be wrong.

The first version of the write silently did nothing -- SetPermission takes an
array of strings and was being handed one string -- and the test did not notice,
because it discarded the helper's output and only checked that state was
unchanged afterwards, which was trivially true. The contract now requires the
value to move, and was proven to fail by putting that exact bug back.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 23:10:48 -04:00

386 lines
16 KiB
QML

// Privacy & Security.
//
// GNOME's Privacy panel covers screen lock, camera and microphone access, file
// history, trash, and device security. Panama covers the parts it genuinely
// owns and is explicit about the parts it does not.
//
// The file-history and trash settings are the notable omission, and the reason
// is worth stating: those are GNOME preferences enforced by gsd-housekeeping,
// which is not running in a Hyprland session. Offering switches for them would
// store a preference, change nothing, and give no sign of it -- the exact
// failure this codebase keeps designing against. So they are delegated by name
// rather than reimplemented as controls that lie.
import QtQuick
import qs.config
import qs.services
SettingsPage {
id: root
title: "Privacy & Security"
// 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
property string confirmingPath: ""
// 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";
}
lede: DeviceSecurity.scanned && DeviceSecurity.attentionCount === 0
? "Screen lock, device access, and a machine whose security settings all check out."
: "Screen lock, which applications can see you, and how this machine is protected."
Component.onCompleted: {
if (!DeviceSecurity.scanned)
DeviceSecurity.refresh();
if (!Keyring.scanned)
Keyring.refresh();
if (!Permissions.scanned)
Permissions.refresh();
}
SettingsCard {
title: "Screen lock"
subtitle: "The same settings as Power & Lock, which is where the idle timings live."
SliderRow { setting: "lockMinutes"; zeroLabel: "Never" }
ToggleRow { setting: "lockOnSleep"; divider: false }
}
// The login keyring, which nothing else surfaces.
//
// It is unlocked at sign-in by PAM, so this card 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"
subtitle: !Keyring.available
? "No secret service is answering, so saved passwords are unavailable."
: Keyring.locked
? "The login keyring is locked. Saved passwords cannot be read until it is unlocked, and applications that need one will appear to fail for unrelated reasons."
: "The login keyring is unlocked, as it is after every normal sign-in."
// Two rows rather than one with a conditional button: a locked keyring
// needs an action, an unlocked one is a statement of fact, and ActionRow
// and TextRow already say exactly those two things.
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 at sign-in by PAM, the same way GNOME does it"
: "No secret service is answering on this session"
value: Keyring.available ? "Unlocked" : "Unavailable"
divider: 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.lastError !== ""
}
SettingRow {
visible: Keyring.lastError !== ""
label: "Keyring problem"
detail: Keyring.lastError
divider: false
}
}
// ── What is actually stored ──────────────────────────────────────────────
// Collapsed until asked. Opening the Privacy page should not enumerate
// someone's saved passwords as a side effect, and the list is long enough
// that it would bury every other setting on the page.
SettingsCard {
visible: Keyring.scanned && Keyring.available && !Keyring.locked
title: "Stored secrets"
subtitle: Keyring.listed
? "Passwords and tokens applications have saved. The values are never shown here."
: "Passwords and tokens applications have saved, listed only when you ask."
ActionRow {
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.confirmingPath = "";
return;
}
root.showingSecrets = true;
if (!Keyring.listed)
Keyring.list();
}
}
TextRow {
visible: root.showingSecrets && Keyring.copiedPath !== ""
label: "Copied to the clipboard"
detail: "It clears itself in about a minute, unless you copy something else first."
value: ""
divider: true
}
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.confirmingPath === 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: 200
divider: secretRow.index < (collectionBlock.modelData.items ?? []).length - 1
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: secretRow.confirming ? "Cancel" : "Copy"
enabled: !Keyring.working
onClicked: {
if (secretRow.confirming) {
root.confirmingPath = "";
return;
}
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.
text: secretRow.confirming ? "Forget it" : "Forget"
tone: secretRow.confirming ? "danger" : "normal"
enabled: !Keyring.working
onClicked: {
if (!secretRow.confirming) {
root.confirmingPath = secretRow.itemPath;
return;
}
root.confirmingPath = "";
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
}
}
SettingsCard {
title: "Camera & microphone"
subtitle: PrivacyState.anyActive
? "In use right now — the bar shows an indicator whenever this is true."
: "Nothing is using your camera or microphone."
TextRow {
label: "Camera"
detail: PrivacyState.cameraActive
? "In use by " + (PrivacyState.cameraApp || "an application")
: "Not in use"
value: PrivacyState.cameraActive ? "Active" : "Idle"
}
TextRow {
label: "Microphone"
detail: PrivacyState.microphoneActive
? "In use by " + (PrivacyState.microphoneApp || "an application")
: "Not in use"
value: PrivacyState.microphoneActive ? "Active" : "Idle"
}
TextRow {
label: "Screen sharing"
detail: PrivacyState.screenSharingActive
? "Being shared by " + (PrivacyState.screenSharingApp || "an application")
: "Not being shared"
value: PrivacyState.screenSharingActive ? "Active" : "Idle"
divider: false
}
}
SettingsCard {
title: "Application permissions"
// The limit is stated here rather than left to be discovered. Saying
// "your camera is protected" when a native binary can open it
// directly would be a claim this page cannot back up.
subtitle: Permissions.available
? "Applications that asked through the desktop portal. Programs installed outside it can still reach these devices directly."
: (Permissions.lastError || "The desktop portal's permission store is not running.")
Repeater {
model: Permissions.devices
delegate: Column {
id: deviceBlock
required property var modelData
readonly property var applications: deviceBlock.modelData.applications ?? []
width: parent.width
TextRow {
width: parent.width
visible: deviceBlock.applications.length === 0
label: String(deviceBlock.modelData.label ?? "")
detail: "No application has asked for this."
value: ""
}
Repeater {
model: deviceBlock.applications
delegate: SettingRow {
required property var modelData
width: parent.width
label: String(modelData.app ?? "")
detail: String(deviceBlock.modelData.label ?? "")
controlWidth: 150
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 9
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
text: "Ask again"
enabled: !Permissions.busy
onClicked: Permissions.forget(
String(deviceBlock.modelData.id), String(modelData.app))
}
SettingsToggle {
anchors.verticalCenter: parent.verticalCenter
checked: modelData.allowed === true
onToggled: value => Permissions.setAllowed(
String(deviceBlock.modelData.id), String(modelData.app), value)
}
}
}
}
}
}
}
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
required property int index
label: factRow.modelData.label
detail: factRow.modelData.detail
value: factRow.modelData.value
divider: factRow.index < DeviceSecurity.facts.length - 1
}
}
}
SettingsCard {
title: "Owned by Fedora"
subtitle: "File history and trash retention are GNOME preferences, applied by a housekeeping service that does not run in a Hyprland session. They are not offered as switches here, because storing that preference would change nothing."
ActionRow {
label: "File history & trash"
detail: "Opens GNOME Settings, which owns these"
action: "Open privacy"
divider: false
onTriggered: SystemSettings.openGnomePanel("privacy")
}
}
}