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
@@ -1,15 +1,24 @@
// Privacy & Security.
// Privacy.
//
// 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.
// 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 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.
// 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
@@ -18,12 +27,70 @@ import qs.services
SettingsPage {
id: root
title: "Privacy & Security"
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
property string confirmingPath: ""
// 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 {
@@ -38,9 +105,18 @@ SettingsPage {
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."
// 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)
@@ -49,40 +125,249 @@ SettingsPage {
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: "Screen lock"
subtitle: "The same settings as Power & Lock, which is where the idle timings live."
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.")
SliderRow { setting: "lockMinutes"; zeroLabel: "Never" }
ToggleRow { setting: "lockOnSleep"; divider: false }
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
}
}
// The login keyring, which nothing else surfaces.
// ── 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 ────────────────────────────────────────────
//
// It is unlocked at sign-in by PAM, so this card normally just confirms
// 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"
title: "Saved passwords & secrets"
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."
? "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."
// 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"
action: Keyring.unlocking ? "Waiting…" : "Unlock"
enabled: !Keyring.unlocking
divider: Keyring.replacementDaemon || Keyring.lastError !== ""
onTriggered: Keyring.unlock()
@@ -92,10 +377,10 @@ SettingsPage {
visible: !(Keyring.available && Keyring.locked)
label: "Login keyring"
detail: Keyring.available
? "Unlocked at sign-in by PAM, the same way GNOME does it"
? "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.replacementDaemon || Keyring.lastError !== ""
divider: Keyring.available || Keyring.replacementDaemon || Keyring.lastError !== ""
}
// Only shown when it is true, because it is a diagnostic rather than a
@@ -106,30 +391,20 @@ SettingsPage {
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 !== ""
divider: Keyring.available || Keyring.lastError !== ""
}
SettingRow {
visible: Keyring.lastError !== ""
label: "Keyring problem"
detail: Keyring.lastError
divider: false
divider: Keyring.available
}
}
// ── 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."
// 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 "
@@ -144,7 +419,7 @@ SettingsPage {
onTriggered: {
if (root.showingSecrets) {
root.showingSecrets = false;
root.confirmingPath = "";
root.confirmingItem = "";
return;
}
root.showingSecrets = true;
@@ -153,14 +428,6 @@ SettingsPage {
}
}
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 : []
@@ -191,14 +458,15 @@ SettingsPage {
required property int index
readonly property string itemPath: String(secretRow.modelData.path ?? "")
readonly property bool confirming: root.confirmingPath === secretRow.itemPath
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: 200
controlWidth: 300
divider: secretRow.index < (collectionBlock.modelData.items ?? []).length - 1
Row {
@@ -206,31 +474,44 @@ SettingsPage {
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 {
text: secretRow.confirming ? "Cancel" : "Copy"
anchors.verticalCenter: parent.verticalCenter
text: "Copy"
enabled: !Keyring.working
onClicked: {
if (secretRow.confirming) {
root.confirmingPath = "";
return;
}
Keyring.copy(secretRow.itemPath);
}
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.
text: secretRow.confirming ? "Forget it" : "Forget"
anchors.verticalCenter: parent.verticalCenter
text: secretRow.confirming ? "Forget it" : "Forget…"
tone: secretRow.confirming ? "danger" : "normal"
enabled: !Keyring.working
onClicked: {
if (!secretRow.confirming) {
root.confirmingPath = secretRow.itemPath;
root.confirmingItem = secretRow.itemPath;
return;
}
root.confirmingPath = "";
root.confirmingItem = "";
Keyring.forget(secretRow.itemPath);
}
}
@@ -251,101 +532,185 @@ SettingsPage {
}
}
// ── 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: "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."
title: "Traces"
subtitle: "What this machine remembers about what you did. Each row clears itself, once, when you say so."
TextRow {
label: "Camera"
detail: PrivacyState.cameraActive
? "In use by " + (PrivacyState.cameraApp || "an application")
: "Not in use"
value: PrivacyState.cameraActive ? "Active" : "Idle"
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"
}
}
}
TextRow {
label: "Microphone"
detail: PrivacyState.microphoneActive
? "In use by " + (PrivacyState.microphoneApp || "an application")
: "Not in use"
value: PrivacyState.microphoneActive ? "Active" : "Idle"
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: "Screen sharing"
detail: PrivacyState.screenSharingActive
? "Being shared by " + (PrivacyState.screenSharingApp || "an application")
: "Not being shared"
value: PrivacyState.screenSharingActive ? "Active" : "Idle"
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
}
}
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)
}
}
}
}
}
}
}
// ── Device security ──────────────────────────────────────────────────────
SettingsCard {
title: "Device security"
@@ -360,26 +725,44 @@ SettingsPage {
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
}
}
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: "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."
title: "Elsewhere"
subtitle: "Two neighbours of this page, kept where the rest of their settings are."
ActionRow {
label: "File history & trash"
detail: "Opens GNOME Settings, which owns these"
action: "Open privacy"
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: SystemSettings.openGnomePanel("privacy")
onTriggered: ShellState.openSettings("notifications")
}
}
}