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
386 lines
16 KiB
QML
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")
|
|
}
|
|
}
|
|
}
|