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:
@@ -0,0 +1,81 @@
|
||||
// One tile of the camera/microphone/screen strip: whether the thing is being
|
||||
// used right this second, and by what.
|
||||
//
|
||||
// PrivacyLiveTile {
|
||||
// glyph: "\u{F0100}"
|
||||
// label: "Camera"
|
||||
// active: PrivacyState.cameraActive
|
||||
// app: PrivacyState.cameraApp
|
||||
// }
|
||||
//
|
||||
// Deliberately still. An indicator that pulses or fades is reporting the same
|
||||
// fact over and over at sixty frames a second, and this one sits on a settings
|
||||
// page that may be open for a long time -- the border and the warn-toned line
|
||||
// carry the whole of the difference, and they carry it without repainting.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property string glyph: ""
|
||||
property string label: ""
|
||||
property bool active: false
|
||||
// The application name PipeWire reported, which is often empty even while
|
||||
// a stream is plainly running.
|
||||
property string app: ""
|
||||
|
||||
// Not `state`: Item already owns that name.
|
||||
readonly property string useLine: root.active
|
||||
? "In use by " + (root.app !== "" ? root.app : "an application")
|
||||
: "Idle"
|
||||
|
||||
implicitHeight: 56
|
||||
radius: Theme.cardRadius
|
||||
color: Theme.alpha(Theme.bgDark, 0.55)
|
||||
border.width: 1
|
||||
border.color: root.active
|
||||
? Theme.alpha(Theme.warn, 0.45)
|
||||
: Theme.alpha(Theme.fg, 0.08)
|
||||
|
||||
Text {
|
||||
id: icon
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.glyph
|
||||
color: root.active ? Theme.warn : Theme.fgDim
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: 17
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.left: icon.right
|
||||
anchors.leftMargin: 10
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 2
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.label
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.DemiBold
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.useLine
|
||||
color: root.active ? Theme.warn : Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1)
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,13 +295,14 @@ the owner instead of duplicating it.
|
||||
| `cursorInactiveTimeout` | Mouse | Accessibility | Pointer visibility is configured with pointer behavior but affects motor and visual access. |
|
||||
| `cursorSize` | Accessibility | Mouse | Large cursors are an accessibility adaptation that users also look for beside pointer controls. |
|
||||
| `inactiveOpacity` | Appearance | Accessibility | Window translucency is an appearance choice with a direct readability impact. |
|
||||
| `lockMinutes` | Power | Privacy | Idle timing owns the mechanism; privacy owns the expectation that the unattended desktop locks. |
|
||||
| `lockOnSleep` | Power | Privacy | Suspend owns the transition; privacy owns whether waking requires authentication. |
|
||||
|
||||
Lock-screen visuals belong only to **Appearance**: background source, blur,
|
||||
clock, date, user name, and password-field presentation. **Power** owns when
|
||||
the session locks, while **Privacy** keeps only the established timing mirrors
|
||||
above. Visual controls must not be copied onto either page.
|
||||
the session locks — the timings and the suspend transition both, with no mirror
|
||||
anywhere. **Privacy** carried a second Screen-lock card until it was removed:
|
||||
two sliders writing one preference is not a convenience, it is a page where the
|
||||
number you are reading may not be the one you last set. Privacy points at Power
|
||||
& Lock instead. Visual controls must not be copied onto either page.
|
||||
|
||||
**Displays** is the sole owner of mode, scale, rotation, arrangement, and primary role.
|
||||
Those values form one safety transaction: every connected output
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// A small uppercase heading inside a card, with an optional count beside it.
|
||||
//
|
||||
// SectionLabel { text: "Camera"; count: "2 apps have asked" }
|
||||
//
|
||||
// A card whose rows come from four different permission tables needs to say
|
||||
// which table a row belongs to, and a second SettingsCard per table would give
|
||||
// four headers, four subtitles and four borders to one subject. This is the
|
||||
// lighter divider: it groups rows without pretending they are separate topics.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property string text: ""
|
||||
property string count: ""
|
||||
|
||||
width: parent ? parent.width : 620
|
||||
implicitHeight: 30
|
||||
|
||||
Row {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.bottomMargin: 4
|
||||
spacing: 8
|
||||
|
||||
Text {
|
||||
text: root.text
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1)
|
||||
font.weight: Font.DemiBold
|
||||
font.capitalization: Font.AllUppercase
|
||||
font.letterSpacing: 0.7
|
||||
}
|
||||
|
||||
Text {
|
||||
visible: root.count !== ""
|
||||
text: root.count
|
||||
color: Theme.alpha(Theme.fgMuted, 0.75)
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1)
|
||||
font.letterSpacing: 0.7
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
// SSH keys, and what this machine can reach with them.
|
||||
//
|
||||
// Read-heavy on purpose. The genuinely useful things a person wants from a page
|
||||
// like this are "which key is this", "is the agent holding it", "copy the public
|
||||
// half", and "forget a host whose key changed" -- and all four are safe. What is
|
||||
// not here is generating a key, because a passphrase cannot be collected and
|
||||
// handed to ssh-keygen without putting it somewhere it should not be, and a
|
||||
// page offering to make an unencrypted key instead would be a downgrade
|
||||
// disguised as a feature.
|
||||
// Private keys are never read here. What the page knows about one is what
|
||||
// ssh-keygen will say about it from the outside -- its type, its fingerprint,
|
||||
// its comment, whether it has a passphrase, and what its file mode is.
|
||||
//
|
||||
// Generating a key used to be missing on the grounds that a passphrase could
|
||||
// not be collected safely. The grounds were half right: it cannot go in argv,
|
||||
// which /proc publishes to every process on the machine, and it cannot go in a
|
||||
// temp file. It CAN go down a pipe. The helper reads it from stdin and feeds it
|
||||
// to ssh-keygen over a pty, so it exists in two processes' memory and nowhere
|
||||
// else -- which is a better place for it than the alternative this page used to
|
||||
// leave people with, an unencrypted key made by hand at a terminal.
|
||||
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
@@ -18,29 +22,82 @@ SettingsPage {
|
||||
|
||||
objectName: "ssh-keys"
|
||||
title: "SSH Keys"
|
||||
lede: "The keys this machine signs in with, and the hosts it has met."
|
||||
lede: "The keys this machine signs in with. Private keys are never read — only their public halves and their locks."
|
||||
|
||||
property string confirmingForget: ""
|
||||
|
||||
Component.onCompleted: if (!SshKeys.scanned) SshKeys.refresh()
|
||||
// ── The generate form ───────────────────────────────────────────────────
|
||||
property bool showingGenerator: false
|
||||
property string newName: ""
|
||||
property string newComment: ""
|
||||
// Held only while the form is open, and emptied the moment it closes or
|
||||
// succeeds. SecretFieldRow exists for exactly this: PasswordRow's field is
|
||||
// private, so a form that closed left the typed passphrase sitting in a
|
||||
// hidden TextInput for the rest of the session.
|
||||
property string passphrase: ""
|
||||
property string passphraseAgain: ""
|
||||
|
||||
TextRow {
|
||||
visible: SshKeys.lastError !== ""
|
||||
label: "That did not work"
|
||||
detail: SshKeys.lastError
|
||||
value: ""
|
||||
divider: false
|
||||
// The helper's own rule, mirrored so the button can be dark before the
|
||||
// round trip rather than after it. If these two ever disagree the helper
|
||||
// wins -- it is the one confined to ~/.ssh.
|
||||
readonly property bool nameValid: /^[A-Za-z0-9_.-]{1,64}$/.test(root.newName)
|
||||
readonly property bool nameTaken: SshKeys.keys.some(
|
||||
key => String(key.name ?? "") === root.newName)
|
||||
readonly property bool passphrasesMatch:
|
||||
root.passphrase !== "" && root.passphrase === root.passphraseAgain
|
||||
readonly property bool canCreate:
|
||||
root.nameValid && !root.nameTaken && root.passphrasesMatch && !SshKeys.busy
|
||||
|
||||
readonly property var heldKeys: SshKeys.keys.filter(key => key.loaded === true)
|
||||
|
||||
function resetForm(): void {
|
||||
root.showingGenerator = false;
|
||||
root.newName = "";
|
||||
root.newComment = "";
|
||||
root.passphrase = "";
|
||||
root.passphraseAgain = "";
|
||||
nameField.clear();
|
||||
commentField.clear();
|
||||
passphraseField.clear();
|
||||
confirmField.clear();
|
||||
}
|
||||
|
||||
TextRow {
|
||||
Component.onCompleted: if (!SshKeys.scanned) SshKeys.refresh()
|
||||
|
||||
// The form closes itself when the key it was asked for turns up in the
|
||||
// snapshot. A failure leaves it open with the name still typed, because the
|
||||
// usual failure is a name already taken and retyping the rest would be a
|
||||
// punishment for the helper's refusal.
|
||||
Connections {
|
||||
target: SshKeys
|
||||
|
||||
function onKeysChanged(): void {
|
||||
if (!root.showingGenerator || root.newName === "")
|
||||
return;
|
||||
if (SshKeys.keys.some(key => String(key.name ?? "") === root.newName))
|
||||
root.resetForm();
|
||||
}
|
||||
}
|
||||
|
||||
// ── What went wrong ─────────────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
visible: SshKeys.lastError !== ""
|
||||
title: "That did not work"
|
||||
subtitle: SshKeys.lastError
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
visible: SshKeys.scanned && !SshKeys.available
|
||||
label: "No SSH directory"
|
||||
detail: "Nothing has created ~/.ssh on this machine yet."
|
||||
value: ""
|
||||
divider: false
|
||||
title: "No SSH directory"
|
||||
subtitle: "Nothing has created ~/.ssh on this machine yet. Generating a key below is one way to."
|
||||
}
|
||||
|
||||
// ── Keys readable by other people ───────────────────────────────────────
|
||||
//
|
||||
// ssh refuses to use a key with these permissions, so it will never be
|
||||
// offered and nothing will say why. This card used to only be able to point
|
||||
// at the problem; it can fix it now.
|
||||
|
||||
SettingsCard {
|
||||
visible: SshKeys.overexposed.length > 0
|
||||
@@ -52,55 +109,45 @@ SettingsPage {
|
||||
Repeater {
|
||||
model: SshKeys.overexposed
|
||||
|
||||
delegate: TextRow {
|
||||
delegate: SettingRow {
|
||||
id: exposedRow
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
width: parent.width
|
||||
label: String(modelData.name ?? "")
|
||||
detail: "Mode " + String(modelData.mode ?? "") + " · should be 600"
|
||||
value: ""
|
||||
label: String(exposedRow.modelData.name ?? "")
|
||||
detail: "Mode " + String(exposedRow.modelData.mode ?? "") + " · should be 600"
|
||||
controlWidth: 150
|
||||
divider: exposedRow.index < SshKeys.overexposed.length - 1
|
||||
|
||||
SettingsButton {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Fix permissions"
|
||||
tone: "accent"
|
||||
enabled: !SshKeys.busy
|
||||
onClicked: SshKeys.fixPermissions(String(exposedRow.modelData.name ?? ""))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── The agent ───────────────────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
title: "Agent"
|
||||
subtitle: SshKeys.agent?.available === true
|
||||
? (SshKeys.agent?.kind === "gnome-keyring"
|
||||
? "The login keyring is holding your keys, and offers every key it finds in ~/.ssh."
|
||||
: "An SSH agent is holding your keys for this session.")
|
||||
: String(SshKeys.agent?.detail ?? "No SSH agent is running.")
|
||||
|
||||
TextRow {
|
||||
label: "Holding"
|
||||
detail: SshKeys.agent?.available === true
|
||||
? String(SshKeys.agent?.socket ?? "")
|
||||
: "Keys will be asked for on every connection"
|
||||
value: SshKeys.loadedCount + " key" + (SshKeys.loadedCount === 1 ? "" : "s")
|
||||
divider: SshKeys.agent?.kind === "gnome-keyring"
|
||||
}
|
||||
|
||||
// Said plainly because it is measurable and surprising: ssh-add -d
|
||||
// reports success against this agent and the key is still offered a
|
||||
// moment later, because it is read back off disk.
|
||||
TextRow {
|
||||
visible: SshKeys.agent?.kind === "gnome-keyring"
|
||||
label: "Removing a key from this agent does not stick"
|
||||
detail: "It lists every key in ~/.ssh, so one removed comes straight back. Move the file out of ~/.ssh to stop it being offered."
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Keys ────────────────────────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
title: SshKeys.keys.length === 1 ? "Your key" : "Your keys"
|
||||
subtitle: SshKeys.keys.length === 0
|
||||
? "No keys in " + SshKeys.directory
|
||||
? "No keys in " + (SshKeys.directory !== "" ? SshKeys.directory : "~/.ssh")
|
||||
: "Public halves are safe to share; the private half never leaves this machine."
|
||||
|
||||
TextRow {
|
||||
visible: SshKeys.scanned && SshKeys.keys.length === 0
|
||||
label: "Nothing here yet"
|
||||
detail: "A key you generate below shows up here, with its fingerprint and whether the agent is holding it."
|
||||
value: ""
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: SshKeys.keys
|
||||
|
||||
@@ -110,16 +157,20 @@ SettingsPage {
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
readonly property bool copied:
|
||||
SshKeys.copiedKey !== ""
|
||||
&& SshKeys.copiedKey === String(keyRow.modelData.publicPath ?? "")
|
||||
|
||||
label: String(keyRow.modelData.name ?? "")
|
||||
detail: String(keyRow.modelData.type ?? "") + " · "
|
||||
+ String(keyRow.modelData.fingerprint ?? "")
|
||||
+ (String(keyRow.modelData.comment ?? "") !== ""
|
||||
? " · " + keyRow.modelData.comment : "")
|
||||
+ (keyRow.modelData.encrypted === true
|
||||
? " · passphrase protected"
|
||||
? " · passphrase set"
|
||||
: (keyRow.modelData.encrypted === false ? " · no passphrase" : ""))
|
||||
divider: keyRow.index < SshKeys.keys.length - 1
|
||||
controlWidth: 220
|
||||
divider: true
|
||||
controlWidth: 290
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
@@ -128,7 +179,16 @@ SettingsPage {
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: keyRow.modelData.loaded === true
|
||||
visible: keyRow.copied
|
||||
text: "Copied"
|
||||
color: Theme.ok
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: keyRow.modelData.loaded === true && !keyRow.copied
|
||||
text: "In the agent"
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
@@ -152,14 +212,175 @@ SettingsPage {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
label: "Generate a key"
|
||||
detail: "ed25519, with a passphrase — collected here and handed to ssh-keygen over a pty, never on a command line"
|
||||
action: root.showingGenerator ? "Cancel" : "Generate…"
|
||||
divider: root.showingGenerator
|
||||
onTriggered: {
|
||||
if (root.showingGenerator) {
|
||||
root.resetForm();
|
||||
return;
|
||||
}
|
||||
root.showingGenerator = true;
|
||||
}
|
||||
}
|
||||
|
||||
LiveFieldRow {
|
||||
id: nameField
|
||||
|
||||
visible: root.showingGenerator
|
||||
label: "File name"
|
||||
detail: root.newName === ""
|
||||
? "Letters, numbers, dot, dash and underscore. It is written into " + (SshKeys.directory !== "" ? SshKeys.directory : "~/.ssh") + "."
|
||||
: (!root.nameValid
|
||||
? "Only letters, numbers, dot, dash and underscore, up to 64 characters."
|
||||
: (root.nameTaken
|
||||
? "A key by that name is already there, and it will not be written over."
|
||||
: "Written as " + root.newName + " and " + root.newName + ".pub"))
|
||||
invalid: root.newName !== "" && (!root.nameValid || root.nameTaken)
|
||||
placeholder: "id_ed25519_forge"
|
||||
text: root.newName
|
||||
maximumLength: 64
|
||||
onEdited: value => root.newName = value
|
||||
}
|
||||
|
||||
LiveFieldRow {
|
||||
id: commentField
|
||||
|
||||
visible: root.showingGenerator
|
||||
label: "Comment"
|
||||
detail: "Written into the public half, so a server's authorized_keys says which key this is"
|
||||
placeholder: "you@machine"
|
||||
text: root.newComment
|
||||
maximumLength: 128
|
||||
onEdited: value => root.newComment = value
|
||||
}
|
||||
|
||||
SecretFieldRow {
|
||||
id: passphraseField
|
||||
|
||||
visible: root.showingGenerator
|
||||
label: "Passphrase"
|
||||
detail: "Required. A key with no passphrase is a password file that anyone reading the disk can use."
|
||||
placeholder: "Passphrase"
|
||||
onChanged: value => root.passphrase = value
|
||||
}
|
||||
|
||||
SecretFieldRow {
|
||||
id: confirmField
|
||||
|
||||
visible: root.showingGenerator
|
||||
label: "Confirm"
|
||||
detail: root.passphraseAgain === ""
|
||||
? "Type it again"
|
||||
: (root.passphrasesMatch ? "Matches" : "These two do not match yet.")
|
||||
placeholder: "Passphrase again"
|
||||
onChanged: value => root.passphraseAgain = value
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
visible: root.showingGenerator
|
||||
label: "Create key"
|
||||
detail: SshKeys.generating
|
||||
? "ssh-keygen is working. It takes a moment."
|
||||
: (!root.nameValid
|
||||
? "Give the key a file name first."
|
||||
: (root.nameTaken
|
||||
? "That name is taken."
|
||||
: (root.passphrase === ""
|
||||
? "Choose a passphrase."
|
||||
: (!root.passphrasesMatch
|
||||
? "The two passphrases do not match."
|
||||
: "ed25519, written to " + (SshKeys.directory !== "" ? SshKeys.directory : "~/.ssh") + "/" + root.newName))))
|
||||
action: SshKeys.generating ? "Making the key…" : "Create key"
|
||||
enabled: root.canCreate
|
||||
divider: false
|
||||
onTriggered: {
|
||||
if (!root.canCreate)
|
||||
return;
|
||||
SshKeys.generate(root.newName, root.newComment, root.passphrase);
|
||||
// Out of the fields immediately, whatever happens next. The
|
||||
// name stays so a refused write can be retried without
|
||||
// retyping everything.
|
||||
root.passphrase = "";
|
||||
root.passphraseAgain = "";
|
||||
passphraseField.clear();
|
||||
confirmField.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── The agent ───────────────────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
title: "Agent"
|
||||
subtitle: SshKeys.agent?.available === true
|
||||
? (SshKeys.agentKind === "gnome-keyring"
|
||||
? "The login keyring is holding your keys, and offers every key it finds in ~/.ssh."
|
||||
: "An SSH agent is holding your keys for this session.")
|
||||
: String(SshKeys.agent?.detail ?? "No SSH agent is running.")
|
||||
|
||||
TextRow {
|
||||
label: "Holding"
|
||||
detail: SshKeys.agent?.available === true
|
||||
? String(SshKeys.agent?.socket ?? "")
|
||||
: "Keys will be asked for on every connection"
|
||||
value: SshKeys.loadedCount + " key" + (SshKeys.loadedCount === 1 ? "" : "s")
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: SshKeys.agent?.available === true && root.heldKeys.length === 0
|
||||
label: "Nothing loaded"
|
||||
detail: "Add a key above and the agent will offer it without asking for its passphrase again."
|
||||
value: ""
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.heldKeys
|
||||
|
||||
delegate: SettingRow {
|
||||
id: heldRow
|
||||
|
||||
required property var modelData
|
||||
|
||||
width: parent.width
|
||||
label: String(heldRow.modelData.name ?? "")
|
||||
detail: String(heldRow.modelData.fingerprint ?? "")
|
||||
controlWidth: 110
|
||||
|
||||
SettingsButton {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Remove"
|
||||
enabled: !SshKeys.busy
|
||||
onClicked: SshKeys.removeFromAgent(String(heldRow.modelData.path ?? ""))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Said plainly because it is measurable and surprising: ssh-add -d
|
||||
// reports success against this agent and the key is still offered a
|
||||
// moment later, because it is read back off disk. The Remove button
|
||||
// above stays -- the helper refuses and says this, which is a better
|
||||
// answer than a button that is not there.
|
||||
TextRow {
|
||||
visible: SshKeys.agent?.available === true && !SshKeys.durableRemoval
|
||||
label: "Removing a key from this agent does not stick"
|
||||
detail: "It lists every key in ~/.ssh, so one removed comes straight back at the next sign-in. That is the keyring agent's design, not a bug — move the file out of ~/.ssh to stop it being offered."
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Known hosts ─────────────────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
visible: SshKeys.hosts.length > 0
|
||||
title: "Known hosts"
|
||||
subtitle: "Machines this one has connected to before. Forgetting one means being asked to trust it again."
|
||||
subtitle: SshKeys.hosts.length === 0
|
||||
? "Nothing yet. A host is recorded the first time you accept its key."
|
||||
: "Machines this one has connected to. Forget an entry when a server legitimately changed — ssh-keygen keeps a .old copy."
|
||||
|
||||
Repeater {
|
||||
model: SshKeys.hosts
|
||||
@@ -168,11 +389,11 @@ SettingsPage {
|
||||
id: hostRow
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
readonly property bool confirming:
|
||||
root.confirmingForget === String(hostRow.modelData.host ?? "")
|
||||
|
||||
width: parent.width
|
||||
label: hostRow.modelData.hashed === true
|
||||
? hostRow.modelData.count + " hashed entries"
|
||||
: String(hostRow.modelData.host ?? "")
|
||||
@@ -181,7 +402,6 @@ SettingsPage {
|
||||
: (hostRow.confirming
|
||||
? "You will be asked to trust this host the next time you connect."
|
||||
: (hostRow.modelData.types ?? []).join(", "))
|
||||
divider: hostRow.index < SshKeys.hosts.length - 1
|
||||
controlWidth: 190
|
||||
|
||||
Row {
|
||||
@@ -212,5 +432,14 @@ SettingsPage {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
label: "Check again"
|
||||
detail: "Re-reads ~/.ssh and asks the agent what it is holding"
|
||||
action: SshKeys.busy ? "Reading…" : "Refresh"
|
||||
enabled: !SshKeys.busy
|
||||
divider: false
|
||||
onTriggered: SshKeys.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +111,8 @@ HomeLightTile 1.0 HomeLightTile.qml
|
||||
DictationPage 1.0 DictationPage.qml
|
||||
TextEntryRow 1.0 TextEntryRow.qml
|
||||
PrivacyPage 1.0 PrivacyPage.qml
|
||||
PrivacyLiveTile 1.0 PrivacyLiveTile.qml
|
||||
SectionLabel 1.0 SectionLabel.qml
|
||||
RegionPage 1.0 RegionPage.qml
|
||||
SearchPicker 1.0 SearchPicker.qml
|
||||
OnlineAccountsPage 1.0 OnlineAccountsPage.qml
|
||||
|
||||
Reference in New Issue
Block a user