796 lines
34 KiB
QML
796 lines
34 KiB
QML
// 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 application has asked for the camera. The portal records an answer the first time each one asks."
|
|
},
|
|
{
|
|
table: "microphone",
|
|
label: "Microphone",
|
|
detail: "",
|
|
empty: "No application 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 application holds a screen-capture grant."
|
|
},
|
|
{
|
|
table: "remote-desktop",
|
|
label: "Remote desktop",
|
|
detail: "Can move the pointer and type",
|
|
empty: "No application 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 ? " application has asked" : " applications 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.scanned
|
|
? "Reading what is in use now, and what the desktop portal has recorded."
|
|
: (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"
|
|
// False until pw-dump has answered once, and false forever if
|
|
// it cannot run: an unmeasured tile must not read as "Idle".
|
|
measured: PrivacyState.initialized
|
|
active: PrivacyState.cameraActive
|
|
app: PrivacyState.cameraApp
|
|
}
|
|
|
|
PrivacyLiveTile {
|
|
width: (parent.width - 20) / 3
|
|
glyph: "\u{F036C}"
|
|
label: "Microphone"
|
|
measured: PrivacyState.initialized
|
|
active: PrivacyState.microphoneActive
|
|
app: PrivacyState.microphoneApp
|
|
}
|
|
|
|
PrivacyLiveTile {
|
|
width: (parent.width - 20) / 3
|
|
glyph: "\u{F0379}"
|
|
label: "Screen sharing"
|
|
measured: PrivacyState.initialized
|
|
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)
|
|
}
|
|
|
|
// An empty table means "nothing has asked" only once the store
|
|
// has been read and answered. Before that, and when the
|
|
// permission store is not running at all, every table is empty
|
|
// for the same reason -- and "no application has asked for the camera"
|
|
// is then a measurement nobody took.
|
|
TextRow {
|
|
width: section.width
|
|
visible: section.rows.length === 0
|
|
label: !Permissions.scanned
|
|
? "Not read yet"
|
|
: (Permissions.available ? "Nothing has asked yet" : "Not measured")
|
|
detail: !Permissions.scanned
|
|
? "Reading what the desktop portal has recorded."
|
|
: (Permissions.available
|
|
? String(section.modelData.empty)
|
|
: "The desktop portal's permission store is not answering, so what has asked for this cannot be read.")
|
|
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 application 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 }
|
|
}
|
|
}
|
|
|
|
Item { width: 1; height: 10 }
|
|
|
|
// 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.
|
|
SettingsNote {
|
|
headline: "Only sandboxed applications are covered"
|
|
body: "These are the answers Flatpak applications gave the permission portal. Programs installed as system packages are not sandboxed, never ask, and reach these devices directly."
|
|
}
|
|
}
|
|
|
|
// ── 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 application has asked to keep running with no window open."
|
|
: backgroundCard.rows.length + " application"
|
|
+ (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 !== ""
|
|
}
|
|
|
|
ErrorRow {
|
|
label: "Keyring problem"
|
|
message: 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 colored 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 applications 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"
|
|
}
|
|
}
|
|
}
|
|
|
|
Item { width: 1; height: 10 }
|
|
|
|
// 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.
|
|
SettingsNote {
|
|
headline: "Clipboard history is kept by Vicinae"
|
|
body: "Vicinae owns the database Super+V browses. Panama reads that history and does not clear it."
|
|
}
|
|
|
|
ErrorRow {
|
|
label: "That did not work"
|
|
message: Traces.lastError
|
|
}
|
|
}
|
|
|
|
// ── Device security ──────────────────────────────────────────────────────
|
|
|
|
SettingsCard {
|
|
title: "Device security"
|
|
// Three answers, not two. "Everything is in its recommended state" is
|
|
// computed from an empty fact list exactly as happily as from a clean
|
|
// one, so a helper that failed to run would be reported as a machine
|
|
// with nothing wrong with it. No facts means nothing was checked.
|
|
subtitle: {
|
|
if (!DeviceSecurity.scanned)
|
|
return "Reading the firmware, the kernel and the filesystem…";
|
|
if (DeviceSecurity.facts.length === 0)
|
|
return "Could not check — " + (DeviceSecurity.lastError
|
|
|| "the security helper did not answer.");
|
|
if (DeviceSecurity.attentionCount === 0)
|
|
return "Everything below is in its recommended state.";
|
|
return 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-application notifications"
|
|
detail: "Which applications may notify, and how much they may show on a locked screen"
|
|
action: "Open Notifications"
|
|
divider: false
|
|
onTriggered: ShellState.openSettings("notifications")
|
|
}
|
|
}
|
|
}
|