diff --git a/config/dot/quickshell/health-harness.qml b/config/dot/quickshell/health-harness.qml
index 572e577..c0ae0b8 100644
--- a/config/dot/quickshell/health-harness.qml
+++ b/config/dot/quickshell/health-harness.qml
@@ -19,5 +19,9 @@ ShellRoot {
function repair(id: string): bool { return Health.repair(id, false); }
function report(): string { return JSON.stringify(Health.snapshot, null, 2); }
function copy(): bool { return Health.copyReport(); }
+ // Both take an explicit destination or subject, so nothing here writes
+ // to the real home or rescans anything the fixture did not ask for.
+ function save(path: string): bool { return Health.saveReport(path); }
+ function recheck(id: string): bool { return Health.refreshCheck(id); }
}
}
diff --git a/config/dot/quickshell/manual/02-the-keyboard.md b/config/dot/quickshell/manual/02-the-keyboard.md
index 384c6ce..6ecbcd7 100644
--- a/config/dot/quickshell/manual/02-the-keyboard.md
+++ b/config/dot/quickshell/manual/02-the-keyboard.md
@@ -55,6 +55,6 @@ one I have".
## Changing them
-Settings has a Keyboard page, under Input, listing every bind, each of which
+Settings has a [Keyboard page](panama://settings/shortcuts), under Input, listing every bind, each of which
can be reassigned. A rebind moves the shortcut and cannot change what it does, so
there is no way to make a key do something unexpected by editing it.
diff --git a/config/dot/quickshell/manual/04-when-something-breaks.md b/config/dot/quickshell/manual/04-when-something-breaks.md
index 0a87526..78ec38b 100644
--- a/config/dot/quickshell/manual/04-when-something-breaks.md
+++ b/config/dot/quickshell/manual/04-when-something-breaks.md
@@ -2,8 +2,8 @@
## Start here
-Run **Check System Health** from the launcher, or open Settings and go to
-System, then the System Health tab. It reports what is actually running rather than what was
+Run **Check System Health** from the launcher, or open
+[System Health](panama://settings/services). It reports what is actually running rather than what was
installed, and it can repair several things itself.
From a terminal, the same check is `panama doctor`.
@@ -28,7 +28,7 @@ to load. `Hyprland --verify-config` says why without touching your session.
## The screen resolution is wrong
-Settings has a Displays page. It opens on a picture of what is connected —
+Settings has a [Displays page](panama://settings/displays). It opens on a picture of what is connected —
one display or several — and everything under that picture belongs to
whichever one you have selected. Every change there reverts itself after
fifteen seconds unless you confirm it, so a mode your monitor cannot show
@@ -50,7 +50,7 @@ harmed by being refused.
`panama migrate` applies repairs this machine has not had yet. It is safe to
run at any time and does nothing when there is nothing to do.
-If an update went badly, the Snapshots tab under System in Settings can roll
+If an update went badly, [Snapshots](panama://settings/snapshots) under System can roll
the system back. That tab is only there where btrfs snapshots are configured,
so if you cannot see it, this machine has none to roll back to.
diff --git a/config/dot/quickshell/modules/settings/AboutPage.qml b/config/dot/quickshell/modules/settings/AboutPage.qml
index dc5015f..8903283 100644
--- a/config/dot/quickshell/modules/settings/AboutPage.qml
+++ b/config/dot/quickshell/modules/settings/AboutPage.qml
@@ -1,13 +1,30 @@
-import QtQuick
+// About.
+//
+// The landing page of the System category, and the answer to "what am I
+// running". Every line here is read from the machine rather than written down:
+// the versions block used to carry a hardcoded Quickshell version that had been
+// wrong for two releases, and a Design principles card restating opinions the
+// manual argues properly.
+//
+// Rows come from MachineInfo, except graphics, which is joined from
+// GraphicsDevices rather than read a second time -- two readouts of the same
+// hardware are two things that can disagree. Facts the cards below do not claim
+// by name are still shown, in Software, so a row the helper learns to report
+// cannot go missing here.
+
import Quickshell
+import Quickshell.Io
+import QtQuick
import qs.config
import qs.services
SettingsPage {
id: root
+ objectName: "about"
+
title: "About"
- lede: "A curated Hyprland desktop built around focus, speed, and good taste."
+ lede: "This machine, plainly."
Component.onCompleted: {
if (!MachineInfo.scanned)
@@ -16,22 +33,298 @@ SettingsPage {
GraphicsDevices.refresh();
}
+ // ── Reading the machine ──────────────────────────────────────────────────
+
+ function fact(label: string): string {
+ const row = (MachineInfo.facts ?? []).find(entry => entry.label === label);
+ return row ? String(row.value ?? "") : "";
+ }
+
+ // Joined with a middle dot, skipping whatever is absent. Firmware and
+ // Secure Boot are both absent-tolerant on the helper's side, so either half
+ // of that row can be missing on a given machine.
+ function joined(parts: var): string {
+ return parts.filter(part => String(part ?? "") !== "").join(" · ");
+ }
+
+ readonly property string hostname: root.fact("Hostname")
+ // "Fedora Linux 44 (Workstation Edition)" says the edition twice for the
+ // one line where brevity matters most.
+ readonly property string operatingSystem:
+ root.fact("Operating system").replace(/\s*\(.*\)\s*$/, "")
+ readonly property string uptime: root.fact("Uptime").split(",")[0].trim()
+
+ readonly property string identity: root.joined([
+ root.operatingSystem === "" ? "" : "Panama on " + root.operatingSystem,
+ SystemSettings.hyprlandVersion === "" ? "" : "Hyprland " + SystemSettings.hyprlandVersion,
+ root.uptime === "" ? "" : "up " + root.uptime
+ ])
+
+ readonly property var graphicsRows: {
+ const gpus = GraphicsDevices.devices ?? [];
+ return gpus.map((device, index) => ({
+ label: gpus.length > 1 ? "Graphics " + (index + 1) : "Graphics",
+ value: String(device.name ?? "")
+ }));
+ }
+
+ readonly property string displayValue: root.joined([
+ SystemSettings.monitorName, root.fact("Resolution")
+ ])
+
+ // Everything the three cards below name explicitly. Whatever the helper
+ // reports that is not in here lands in Software rather than nowhere.
+ readonly property var claimedFacts: [
+ "Hostname", "Operating system", "Uptime", "Panama", "Kernel",
+ "Firmware", "Secure Boot", "Model", "Processor", "Memory", "Swap",
+ "Disk", "Resolution"
+ ]
+
+ readonly property var otherFacts: (MachineInfo.facts ?? [])
+ .filter(entry => root.claimedFacts.indexOf(String(entry.label ?? "")) < 0)
+
+ // ── Copying it ───────────────────────────────────────────────────────────
+
+ // The same facts, as plain text, for a bug report or a message to somebody
+ // trying to help. Built from what is on screen so the two cannot disagree.
+ function systemInfoText(): string {
+ const lines = [];
+ if (root.hostname !== "")
+ lines.push(root.hostname);
+ if (root.identity !== "")
+ lines.push(root.identity);
+ lines.push("");
+ for (const row of root.versionRows.concat(root.hardwareRows, root.otherFacts)) {
+ if (String(row.value ?? "") !== "")
+ lines.push(row.label + ": " + row.value);
+ }
+ return lines.join("\n") + "\n";
+ }
+
+ property string copyResult: ""
+
+ Process {
+ id: copyRun
+
+ property string payload: ""
+
+ command: ["wl-copy"]
+ stdinEnabled: true
+ onStarted: {
+ copyRun.write(copyRun.payload);
+ // wl-copy reads stdin until EOF before it exits; leaving the
+ // channel open would hang it forever waiting for more. Same
+ // stdinEnabled close that Health.copyReport uses.
+ copyRun.stdinEnabled = false;
+ }
+ onExited: exitCode => {
+ root.copyResult = exitCode === 0
+ ? "Copied."
+ : "Could not reach the clipboard.";
+ copyRun.payload = "";
+ }
+ }
+
+ function copySystemInfo(): void {
+ if (copyRun.running)
+ return;
+ root.copyResult = "";
+ copyRun.payload = root.systemInfoText();
+ copyRun.stdinEnabled = true;
+ copyRun.running = true;
+ }
+
+ // ── The hero ─────────────────────────────────────────────────────────────
+
+ readonly property var versionRows: [
+ { label: "Panama", value: root.fact("Panama") },
+ { label: "Quickshell", value: SystemSettings.quickshellVersion },
+ { label: "Kernel", value: root.fact("Kernel") },
+ { label: "Firmware", value: root.joined([
+ root.fact("Firmware"),
+ root.fact("Secure Boot") === "" ? "" : "Secure Boot " + root.fact("Secure Boot")
+ ]) }
+ ].filter(row => String(row.value ?? "") !== "")
+
SettingsCard {
- title: "Desktop"
- subtitle: "Tokyo Night Moon · Prism glass · native tiling"
+ Item {
+ width: parent.width
+ implicitHeight: Math.max(96, heroCopy.implicitHeight + 16)
+
+ // The house tile: a rounded plate carrying the System glyph the
+ // sidebar uses, in the accent, drawn rather than shipped as an
+ // image so it follows the theme like everything else.
+ Rectangle {
+ id: heroArt
+
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ width: 84
+ height: 84
+ radius: 24
+ color: Theme.alpha(Theme.accent, 0.13)
+ border.width: 1
+ border.color: Theme.alpha(Theme.accent, 0.3)
+
+ Rectangle {
+ anchors.fill: parent
+ radius: parent.radius
+ color: "transparent"
+ gradient: Gradient {
+ orientation: Gradient.Vertical
+ GradientStop { position: 0; color: Theme.alpha(Theme.accentSecondary, 0.14) }
+ GradientStop { position: 1; color: Theme.alpha(Theme.accent, 0.02) }
+ }
+ }
+
+ Text {
+ anchors.centerIn: parent
+ text: "\u{F02FD}"
+ color: Theme.accent
+ font.family: Theme.fontMono
+ font.pixelSize: 38
+ }
+ }
+
+ Column {
+ id: heroCopy
+
+ anchors.left: heroArt.right
+ anchors.leftMargin: 20
+ anchors.right: copyButton.left
+ anchors.rightMargin: 18
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 5
+
+ Text {
+ width: parent.width
+ text: root.hostname === "" ? "This machine" : root.hostname
+ color: Theme.fg
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSizeTitle
+ font.weight: Font.Bold
+ elide: Text.ElideRight
+ }
+
+ Text {
+ width: parent.width
+ visible: root.identity !== ""
+ text: root.identity
+ color: Theme.fgDim
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSizeSmall
+ wrapMode: Text.WordWrap
+ }
+
+ Text {
+ width: parent.width
+ visible: root.copyResult !== ""
+ text: root.copyResult
+ color: Theme.fgMuted
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSizeSmall
+ }
+ }
+
+ SettingsButton {
+ id: copyButton
+
+ objectName: "about-copy-button"
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ text: "Copy system info"
+ enabled: !copyRun.running
+ activeFocusOnTab: enabled
+ border.width: activeFocus ? 2 : 1
+ border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
+ onClicked: root.copySystemInfo()
+
+ Accessible.role: Accessible.Button
+ Accessible.name: "Copy system info"
+ Keys.onReturnPressed: root.copySystemInfo()
+ Keys.onSpacePressed: root.copySystemInfo()
+ }
+ }
+
+ Repeater {
+ model: root.versionRows
+
+ TextRow {
+ required property var modelData
+ required property int index
+
+ label: modelData.label
+ value: modelData.value
+ divider: index < root.versionRows.length - 1
+ }
+ }
+ }
+
+ // ── Hardware ─────────────────────────────────────────────────────────────
+
+ readonly property var hardwareRows: [
+ { label: "Model", value: root.fact("Model") },
+ { label: "Processor", value: root.fact("Processor") }
+ ].concat(root.graphicsRows, [
+ { label: "Memory", value: root.joined([
+ root.fact("Memory"),
+ root.fact("Swap") === "" ? "" : root.fact("Swap") + " swap"
+ ]) },
+ { label: "Disk", value: root.fact("Disk") },
+ { label: "Display", value: root.displayValue }
+ ]).filter(row => String(row.value ?? "") !== "")
+
+ SettingsCard {
+ title: "Hardware"
+
+ Repeater {
+ model: root.hardwareRows
+
+ TextRow {
+ required property var modelData
+
+ label: modelData.label
+ value: modelData.value
+ }
+ }
TextRow {
- label: "Hyprland"
- value: SystemSettings.hyprlandVersion || "Detecting…"
+ visible: MachineInfo.scanned && root.hardwareRows.length === 0
+ label: "Hardware"
+ detail: "The system did not report anything readable"
+ value: "Unavailable"
}
- TextRow {
- label: "Quickshell"
- value: SystemSettings.quickshellVersion
+
+ // The machine's name is a network-facing setting rather than a fact
+ // about the hardware, and Sharing is the page that owns it. Saying so
+ // here is cheaper than a second field that writes the same hostname.
+ ActionRow {
+ label: "Device name"
+ detail: root.hostname === ""
+ ? "What this machine calls itself on the network"
+ : root.hostname + " — what this machine calls itself on the network"
+ action: "Open Sharing"
+ divider: false
+ onTriggered: ShellState.openSettings("sharing")
}
- TextRow {
- label: "Display"
- value: SystemSettings.monitorName || "Detecting…"
+ }
+
+ // ── Software ─────────────────────────────────────────────────────────────
+
+ SettingsCard {
+ title: "Software"
+
+ Repeater {
+ model: root.otherFacts
+
+ TextRow {
+ required property var modelData
+
+ label: String(modelData.label ?? "")
+ value: String(modelData.value ?? "")
+ }
}
+
TextRow {
label: "Configuration"
detail: Quickshell.shellDir
@@ -40,74 +333,34 @@ SettingsPage {
}
}
- // What GNOME's About panel answers and this page did not: what am I running
- // on. Rows come from MachineInfo, except graphics, which is joined from
- // GraphicsDevices rather than read a second time -- two readouts of the same
- // hardware are two things that can disagree.
- //
- // The join splices the GPU rows in directly after Processor rather than
- // appending them, so the card reads the way fastfetch does: what the system
- // is, then what is installed on it, then the hardware underneath. A GPU
- // listed after "Disk" reads as an afterthought.
- readonly property var machineRows: {
- const rows = (MachineInfo.facts ?? []).slice();
- const gpus = GraphicsDevices.devices ?? [];
- if (gpus.length === 0)
- return rows;
+ // ── Manual ───────────────────────────────────────────────────────────────
- const graphics = gpus.map((device, index) => ({
- label: gpus.length > 1 ? "Graphics " + (index + 1) : "Graphics",
- value: device.name
- }));
-
- const after = rows.findIndex(row => row.label === "Processor");
- if (after < 0)
- return rows.concat(graphics);
- return rows.slice(0, after + 1).concat(graphics, rows.slice(after + 1));
- }
+ ManualChapters { id: contents }
SettingsCard {
- title: "This machine"
- subtitle: "Hardware and system, as the kernel reports it."
+ title: "Manual"
+ subtitle: "How this desktop works, written for the person using it. Links inside a chapter open the settings page they name."
Repeater {
- model: root.machineRows
+ id: chapterRows
+ model: contents.titled
- TextRow {
- id: machineRow
+ ActionRow {
required property var modelData
required property int index
- label: machineRow.modelData.label
- value: machineRow.modelData.value
- divider: machineRow.index < root.machineRows.length - 1
+ label: modelData.title
+ action: "Read"
+ onTriggered: ShellState.openSettingsSection("manual", modelData.file)
}
}
- TextRow {
- visible: MachineInfo.scanned && root.machineRows.length === 0
- label: "Hardware"
- detail: "The system did not report anything readable"
- value: "Unavailable"
- divider: false
- }
- }
-
- SettingsCard {
- title: "Design principles"
-
- TextRow {
- label: "Curated by default"
- detail: "Strong choices instead of an incoherent matrix of switches"
- }
- TextRow {
- label: "Quiet while idle"
- detail: "No continuous decorative repaint loops"
- }
- TextRow {
- label: "Real system boundaries"
- detail: "Every control either works or clearly hands off to its owner"
+ ActionRow {
+ label: "Open the manual"
+ detail: "Starts at the beginning"
+ action: "Open"
divider: false
+ onTriggered: ShellState.openSettings("manual")
}
}
}
diff --git a/config/dot/quickshell/modules/settings/ContainersPage.qml b/config/dot/quickshell/modules/settings/ContainersPage.qml
index c22cc70..eea996b 100644
--- a/config/dot/quickshell/modules/settings/ContainersPage.qml
+++ b/config/dot/quickshell/modules/settings/ContainersPage.qml
@@ -16,11 +16,18 @@ import QtQuick
import qs.config
import qs.services
-Item {
+SettingsPage {
id: root
objectName: "containers"
+ title: "Containers"
+ lede: "Local services you run for development — what they expose, and what they cost."
+
+ // The log reader replaces the page while it is open; SettingsPage owns the
+ // swap so this file is one page rather than two stacked on a bare Item.
+ drilledIn: Containers.logTarget !== ""
+
// "images", "volumes", or empty. Removal never happens on a first press.
property string confirmingPrune: ""
@@ -58,371 +65,269 @@ Item {
// ── the list ────────────────────────────────────────────────────────────
- SettingsPage {
- anchors.fill: parent
- visible: Containers.logTarget === ""
+ TextRow {
+ visible: Containers.lastError !== ""
+ label: "That did not work"
+ detail: Containers.lastError
+ value: ""
+ divider: false
+ }
- title: "Containers"
- lede: "Local services you run for development — what they expose, and what they cost."
+ TextRow {
+ visible: Containers.scanned && !Containers.available
+ label: "podman is not available"
+ detail: "Nothing here can be shown until podman is installed."
+ value: ""
+ divider: false
+ }
- TextRow {
- visible: Containers.lastError !== ""
- label: "That did not work"
- detail: Containers.lastError
- value: ""
- divider: false
- }
+ // ── finding: published to the network ───────────────────────────────
- TextRow {
- visible: Containers.scanned && !Containers.available
- label: "podman is not available"
- detail: "Nothing here can be shown until podman is installed."
- value: ""
- divider: false
- }
-
- // ── finding: published to the network ───────────────────────────────
-
- SettingsCard {
- visible: Containers.reachable.length > 0
- title: Containers.reachable.length === 1
- ? "A container is published to your whole network"
- : "Containers are published to your whole network"
- subtitle: "These bind every interface, so any machine on your network can connect. "
- + "For a development database, loopback is almost always what you want. "
- + "Binding adds an address to the compose file and changes nothing else."
-
- Repeater {
- model: Containers.reachable
-
- delegate: SettingRow {
- required property var modelData
- width: parent.width
- label: String(modelData.container ?? "")
- detail: "Port " + modelData.hostPort + "/" + String(modelData.protocol ?? "tcp")
- + " · " + String(modelData.image ?? "")
- + (modelData.configFile
- ? "\n" + Containers.shorten(String(modelData.configFile))
- : "\nNo compose file is recorded for this container, so it cannot be bound from here.")
- controlWidth: 250
-
- Row {
- anchors.right: parent.right
- anchors.verticalCenter: parent.verticalCenter
- spacing: 8
-
- SettingsButton {
- text: "Open compose file"
- visible: String(modelData.configFile ?? "") !== ""
- enabled: !Containers.busy
- onClicked: Containers.openFile(String(modelData.configFile))
- }
-
- SettingsButton {
- text: "Bind to localhost"
- tone: "accent"
- // Needs both halves of the label to find the entry in
- // the compose file; the Supabase CLI records neither.
- visible: String(modelData.configFile ?? "") !== ""
- && String(modelData.service ?? "") !== ""
- enabled: !Containers.busy
- onClicked: Containers.bindLocal(
- String(modelData.project), String(modelData.service))
- }
- }
- }
- }
-
- TextRow {
- visible: Containers.reachable.length > 0
- label: "After binding"
- detail: "The change takes effect the next time the stack comes up, because a "
- + "published port is fixed when the container is created."
- value: ""
- divider: false
- }
- }
-
- // ── finding: disk nothing references ────────────────────────────────
-
- SettingsCard {
- visible: Containers.unusedImages.length > 0 || Containers.unusedVolumes.length > 0
- title: Containers.formatBytes(Containers.reclaimable) + " nothing is using"
- subtitle: "Images and volumes no container references. Removing them frees the space; "
- + "anything still needed downloads again on next use."
-
- Repeater {
- model: Containers.unusedImages.slice(0, 5)
-
- delegate: TextRow {
- required property var modelData
- width: parent.width
- label: String(modelData.name ?? "")
- detail: ""
- value: Containers.formatBytes(Number(modelData.size ?? 0))
- }
- }
-
- TextRow {
- visible: Containers.unusedImages.length > 5
- label: "and " + (Containers.unusedImages.length - 5) + " more"
- detail: ""
- value: ""
- }
-
- SettingRow {
- width: parent.width
- label: root.confirmingPrune === "images"
- ? "Remove " + Containers.unusedImages.length + " images?"
- : "Unused images"
- detail: root.confirmingPrune === "images"
- ? "Each is removed by name. Nothing that a container references is touched."
- : Containers.unusedImages.length + " images, "
- + Containers.formatBytes(Number(Containers.disk.imagesReclaimable ?? 0))
- visible: Containers.unusedImages.length > 0
- controlWidth: 230
-
- Row {
- anchors.right: parent.right
- anchors.verticalCenter: parent.verticalCenter
- spacing: 8
-
- SettingsButton {
- text: root.confirmingPrune === "images" ? "Keep them" : "Remove…"
- enabled: !Containers.busy
- onClicked: root.confirmingPrune =
- root.confirmingPrune === "images" ? "" : "images"
- }
-
- SettingsButton {
- visible: root.confirmingPrune === "images"
- text: "Remove them"
- tone: "danger"
- enabled: !Containers.busy
- onClicked: {
- root.confirmingPrune = "";
- Containers.pruneImages();
- }
- }
- }
- }
-
- SettingRow {
- width: parent.width
- visible: Containers.unusedVolumes.length > 0
- label: root.confirmingPrune === "volumes"
- ? "Remove " + Containers.unusedVolumes.length + " volumes?"
- : "Unused volumes"
- detail: root.confirmingPrune === "volumes"
- ? "A volume holds data. These are the ones podman reports as referenced by "
- + "nothing, but removing them cannot be undone."
- : Containers.unusedVolumes.length + " volumes, "
- + Containers.formatBytes(Number(Containers.disk.volumesReclaimable ?? 0))
- controlWidth: 230
- divider: false
-
- Row {
- anchors.right: parent.right
- anchors.verticalCenter: parent.verticalCenter
- spacing: 8
-
- SettingsButton {
- text: root.confirmingPrune === "volumes" ? "Keep them" : "Remove…"
- enabled: !Containers.busy
- onClicked: root.confirmingPrune =
- root.confirmingPrune === "volumes" ? "" : "volumes"
- }
-
- SettingsButton {
- visible: root.confirmingPrune === "volumes"
- text: "Remove them"
- tone: "danger"
- enabled: !Containers.busy
- onClicked: {
- root.confirmingPrune = "";
- Containers.pruneVolumes();
- }
- }
- }
- }
- }
-
- // ── the stacks ──────────────────────────────────────────────────────
+ SettingsCard {
+ visible: Containers.reachable.length > 0
+ title: Containers.reachable.length === 1
+ ? "A container is published to your whole network"
+ : "Containers are published to your whole network"
+ subtitle: "These bind every interface, so any machine on your network can connect. "
+ + "For a development database, loopback is almost always what you want. "
+ + "Binding adds an address to the compose file and changes nothing else."
Repeater {
- model: Containers.projects
-
- delegate: SettingsCard {
- id: projectCard
+ model: Containers.reachable
+ delegate: SettingRow {
required property var modelData
+ width: parent.width
+ label: String(modelData.container ?? "")
+ detail: "Port " + modelData.hostPort + "/" + String(modelData.protocol ?? "tcp")
+ + " · " + String(modelData.image ?? "")
+ + (modelData.configFile
+ ? "\n" + Containers.shorten(String(modelData.configFile))
+ : "\nNo compose file is recorded for this container, so it cannot be bound from here.")
+ controlWidth: 250
- readonly property var runningSet:
- (projectCard.modelData.containers ?? []).filter(c => c.state === "running")
- readonly property var stoppedSet:
- (projectCard.modelData.containers ?? []).filter(c => c.state !== "running")
- readonly property string projectName: String(projectCard.modelData.name ?? "")
+ Row {
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 8
- title: String(projectCard.modelData.title ?? "")
- subtitle: {
- const total = Number(projectCard.modelData.total ?? 0);
- const up = Number(projectCard.modelData.running ?? 0);
- const where = String(projectCard.modelData.configFile ?? "");
- const counts = up === 0
- ? total + (total === 1 ? " container, stopped" : " containers, all stopped")
- : up + " of " + total + " running";
- return where === "" ? counts : counts + " · " + Containers.shorten(where);
- }
-
- SettingRow {
- width: parent.width
- label: "The whole stack"
- detail: projectCard.runningSet.length === 0
- ? "Starts every container this project defines."
- : "Stopping leaves the containers in place; nothing is removed."
- controlWidth: 250
- divider: projectCard.runningSet.length > 0
- || projectCard.stoppedSet.length > 0
-
- Row {
- anchors.right: parent.right
- anchors.verticalCenter: parent.verticalCenter
- spacing: 8
-
- SettingsButton {
- text: "Start all"
- visible: projectCard.stoppedSet.length > 0
- enabled: !Containers.busy
- onClicked: Containers.startProject(projectCard.projectName)
- }
-
- SettingsButton {
- text: "Restart all"
- visible: projectCard.runningSet.length > 0
- enabled: !Containers.busy
- onClicked: Containers.restartProject(projectCard.projectName)
- }
-
- SettingsButton {
- text: "Stop all"
- visible: projectCard.runningSet.length > 0
- enabled: !Containers.busy
- onClicked: Containers.stopProject(projectCard.projectName)
- }
+ SettingsButton {
+ text: "Open compose file"
+ visible: String(modelData.configFile ?? "") !== ""
+ enabled: !Containers.busy
+ onClicked: Containers.openFile(String(modelData.configFile))
}
- }
- Repeater {
- model: projectCard.runningSet
-
- delegate: SettingRow {
- required property var modelData
- width: parent.width
- label: String(modelData.name ?? "")
- detail: String(modelData.image ?? "") + " · " + root.portSummary(modelData)
- value: {
- const health = String(modelData.health ?? "");
- const up = root.uptimeOf(modelData);
- if (health !== "" && up !== "")
- return health + " · " + up;
- return health !== "" ? health : up;
- }
- controlWidth: 250
-
- Row {
- anchors.right: parent.right
- anchors.verticalCenter: parent.verticalCenter
- spacing: 8
-
- SettingsButton {
- text: "Logs"
- onClicked: Containers.openLogs(String(modelData.name))
- }
-
- SettingsButton {
- text: "Restart"
- enabled: !Containers.busy
- onClicked: Containers.restart(String(modelData.name))
- }
-
- SettingsButton {
- text: "Stop"
- enabled: !Containers.busy
- onClicked: Containers.stop(String(modelData.name))
- }
- }
- }
- }
-
- SettingRow {
- width: parent.width
- visible: projectCard.stoppedSet.length > 0
- activatable: true
- divider: root.isExpanded(projectCard.projectName)
- label: (root.isExpanded(projectCard.projectName) ? "▾ " : "▸ ")
- + projectCard.stoppedSet.length
- + (projectCard.stoppedSet.length === 1
- ? " stopped container" : " stopped containers")
- detail: {
- const bad = projectCard.stoppedSet.filter(c => Number(c.exitCode ?? 0) !== 0);
- return bad.length === 0
- ? ""
- : bad.length + (bad.length === 1 ? " exited" : " exited") + " badly.";
- }
- onActivated: root.toggleExpanded(projectCard.projectName)
- }
-
- Repeater {
- model: root.isExpanded(projectCard.projectName) ? projectCard.stoppedSet : []
-
- delegate: SettingRow {
- required property var modelData
- required property int index
- width: parent.width
- label: String(modelData.name ?? "")
- detail: String(modelData.image ?? "") + " · " + String(modelData.status ?? "")
- controlWidth: 180
- divider: index < projectCard.stoppedSet.length - 1
-
- Row {
- anchors.right: parent.right
- anchors.verticalCenter: parent.verticalCenter
- spacing: 8
-
- SettingsButton {
- text: "Logs"
- onClicked: Containers.openLogs(String(modelData.name))
- }
-
- SettingsButton {
- text: "Start"
- enabled: !Containers.busy
- onClicked: Containers.start(String(modelData.name))
- }
- }
+ SettingsButton {
+ text: "Bind to localhost"
+ tone: "accent"
+ // Needs both halves of the label to find the entry in
+ // the compose file; the Supabase CLI records neither.
+ visible: String(modelData.configFile ?? "") !== ""
+ && String(modelData.service ?? "") !== ""
+ enabled: !Containers.busy
+ onClicked: Containers.bindLocal(
+ String(modelData.project), String(modelData.service))
}
}
}
}
- // ── containers no compose project claims ────────────────────────────
+ TextRow {
+ visible: Containers.reachable.length > 0
+ label: "After binding"
+ detail: "The change takes effect the next time the stack comes up, because a "
+ + "published port is fixed when the container is created."
+ value: ""
+ divider: false
+ }
+ }
- SettingsCard {
- visible: Containers.loose.length > 0
- title: "Not part of a project"
- subtitle: "Started directly rather than by a compose file."
+ // ── finding: disk nothing references ────────────────────────────────
+
+ SettingsCard {
+ visible: Containers.unusedImages.length > 0 || Containers.unusedVolumes.length > 0
+ title: Containers.formatBytes(Containers.reclaimable) + " nothing is using"
+ subtitle: "Images and volumes no container references. Removing them frees the space; "
+ + "anything still needed downloads again on next use."
+
+ Repeater {
+ model: Containers.unusedImages.slice(0, 5)
+
+ delegate: TextRow {
+ required property var modelData
+ width: parent.width
+ label: String(modelData.name ?? "")
+ detail: ""
+ value: Containers.formatBytes(Number(modelData.size ?? 0))
+ }
+ }
+
+ TextRow {
+ visible: Containers.unusedImages.length > 5
+ label: "and " + (Containers.unusedImages.length - 5) + " more"
+ detail: ""
+ value: ""
+ }
+
+ SettingRow {
+ width: parent.width
+ label: root.confirmingPrune === "images"
+ ? "Remove " + Containers.unusedImages.length + " images?"
+ : "Unused images"
+ detail: root.confirmingPrune === "images"
+ ? "Each is removed by name. Nothing that a container references is touched."
+ : Containers.unusedImages.length + " images, "
+ + Containers.formatBytes(Number(Containers.disk.imagesReclaimable ?? 0))
+ visible: Containers.unusedImages.length > 0
+ controlWidth: 230
+
+ Row {
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 8
+
+ SettingsButton {
+ text: root.confirmingPrune === "images" ? "Keep them" : "Remove…"
+ enabled: !Containers.busy
+ onClicked: root.confirmingPrune =
+ root.confirmingPrune === "images" ? "" : "images"
+ }
+
+ SettingsButton {
+ visible: root.confirmingPrune === "images"
+ text: "Remove them"
+ tone: "danger"
+ enabled: !Containers.busy
+ onClicked: {
+ root.confirmingPrune = "";
+ Containers.pruneImages();
+ }
+ }
+ }
+ }
+
+ SettingRow {
+ width: parent.width
+ visible: Containers.unusedVolumes.length > 0
+ label: root.confirmingPrune === "volumes"
+ ? "Remove " + Containers.unusedVolumes.length + " volumes?"
+ : "Unused volumes"
+ detail: root.confirmingPrune === "volumes"
+ ? "A volume holds data. These are the ones podman reports as referenced by "
+ + "nothing, but removing them cannot be undone."
+ : Containers.unusedVolumes.length + " volumes, "
+ + Containers.formatBytes(Number(Containers.disk.volumesReclaimable ?? 0))
+ controlWidth: 230
+ divider: false
+
+ Row {
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 8
+
+ SettingsButton {
+ text: root.confirmingPrune === "volumes" ? "Keep them" : "Remove…"
+ enabled: !Containers.busy
+ onClicked: root.confirmingPrune =
+ root.confirmingPrune === "volumes" ? "" : "volumes"
+ }
+
+ SettingsButton {
+ visible: root.confirmingPrune === "volumes"
+ text: "Remove them"
+ tone: "danger"
+ enabled: !Containers.busy
+ onClicked: {
+ root.confirmingPrune = "";
+ Containers.pruneVolumes();
+ }
+ }
+ }
+ }
+ }
+
+ // ── the stacks ──────────────────────────────────────────────────────
+
+ Repeater {
+ model: Containers.projects
+
+ delegate: SettingsCard {
+ id: projectCard
+
+ required property var modelData
+
+ readonly property var runningSet:
+ (projectCard.modelData.containers ?? []).filter(c => c.state === "running")
+ readonly property var stoppedSet:
+ (projectCard.modelData.containers ?? []).filter(c => c.state !== "running")
+ readonly property string projectName: String(projectCard.modelData.name ?? "")
+
+ title: String(projectCard.modelData.title ?? "")
+ subtitle: {
+ const total = Number(projectCard.modelData.total ?? 0);
+ const up = Number(projectCard.modelData.running ?? 0);
+ const where = String(projectCard.modelData.configFile ?? "");
+ const counts = up === 0
+ ? total + (total === 1 ? " container, stopped" : " containers, all stopped")
+ : up + " of " + total + " running";
+ return where === "" ? counts : counts + " · " + Containers.shorten(where);
+ }
+
+ SettingRow {
+ width: parent.width
+ label: "The whole stack"
+ detail: projectCard.runningSet.length === 0
+ ? "Starts every container this project defines."
+ : "Stopping leaves the containers in place; nothing is removed."
+ controlWidth: 250
+ divider: projectCard.runningSet.length > 0
+ || projectCard.stoppedSet.length > 0
+
+ Row {
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 8
+
+ SettingsButton {
+ text: "Start all"
+ visible: projectCard.stoppedSet.length > 0
+ enabled: !Containers.busy
+ onClicked: Containers.startProject(projectCard.projectName)
+ }
+
+ SettingsButton {
+ text: "Restart all"
+ visible: projectCard.runningSet.length > 0
+ enabled: !Containers.busy
+ onClicked: Containers.restartProject(projectCard.projectName)
+ }
+
+ SettingsButton {
+ text: "Stop all"
+ visible: projectCard.runningSet.length > 0
+ enabled: !Containers.busy
+ onClicked: Containers.stopProject(projectCard.projectName)
+ }
+ }
+ }
Repeater {
- model: Containers.loose
+ model: projectCard.runningSet
delegate: SettingRow {
required property var modelData
- required property int index
width: parent.width
label: String(modelData.name ?? "")
- detail: String(modelData.image ?? "") + " · " + String(modelData.status ?? "")
- controlWidth: 180
- divider: index < Containers.loose.length - 1
+ detail: String(modelData.image ?? "") + " · " + root.portSummary(modelData)
+ value: {
+ const health = String(modelData.health ?? "");
+ const up = root.uptimeOf(modelData);
+ if (health !== "" && up !== "")
+ return health + " · " + up;
+ return health !== "" ? health : up;
+ }
+ controlWidth: 250
Row {
anchors.right: parent.right
@@ -435,122 +340,217 @@ Item {
}
SettingsButton {
- text: modelData.state === "running" ? "Stop" : "Start"
+ text: "Restart"
enabled: !Containers.busy
- onClicked: modelData.state === "running"
- ? Containers.stop(String(modelData.name))
- : Containers.start(String(modelData.name))
+ onClicked: Containers.restart(String(modelData.name))
+ }
+
+ SettingsButton {
+ text: "Stop"
+ enabled: !Containers.busy
+ onClicked: Containers.stop(String(modelData.name))
+ }
+ }
+ }
+ }
+
+ SettingRow {
+ width: parent.width
+ visible: projectCard.stoppedSet.length > 0
+ activatable: true
+ divider: root.isExpanded(projectCard.projectName)
+ label: (root.isExpanded(projectCard.projectName) ? "▾ " : "▸ ")
+ + projectCard.stoppedSet.length
+ + (projectCard.stoppedSet.length === 1
+ ? " stopped container" : " stopped containers")
+ detail: {
+ const bad = projectCard.stoppedSet.filter(c => Number(c.exitCode ?? 0) !== 0);
+ return bad.length === 0
+ ? ""
+ : bad.length + (bad.length === 1 ? " exited" : " exited") + " badly.";
+ }
+ onActivated: root.toggleExpanded(projectCard.projectName)
+ }
+
+ Repeater {
+ model: root.isExpanded(projectCard.projectName) ? projectCard.stoppedSet : []
+
+ delegate: SettingRow {
+ required property var modelData
+ required property int index
+ width: parent.width
+ label: String(modelData.name ?? "")
+ detail: String(modelData.image ?? "") + " · " + String(modelData.status ?? "")
+ controlWidth: 180
+ divider: index < projectCard.stoppedSet.length - 1
+
+ Row {
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 8
+
+ SettingsButton {
+ text: "Logs"
+ onClicked: Containers.openLogs(String(modelData.name))
+ }
+
+ SettingsButton {
+ text: "Start"
+ enabled: !Containers.busy
+ onClicked: Containers.start(String(modelData.name))
}
}
}
}
}
+ }
- TextRow {
- visible: Containers.scanned && Containers.available && Containers.total === 0
- label: "No containers"
- detail: "Nothing has been created on this machine yet."
- value: ""
- divider: false
+ // ── containers no compose project claims ────────────────────────────
+
+ SettingsCard {
+ visible: Containers.loose.length > 0
+ title: "Not part of a project"
+ subtitle: "Started directly rather than by a compose file."
+
+ Repeater {
+ model: Containers.loose
+
+ delegate: SettingRow {
+ required property var modelData
+ required property int index
+ width: parent.width
+ label: String(modelData.name ?? "")
+ detail: String(modelData.image ?? "") + " · " + String(modelData.status ?? "")
+ controlWidth: 180
+ divider: index < Containers.loose.length - 1
+
+ Row {
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 8
+
+ SettingsButton {
+ text: "Logs"
+ onClicked: Containers.openLogs(String(modelData.name))
+ }
+
+ SettingsButton {
+ text: modelData.state === "running" ? "Stop" : "Start"
+ enabled: !Containers.busy
+ onClicked: modelData.state === "running"
+ ? Containers.stop(String(modelData.name))
+ : Containers.start(String(modelData.name))
+ }
+ }
+ }
}
}
+ TextRow {
+ visible: Containers.scanned && Containers.available && Containers.total === 0
+ label: "No containers"
+ detail: "Nothing has been created on this machine yet."
+ value: ""
+ divider: false
+ }
+
// ── logs ────────────────────────────────────────────────────────────────
//
// A drill-in rather than a panel inside the scrolling page: logs need their
// own scrollback, and nesting one scrolling view inside another makes the
// wheel ambiguous over the region where you most want to use it.
- Item {
- anchors.fill: parent
- visible: Containers.logTarget !== ""
+ drillIn: Component {
+ Item {
+ anchors.fill: parent
- Column {
- id: logHeader
- anchors.left: parent.left
- anchors.right: parent.right
- anchors.top: parent.top
- anchors.leftMargin: 34
- anchors.rightMargin: 34
- anchors.topMargin: 30
- spacing: 6
+ Column {
+ id: logHeader
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.leftMargin: 34
+ anchors.rightMargin: 34
+ anchors.topMargin: 30
+ spacing: 6
- Row {
- width: parent.width
- spacing: 12
+ Row {
+ width: parent.width
+ spacing: 12
- SettingsButton {
- text: "‹ Back"
- anchors.verticalCenter: parent.verticalCenter
- onClicked: Containers.closeLogs()
+ SettingsButton {
+ text: "‹ Back"
+ anchors.verticalCenter: parent.verticalCenter
+ onClicked: Containers.closeLogs()
+ }
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: Containers.logTarget
+ color: Theme.fg
+ font.family: Theme.fontFamily
+ font.pixelSize: 22
+ font.weight: Font.DemiBold
+ }
}
Text {
- anchors.verticalCenter: parent.verticalCenter
- text: Containers.logTarget
- color: Theme.fg
+ width: parent.width
+ text: Containers.logError !== ""
+ ? Containers.logError
+ : (Containers.logFollowing
+ ? "Following. The last " + Containers.logLines.count + " lines are shown."
+ : "The stream has ended.")
+ color: Containers.logError !== "" ? Theme.danger : Theme.fgDim
font.family: Theme.fontFamily
- font.pixelSize: 22
- font.weight: Font.DemiBold
+ font.pixelSize: Theme.fontSizeSmall
+ wrapMode: Text.WordWrap
}
}
- Text {
- width: parent.width
- text: Containers.logError !== ""
- ? Containers.logError
- : (Containers.logFollowing
- ? "Following. The last " + Containers.logLines.count + " lines are shown."
- : "The stream has ended.")
- color: Containers.logError !== "" ? Theme.danger : Theme.fgDim
- font.family: Theme.fontFamily
- font.pixelSize: Theme.fontSizeSmall
- wrapMode: Text.WordWrap
- }
- }
+ Rectangle {
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: logHeader.bottom
+ anchors.bottom: parent.bottom
+ anchors.leftMargin: 34
+ anchors.rightMargin: 34
+ anchors.topMargin: 14
+ anchors.bottomMargin: 30
+ radius: Theme.cardRadius + 2
+ color: Theme.alpha(Theme.bgDark, 0.75)
+ border.width: 1
+ border.color: Theme.alpha(Theme.fg, 0.07)
- Rectangle {
- anchors.left: parent.left
- anchors.right: parent.right
- anchors.top: logHeader.bottom
- anchors.bottom: parent.bottom
- anchors.leftMargin: 34
- anchors.rightMargin: 34
- anchors.topMargin: 14
- anchors.bottomMargin: 30
- radius: Theme.cardRadius + 2
- color: Theme.alpha(Theme.bgDark, 0.75)
- border.width: 1
- border.color: Theme.alpha(Theme.fg, 0.07)
+ ListView {
+ id: logList
- ListView {
- id: logList
+ anchors.fill: parent
+ anchors.margins: 12
+ clip: true
+ model: Containers.logLines
+ spacing: 1
+ boundsBehavior: Flickable.StopAtBounds
+ cacheBuffer: 400
- anchors.fill: parent
- anchors.margins: 12
- clip: true
- model: Containers.logLines
- spacing: 1
- boundsBehavior: Flickable.StopAtBounds
- cacheBuffer: 400
+ // Stay pinned to the newest line while the reader is already at
+ // the bottom, and leave the view alone the moment they scroll up
+ // to read something.
+ property bool pinned: true
+ onContentYChanged: logList.pinned =
+ logList.contentY >= logList.contentHeight - logList.height - 24
+ onCountChanged: if (logList.pinned) logList.positionViewAtEnd()
- // Stay pinned to the newest line while the reader is already at
- // the bottom, and leave the view alone the moment they scroll up
- // to read something.
- property bool pinned: true
- onContentYChanged: logList.pinned =
- logList.contentY >= logList.contentHeight - logList.height - 24
- onCountChanged: if (logList.pinned) logList.positionViewAtEnd()
-
- delegate: Text {
- required property string line
- width: logList.width - 24
- text: line
- color: Theme.fgDim
- font.family: Theme.fontMono
- font.pixelSize: Theme.fontSizeSmall
- wrapMode: Text.WrapAnywhere
- textFormat: Text.PlainText
+ delegate: Text {
+ required property string line
+ width: logList.width - 24
+ text: line
+ color: Theme.fgDim
+ font.family: Theme.fontMono
+ font.pixelSize: Theme.fontSizeSmall
+ wrapMode: Text.WrapAnywhere
+ textFormat: Text.PlainText
+ }
}
}
}
diff --git a/config/dot/quickshell/modules/settings/DateTimePage.qml b/config/dot/quickshell/modules/settings/DateTimePage.qml
index 33f4a9a..54a10bf 100644
--- a/config/dot/quickshell/modules/settings/DateTimePage.qml
+++ b/config/dot/quickshell/modules/settings/DateTimePage.qml
@@ -1,16 +1,22 @@
-// Date & Time.
+// Date, Time & Region.
//
-// These belong to the machine rather than to Panama, so nothing here is stored
-// in Panama's settings file -- it would be a second answer to a question the
-// system already answers. Timezone and network time are read from and written
-// to timedatectl directly. 24-hour time is the one presentation choice that
-// belongs here rather than on Shell › Bar: it drives the date menu,
-// notification timestamps and the lock screen as well as the bar. The rest of
-// the bar clock's presentation -- seconds, weekday -- stays with the bar.
+// One tab, because they are one question: what does this machine consider
+// local. It used to be two -- Date & Time here, Region & Language one tab over
+// -- and the second was mostly a button that opened GNOME.
//
-// Changing the timezone or network time needs privilege. timedatectl asks
-// polkit, and a canceled dialog surfaces as an error rather than as a value
-// that appears to have been accepted.
+// Nothing on this page is stored in Panama's settings file except 24-hour time.
+// The timezone and network time belong to timedatectl; the language and the
+// per-category formats belong to localectl. A preference here would be a second
+// answer to a question the system already answers, and the two would drift the
+// moment anything else changed one of them.
+//
+// Changing any of it needs privilege. Both helpers ask polkit, and a canceled
+// dialog surfaces as an error rather than as a value that appears to have been
+// accepted.
+//
+// The preview at the bottom is rendered by Qt from the *chosen* locales rather
+// than described in prose, because "1,234,567.89" settles in one glance what a
+// sentence about separators does not.
import QtQuick
import Quickshell
@@ -21,29 +27,42 @@ import qs.modules.clipboard
SettingsPage {
id: root
- title: "Date & Time"
- lede: "Timezone and network time, shared with the whole machine."
+ objectName: "datetime"
+
+ title: "Date, Time & Region"
+ lede: SystemLocale.pendingRestart
+ ? "Your new language applies to programs started after you sign out and back in."
+ : root.nowLine
+
+ Component.onCompleted: if (SystemLocale.locales.length === 0) SystemLocale.refresh()
+
+ readonly property string nowLine:
+ Qt.formatDateTime(clock.date, Settings.use24Hour ? "HH:mm" : "h:mm AP")
+ + " · " + Qt.formatDate(clock.date, "dddd, MMMM d")
+ + (DateTime.timezone === "" ? "" : " · " + DateTime.timezone) + "."
+
+ // ── The clock ────────────────────────────────────────────────────────────
+
+ // What the system itself says the time is, in the shape `timedatectl
+ // set-time` accepts, so the manual field starts from the truth rather than
+ // from Qt's idea of it. Falls back to the shell's own clock on a machine
+ // whose timedatectl phrases TimeUSec differently.
+ function seedTime(): string {
+ const match = /(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2})/.exec(String(DateTime.localTime ?? ""));
+ return match
+ ? match[1] + " " + match[2]
+ : Qt.formatDateTime(clock.date, "yyyy-MM-dd HH:mm");
+ }
SettingsCard {
title: "Clock"
- TextRow {
- label: "Current time"
- detail: DateTime.timezone === "" ? "Reading the system clock" : DateTime.timezone
- value: Qt.formatDateTime(clock.date, Settings.use24Hour ? "ddd d MMM HH:mm" : "ddd d MMM h:mm AP")
- }
- // Not just the bar's. The same choice reads out in the date menu,
- // every notification's timestamp, and the lock screen, so it belongs
- // beside the clock the whole machine shares rather than on a page
- // about one surface.
- ToggleRow { setting: "use24Hour" }
SettingRow {
label: "Set automatically"
detail: DateTime.ntpEnabled
? (DateTime.ntpSynchronized ? "Synchronized with a time server" : "Waiting to synchronize")
: "The clock is set by hand"
controlWidth: 48
- divider: false
SettingsToggle {
anchors.right: parent.right
@@ -53,6 +72,32 @@ SettingsPage {
onToggled: value => DateTime.setNtp(value)
}
}
+
+ // Only offered when there is something to do: with a time server in
+ // charge, setting the clock by hand is refused by timedatectl anyway,
+ // and a field that always fails is worse than no field.
+ FieldActionRow {
+ id: manualClock
+
+ visible: !DateTime.ntpEnabled
+ label: "Set the clock"
+ detail: "Date and time as YYYY-MM-DD HH:MM"
+ placeholder: "2026-01-31 09:00"
+ action: "Set"
+ fieldWidth: 160
+ enabled: !DateTime.busy
+ onSubmitted: value => DateTime.setTime(value)
+
+ // Seeded when the row appears rather than bound, so typing is not
+ // yanked out from under anyone by the next status read.
+ onVisibleChanged: if (visible && text === "") text = root.seedTime()
+ }
+
+ // Not just the bar's. The same choice reads out in the date menu,
+ // every notification's timestamp, and the lock screen, so it belongs
+ // beside the clock the whole machine shares rather than on a page
+ // about one surface.
+ ToggleRow { setting: "use24Hour"; divider: false }
}
SettingsCard {
@@ -109,6 +154,265 @@ SettingsPage {
subtitle: DateTime.lastError
}
+ // ── Language and formats ─────────────────────────────────────────────────
+
+ // Each category may follow the language or override it. An empty override
+ // is what "Match language" means, and it is what localectl stores: no
+ // LC_TIME line at all, rather than a copy of LANG that stops tracking it.
+ function categoryValue(category: string): string {
+ return String(SystemLocale.categoryValue(category) ?? "");
+ }
+
+ // The locale actually in force for a category, which is what the preview
+ // must be rendered from.
+ function effectiveLocale(category: string): string {
+ const override = root.categoryValue(category);
+ return override === "" ? SystemLocale.current : override;
+ }
+
+ function localeLabel(value: string): string {
+ const match = (SystemLocale.locales ?? []).find(entry => entry.value === value);
+ return match ? match.label : value;
+ }
+
+ // "Match language" first, then everything installed. The first entry has an
+ // empty value because that is literally what it sets.
+ readonly property var categoryChoices: [{
+ value: "",
+ label: "Match language",
+ detail: root.localeLabel(SystemLocale.current)
+ }].concat(SystemLocale.locales ?? [])
+
+ // Qt wants "en_US"; localectl deals in "en_US.UTF-8". QLocale tolerates the
+ // codeset, but the modifier forms do not all round-trip, so it is trimmed.
+ function qtLocale(value: string): var {
+ return Qt.locale(String(value ?? "").split(".")[0].split("@")[0]);
+ }
+
+ readonly property var timeLocale: root.qtLocale(root.effectiveLocale("LC_TIME"))
+ readonly property var numberLocale: root.qtLocale(root.effectiveLocale("LC_NUMERIC"))
+ readonly property var currencyLocale: root.qtLocale(root.effectiveLocale("LC_MONETARY"))
+ readonly property var measurementLocale: root.qtLocale(root.effectiveLocale("LC_MEASUREMENT"))
+
+ readonly property var weekdayNames: [
+ "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"
+ ]
+
+ // Measurement and paper are chosen together by one row, because glibc ships
+ // them from the same country and nobody sets one without the other. When
+ // they have been set apart by hand the row says so rather than hiding it.
+ readonly property bool paperDiffers:
+ root.categoryValue("LC_PAPER") !== root.categoryValue("LC_MEASUREMENT")
+
+ SettingsCard {
+ title: "Language & formats"
+ subtitle: "Changing any of these needs your password, and takes effect for programs started afterwards."
+
+ PickerRow {
+ id: languagePicker
+
+ label: "Language"
+ detail: SystemLocale.pendingRestart
+ ? "Chosen, but not in use until you sign out and back in"
+ : "Used by programs that ask the system what language to speak"
+ value: SystemLocale.currentLabel || "Reading…"
+
+ SearchPicker {
+ width: parent.width
+ items: SystemLocale.locales
+ current: SystemLocale.current
+ placeholder: "Search languages and regions"
+ emptyText: SystemLocale.scanning ? "Reading installed locales…" : "No locales are installed"
+ onPicked: value => {
+ SystemLocale.set(value);
+ languagePicker.collapse();
+ }
+ }
+ }
+
+ PickerRow {
+ id: timePicker
+
+ label: "Dates and times"
+ detail: "LC_TIME — the order of day and month, and the shape of the clock"
+ value: SystemLocale.categoryLabel("LC_TIME")
+
+ SearchPicker {
+ width: parent.width
+ items: root.categoryChoices
+ current: root.categoryValue("LC_TIME")
+ placeholder: "Search locales"
+ onPicked: value => {
+ SystemLocale.setCategory("LC_TIME", value);
+ timePicker.collapse();
+ }
+ }
+ }
+
+ PickerRow {
+ id: numberPicker
+
+ label: "Numbers"
+ detail: "LC_NUMERIC — the decimal mark and the thousands separator"
+ value: SystemLocale.categoryLabel("LC_NUMERIC")
+
+ SearchPicker {
+ width: parent.width
+ items: root.categoryChoices
+ current: root.categoryValue("LC_NUMERIC")
+ placeholder: "Search locales"
+ onPicked: value => {
+ SystemLocale.setCategory("LC_NUMERIC", value);
+ numberPicker.collapse();
+ }
+ }
+ }
+
+ PickerRow {
+ id: currencyPicker
+
+ label: "Currency"
+ detail: "LC_MONETARY — which symbol, and which side of the number it sits on"
+ value: SystemLocale.categoryLabel("LC_MONETARY")
+
+ SearchPicker {
+ width: parent.width
+ items: root.categoryChoices
+ current: root.categoryValue("LC_MONETARY")
+ placeholder: "Search locales"
+ onPicked: value => {
+ SystemLocale.setCategory("LC_MONETARY", value);
+ currencyPicker.collapse();
+ }
+ }
+ }
+
+ PickerRow {
+ id: measurementPicker
+
+ label: "Measurements and paper"
+ detail: root.paperDiffers
+ ? "LC_MEASUREMENT and LC_PAPER — set apart from each other on this machine; choosing here sets both"
+ : "LC_MEASUREMENT and LC_PAPER — metric or imperial, and the default page size"
+ value: SystemLocale.categoryLabel("LC_MEASUREMENT")
+
+ SearchPicker {
+ width: parent.width
+ items: root.categoryChoices
+ current: root.categoryValue("LC_MEASUREMENT")
+ placeholder: "Search locales"
+ onPicked: value => {
+ SystemLocale.setCategory("LC_MEASUREMENT", value);
+ SystemLocale.setCategory("LC_PAPER", value);
+ measurementPicker.collapse();
+ }
+ }
+ }
+
+ // A fact, not a switch: glibc ships the first day of the week with the
+ // date format, and there is no separate thing to set.
+ TextRow {
+ label: "First day of the week"
+ detail: "Comes with the date format — glibc owns it, so this is a readout rather than a choice"
+ value: root.weekdayNames[root.timeLocale.firstDayOfWeek] ?? ""
+ }
+
+ // ── The preview ──────────────────────────────────────────────────────
+
+ Item {
+ width: parent.width
+ implicitHeight: previewFrame.implicitHeight + 20
+
+ Rectangle {
+ id: previewFrame
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.topMargin: 6
+ implicitHeight: previewGrid.implicitHeight + 22
+ radius: Theme.cardRadius
+ color: Theme.alpha(Theme.bgDark, 0.55)
+ border.width: 1
+ border.color: Theme.alpha(Theme.fg, 0.07)
+
+ Grid {
+ id: previewGrid
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.margins: 11
+ columns: previewFrame.width >= 520 ? 2 : 1
+ columnSpacing: 18
+ rowSpacing: 5
+
+ Repeater {
+ model: root.previewCells
+
+ Row {
+ required property var modelData
+
+ width: (previewGrid.width - (previewGrid.columns - 1) * previewGrid.columnSpacing)
+ / previewGrid.columns
+ spacing: 10
+
+ Text {
+ width: 78
+ text: modelData.label
+ color: Theme.fgMuted
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSizeSmall
+ }
+
+ Text {
+ width: parent.width - 88
+ text: modelData.value
+ color: Theme.fg
+ font.family: Theme.fontFamily
+ font.features: Theme.tabularFigures
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.Medium
+ elide: Text.ElideRight
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Paper size is deliberately absent above. Qt reports a locale's
+ // measurement system and not its page size, and deriving Letter from a
+ // country list would be Panama inventing an answer glibc already holds
+ // -- a preview that guesses is worse than a preview that stops.
+ TextRow {
+ label: "Paper size is not previewed"
+ detail: "Qt can report a locale's measurement system but not its page size, and guessing it from the country would be this page making something up."
+ value: ""
+ divider: false
+ }
+ }
+
+ SettingsCard {
+ visible: SystemLocale.lastError !== ""
+ title: "The system did not accept that"
+ subtitle: SystemLocale.lastError
+ }
+
+ readonly property var previewCells: {
+ const sample = clock.date;
+ return [
+ { label: "Today", value: sample.toLocaleDateString(root.timeLocale, Locale.LongFormat) },
+ { label: "Time", value: sample.toLocaleTimeString(root.timeLocale, Locale.ShortFormat) },
+ { label: "Number", value: (1234567.89).toLocaleString(root.numberLocale) },
+ { label: "Currency", value: (1234.56).toLocaleCurrencyString(root.currencyLocale) },
+ { label: "Units", value: root.measurementLocale.measurementSystem === Locale.MetricSystem
+ ? "Metric — °C, km, kg"
+ : "Imperial — °F, miles, lb" },
+ { label: "Short date", value: sample.toLocaleDateString(root.timeLocale, Locale.ShortFormat) }
+ ];
+ }
+
// Bounded so a blank search does not try to lay out six hundred rows. The
// current zone is always included, so the card never looks empty when the
// field is untouched.
diff --git a/config/dot/quickshell/modules/settings/FieldActionRow.qml b/config/dot/quickshell/modules/settings/FieldActionRow.qml
new file mode 100644
index 0000000..4669652
--- /dev/null
+++ b/config/dot/quickshell/modules/settings/FieldActionRow.qml
@@ -0,0 +1,120 @@
+// A row whose button takes a short piece of typed text as its argument.
+//
+// FieldActionRow {
+// label: "Back up now"
+// placeholder: "Name (optional)"
+// action: "Back up"
+// onSubmitted: value => SettingsBackup.save(value)
+// }
+//
+// TextFieldRow is for a value that IS the setting -- a hostname, an account's
+// real name -- and keeps showing whatever the system currently holds. This is
+// for a one-shot whose argument happens to be typed: naming a backup, setting
+// the clock by hand. The field holds an argument rather than a value, so the
+// caller clears it once the action is away instead of leaving text sitting
+// there looking like something that took effect.
+
+import QtQuick
+import qs.config
+
+SettingRow {
+ id: root
+
+ property string placeholder: ""
+ property string action: ""
+ property bool enabled: true
+ property int fieldWidth: 150
+
+ // What is typed right now, so a caller can require it before enabling the
+ // button, or seed it with a sensible starting point.
+ property alias text: input.text
+
+ signal submitted(value: string)
+
+ function clear(): void { input.text = ""; }
+
+ function press(): void {
+ if (root.enabled)
+ root.submitted(input.text.trim());
+ }
+
+ controlWidth: root.fieldWidth + submitButton.implicitWidth + 10
+
+ Row {
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 10
+
+ Rectangle {
+ anchors.verticalCenter: parent.verticalCenter
+ width: root.fieldWidth
+ height: 32
+ radius: 9
+ color: Theme.alpha(Theme.fg, root.enabled ? 0.06 : 0.03)
+ border.width: input.activeFocus ? 2 : 1
+ border.color: input.activeFocus
+ ? Theme.alpha(Theme.accent, 0.55)
+ : Theme.alpha(Theme.fg, 0.1)
+ opacity: root.enabled ? 1 : 0.5
+
+ TextInput {
+ id: input
+
+ anchors.fill: parent
+ anchors.leftMargin: 11
+ anchors.rightMargin: 11
+ enabled: root.enabled
+ activeFocusOnTab: root.enabled
+ color: Theme.fg
+ selectionColor: Theme.alpha(Theme.accent, 0.5)
+ selectedTextColor: Theme.fg
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSize
+ verticalAlignment: TextInput.AlignVCenter
+ clip: true
+
+ Accessible.role: Accessible.EditableText
+ Accessible.name: root.label
+ Accessible.description: root.placeholder
+
+ // Enter in the field is the same press as the button. Typing a
+ // name and hitting return is what everybody does first.
+ onAccepted: root.press()
+
+ Text {
+ anchors.fill: parent
+ visible: input.text === ""
+ text: root.placeholder
+ color: Theme.fgMuted
+ font: input.font
+ verticalAlignment: Text.AlignVCenter
+ elide: Text.ElideRight
+ }
+ }
+ }
+
+ SettingsButton {
+ id: submitButton
+
+ anchors.verticalCenter: parent.verticalCenter
+ text: root.action
+ enabled: root.enabled
+ activeFocusOnTab: root.enabled
+ border.width: activeFocus ? 2 : 1
+ border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
+ onClicked: root.press()
+
+ Accessible.role: Accessible.Button
+ Accessible.name: root.label
+ Accessible.description: root.detail === ""
+ ? root.action
+ : `${root.detail} — ${root.action}`
+ Accessible.focusable: root.enabled
+ Accessible.onPressAction: root.press()
+
+ Keys.onReturnPressed: root.press()
+ Keys.onEnterPressed: root.press()
+ Keys.onSpacePressed: root.press()
+ }
+ }
+}
diff --git a/config/dot/quickshell/modules/settings/HealthCheckRow.qml b/config/dot/quickshell/modules/settings/HealthCheckRow.qml
index e1e398e..8e41712 100644
--- a/config/dot/quickshell/modules/settings/HealthCheckRow.qml
+++ b/config/dot/quickshell/modules/settings/HealthCheckRow.qml
@@ -15,6 +15,13 @@ Item {
readonly property bool repairFailed: root.check.status !== "ok"
&& Health.lastRepair.checkId === root.check.id
&& (Health.lastRepair.accepted === false || Health.lastRepair.exitCode !== 0)
+ readonly property bool rechecking: Health.refreshingId === root.check.id
+ && Health.refreshingCheck
+
+ // What the row says under its title. Defaults to the check's own detail;
+ // the page overrides it where it has more to say -- a repair row is given
+ // the command it would run.
+ property string detailText: String(root.check.detail ?? "")
objectName: `health-check-row:${root.issue ? "issue" : "quiet"}:${root.check.id}`
implicitHeight: 62
@@ -86,7 +93,7 @@ Item {
Text {
width: parent.width
- text: root.check.detail
+ text: root.detailText
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
@@ -104,7 +111,7 @@ Item {
Text {
objectName: `health-status-text:${root.issue ? "issue" : "quiet"}:${root.check.id}`
anchors.verticalCenter: parent.verticalCenter
- text: root.displayedStatus()
+ text: root.rechecking ? "Checking…" : root.displayedStatus()
color: root.statusColor(root.check.status)
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
@@ -124,6 +131,26 @@ Item {
Keys.onReturnPressed: if (enabled) root.actionRequested(root.check)
Keys.onSpacePressed: if (enabled) root.actionRequested(root.check)
}
+
+ // One probe rather than thirty. After a repair -- or after fixing
+ // something by hand in a terminal -- the question is whether THIS row
+ // is happy now, and a full rescan to answer it costs nine seconds and
+ // re-renders the whole page. Offered on rows that are unhappy, since a
+ // healthy row has nothing to re-ask.
+ SettingsButton {
+ id: recheckButton
+
+ objectName: `health-row-recheck:${root.check.id}`
+ visible: root.issue
+ text: root.rechecking ? "Checking…" : "Re-check"
+ enabled: visible && !Health.busy && !Health.refreshingCheck
+ activeFocusOnTab: enabled
+ border.width: activeFocus ? 2 : 1
+ border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
+ onClicked: Health.refreshCheck(root.check.id)
+ Keys.onReturnPressed: if (enabled) Health.refreshCheck(root.check.id)
+ Keys.onSpacePressed: if (enabled) Health.refreshCheck(root.check.id)
+ }
}
Rectangle {
diff --git a/config/dot/quickshell/modules/settings/HealthPage.qml b/config/dot/quickshell/modules/settings/HealthPage.qml
index 20ab0b1..bdd8ae7 100644
--- a/config/dot/quickshell/modules/settings/HealthPage.qml
+++ b/config/dot/quickshell/modules/settings/HealthPage.qml
@@ -8,7 +8,17 @@ SettingsPage {
objectName: "system-health-page"
title: "System Health"
- lede: "Checks the parts of the desktop this app owns, and explains what needs attention."
+ lede: root.ledeText
+
+ readonly property string ledeText: {
+ if (Health.checks.length === 0)
+ return "Checks the parts of the desktop this app owns, and explains what needs attention.";
+ const count = Health.checks.length + (Health.checks.length === 1 ? " check" : " checks");
+ if (root.issueChecks.length === 0)
+ return count + " · everything healthy.";
+ return count + " · " + root.issueChecks.length
+ + (root.issueChecks.length === 1 ? " needs" : " need") + " a look.";
+ }
property var pendingConfirmation: null
property string instructionTarget: ""
@@ -43,11 +53,17 @@ SettingsPage {
"integration.bluebubbles": "bluebubbles"
})
- function statusLabel(status: string): string {
- if (status === "ok") return "Healthy";
- if (status === "warning") return "Needs attention";
- if (status === "error") return "Action required";
- return "Not set up";
+ // What a row says under its title. For a repair, that includes the command
+ // the button would run -- said before it runs rather than in a log
+ // afterwards. "Restart Vicinae" is what the button says; what it does is
+ // run something as the person pressing it, and by the time a log could
+ // tell them, the decision is already made.
+ function repairDetail(check: var): string {
+ const detail = String(check.detail ?? "");
+ const command = String(check.repairCommand ?? "");
+ if (command === "" || check.action?.kind !== "repair")
+ return detail;
+ return detail + " · Repair runs: " + command;
}
function checksForGroup(group: string): var {
@@ -285,6 +301,7 @@ SettingsPage {
width: issueRows.width
check: modelData
+ detailText: root.repairDetail(modelData)
issue: true
divider: index < issueRepeater.count - 1
onActionRequested: check => root.handleAction(check)
@@ -379,6 +396,7 @@ SettingsPage {
width: groupRows.width
check: modelData
+ detailText: root.repairDetail(modelData)
divider: index < groupRepeater.count - 1
onActionRequested: check => root.handleAction(check)
}
@@ -388,6 +406,26 @@ SettingsPage {
}
}
+ // Copying the report lives on the hero, where it has always been. Saving
+ // one is the other half: a clipboard survives until the next copy, and a
+ // report somebody is going to attach to a message has to be a file.
+ SettingsCard {
+ title: "Report"
+ subtitle: "Everything the last check saw, with the repair commands, redacted the same way the clipboard copy is."
+
+ ActionRow {
+ objectName: "health-save-report-row"
+ label: "Save the report to a file"
+ detail: Health.lastSaveResult !== ""
+ ? Health.lastSaveResult
+ : Health.defaultReportPath
+ action: "Save"
+ enabled: Health.checks.length > 0
+ divider: false
+ onTriggered: Health.saveReport(Health.defaultReportPath)
+ }
+ }
+
SettingsCard {
title: "Fedora system settings"
subtitle: "These areas remain owned by Fedora and GNOME's mature system panels."
@@ -427,14 +465,12 @@ SettingsPage {
}
}
- ActionRow {
- objectName: "health-fedora-handoff:color"
- label: "Color profiles"
- detail: "Assigning an ICC profile here has no visible effect in this session: the colord daemon that loads it onto a display isn't running under Hyprland"
- action: "Open color"
- onTriggered: SystemSettings.openGnomePanel("color")
- }
-
+ // Color profiles used to have a row here whose own detail explained
+ // that pressing it changed nothing -- the colord daemon that applies an
+ // ICC profile is not running under Hyprland. A handoff that documents
+ // its own uselessness is not a boundary, it is a dead button with an
+ // apology attached, so it is gone. Digital wellbeing stays: GNOME
+ // genuinely owns screen time, and that button genuinely works.
ActionRow {
objectName: "health-fedora-handoff:wellbeing"
label: "Digital wellbeing"
diff --git a/config/dot/quickshell/modules/settings/ManualChapters.qml b/config/dot/quickshell/modules/settings/ManualChapters.qml
new file mode 100644
index 0000000..61e90d5
--- /dev/null
+++ b/config/dot/quickshell/modules/settings/ManualChapters.qml
@@ -0,0 +1,83 @@
+// The manual's table of contents, with the titles read from the manual itself.
+//
+// Chapter files are numbered so their order is their filename, and each one
+// begins with its own title as a first-level heading. Reading that heading from
+// the file rather than repeating it in QML means a chapter cannot be renamed in
+// one place and not the other -- which is what had already happened by the time
+// anybody compared the two. There is deliberately no `label:` beside a filename
+// below; if one appears, the drift is back.
+//
+// Two pages need this list: the reader, and About's Manual card. Neither owns
+// it, so it lives here and both instantiate it. This is the one place the
+// chapter files are named.
+//
+// The chapters live beside the shell in manual/, reached through
+// Quickshell.shellDir: that resolves whether or not the repository is where it
+// usually is, and a path walked upward out of the shell directory does not.
+
+import Quickshell
+import Quickshell.Io
+import QtQml
+import QtQuick
+
+Item {
+ id: root
+
+ // Display order is filename order.
+ readonly property var chapters: [
+ { file: "01-coming-from-another-desktop.md" },
+ { file: "02-the-keyboard.md" },
+ { file: "03-windows-and-workspaces.md" },
+ { file: "04-when-something-breaks.md" },
+ { file: "05-making-it-yours.md" }
+ ]
+
+ // file -> first heading, filled in as each file is read.
+ property var headings: ({})
+
+ // [{ file, title }]. A chapter whose file has not been read yet -- or
+ // cannot be -- falls back to a title made out of its filename, so the list
+ // is never blank and never a row of paths.
+ readonly property var titled: root.chapters.map(chapter => ({
+ file: chapter.file,
+ title: String(root.headings[chapter.file] ?? "") !== ""
+ ? root.headings[chapter.file]
+ : root.titleFromFilename(chapter.file)
+ }))
+
+ function titleFromFilename(file: string): string {
+ const stem = file.replace(/^\d+-/, "").replace(/\.md$/, "").replace(/-/g, " ");
+ return stem.charAt(0).toUpperCase() + stem.slice(1);
+ }
+
+ // The first level-one heading, which every chapter opens with.
+ function headingOf(text: string): string {
+ for (const line of String(text ?? "").split("\n")) {
+ const match = /^#\s+(.+?)\s*$/.exec(line);
+ if (match)
+ return match[1];
+ }
+ return "";
+ }
+
+ // Reassigned rather than mutated: a property change on a plain object does
+ // not fire, and `titled` would keep answering with the old headings.
+ function absorb(file: string, text: string): void {
+ const next = Object.assign({}, root.headings);
+ next[file] = root.headingOf(text);
+ root.headings = next;
+ }
+
+ Instantiator {
+ model: root.chapters
+
+ delegate: FileView {
+ required property var modelData
+
+ path: Quickshell.shellDir + "/manual/" + modelData.file
+ printErrors: false
+ onLoaded: root.absorb(modelData.file, this.text())
+ onLoadFailed: root.absorb(modelData.file, "")
+ }
+ }
+}
diff --git a/config/dot/quickshell/modules/settings/ManualPage.qml b/config/dot/quickshell/modules/settings/ManualPage.qml
index 0f34b51..573d111 100644
--- a/config/dot/quickshell/modules/settings/ManualPage.qml
+++ b/config/dot/quickshell/modules/settings/ManualPage.qml
@@ -7,12 +7,12 @@
// So the manual is prose, in chapters, rendered here rather than opened in a
// browser. Keeping it in Settings means it is reachable from the same place
// as everything else it talks about, and a link to a settings page can just
-// be a settings page.
+// be a settings page -- see onLinkActivated below, where that stopped being a
+// figure of speech.
//
-// The chapters live beside the shell in manual/, not at the repository root.
-// They are read at runtime through Quickshell.shellDir, which resolves whether
-// or not the repository is where it usually is; a path walked upward out of
-// the shell directory does not.
+// The chapter list and the chapter titles both come from ManualChapters, which
+// reads each file's own first heading. This page used to carry a hand-written
+// copy of those titles under a comment claiming they were read from the files.
//
// One Text per chapter, never the whole manual at once: Text has an implicit
// texture size limit, and a document long enough to hit it fails by going
@@ -30,16 +30,7 @@ SettingsPage {
title: "Manual"
lede: "How this desktop works, for the person using it."
- // Chapter files are numbered so their order is their filename. The title
- // shown here is the first heading of each, read from the file, so a
- // chapter cannot be renamed in one place and not the other.
- readonly property var chapters: [
- { file: "01-coming-from-another-desktop.md", label: "Coming from another desktop" },
- { file: "02-the-keyboard.md", label: "The keyboard" },
- { file: "03-windows-and-workspaces.md", label: "Windows and workspaces" },
- { file: "04-when-something-breaks.md", label: "When something breaks" },
- { file: "05-making-it-yours.md", label: "Making it yours" }
- ]
+ readonly property var chapters: contents.titled
property int current: 0
@@ -56,10 +47,12 @@ SettingsPage {
}
}
+ ManualChapters { id: contents }
+
SettingsTabs {
tabs: root.chapters.map((chapter, index) => ({
value: String(index),
- label: chapter.label
+ label: chapter.title
}))
current: String(root.current)
onSelected: value => root.current = parseInt(value, 10)
@@ -90,7 +83,30 @@ SettingsPage {
// size; a document read once needs to be easy on the first pass.
lineHeight: 1.35
- onLinkActivated: link => Qt.openUrlExternally(link)
+ // A link that names a settings page opens that page instead of a
+ // browser, which is the whole reason the manual lives inside
+ // Settings. Anything else -- a project URL, a wiki -- still leaves
+ // the desktop.
+ //
+ // The scheme is Panama's own, so a chapter can never accidentally
+ // hand an http link to openSettings, and an unknown page cannot be
+ // smuggled in either: SettingsRoutes.resolve turns anything it does
+ // not recognise into Home rather than a blank loader. A fragment,
+ // where there is one, is the section within the page.
+ onLinkActivated: link => {
+ const target = String(link ?? "");
+ const scheme = "panama://settings/";
+ if (target.indexOf(scheme) !== 0) {
+ Qt.openUrlExternally(target);
+ return;
+ }
+ const rest = target.slice(scheme.length);
+ const hash = rest.indexOf("#");
+ if (hash < 0)
+ ShellState.openSettings(rest);
+ else
+ ShellState.openSettingsSection(rest.slice(0, hash), rest.slice(hash + 1));
+ }
}
}
}
diff --git a/config/dot/quickshell/modules/settings/RegionPage.qml b/config/dot/quickshell/modules/settings/RegionPage.qml
deleted file mode 100644
index 05b06a7..0000000
--- a/config/dot/quickshell/modules/settings/RegionPage.qml
+++ /dev/null
@@ -1,81 +0,0 @@
-// Region & Language.
-//
-// GNOME keeps language and formats under System; the setting itself is the
-// machine's locale, which localectl owns. Panama does not store a copy of it --
-// there is exactly one system locale and localectl is where it lives, so a
-// preference here would be a second source of truth that drifts the moment
-// anything else changes it.
-//
-// Keyboard layout is deliberately not repeated here even though GNOME groups it
-// with region. It is a compositor setting that applies instantly, it lives on
-// the Keyboard page with the rest of the typing settings, and showing it twice
-// invites the two views to disagree.
-
-import QtQuick
-import qs.config
-import qs.services
-
-SettingsPage {
- id: root
-
- title: "Region & Language"
- lede: SystemLocale.pendingRestart
- ? "Your new language applies to programs started after you sign out and back in."
- : "The language and regional formats this machine uses."
-
- Component.onCompleted: if (SystemLocale.locales.length === 0) SystemLocale.refresh()
-
- SettingsCard {
- title: "Language"
- subtitle: "Changing this needs your password, and takes effect for programs started afterwards."
-
- PickerRow {
- id: languagePicker
-
- label: "Current language"
- detail: SystemLocale.pendingRestart
- ? "Chosen, but not in use until you sign out and back in"
- : "Used by programs that ask the system what language to speak"
- value: SystemLocale.currentLabel || "Reading…"
- divider: false
-
- SearchPicker {
- width: parent.width
- items: SystemLocale.locales
- current: SystemLocale.current
- placeholder: "Search languages and regions"
- emptyText: SystemLocale.scanning ? "Reading installed locales…" : "No locales are installed"
- onPicked: value => {
- SystemLocale.set(value);
- languagePicker.collapse();
- }
- }
- }
- }
-
- SettingsCard {
- visible: SystemLocale.lastError !== ""
- title: "Language problem"
- subtitle: SystemLocale.lastError
- }
-
- SettingsCard {
- title: "Formats"
- subtitle: "Dates, times, and numbers follow the language above. The desktop's own clock formatting is on the Appearance page."
-
- ActionRow {
- label: "Clock and date display"
- detail: "How the desktop itself shows the time"
- action: "Open appearance"
- onTriggered: ShellState.openSettings("appearance")
- }
-
- ActionRow {
- label: "Regional formats"
- detail: "Separate per-category formats (LC_TIME, LC_NUMERIC) are owned by Fedora"
- action: "Open system"
- divider: false
- onTriggered: SystemSettings.openGnomePanel("system", "region")
- }
- }
-}
diff --git a/config/dot/quickshell/modules/settings/SettingsPage.qml b/config/dot/quickshell/modules/settings/SettingsPage.qml
index cc15abe..65f0385 100644
--- a/config/dot/quickshell/modules/settings/SettingsPage.qml
+++ b/config/dot/quickshell/modules/settings/SettingsPage.qml
@@ -28,6 +28,14 @@ Item {
// scrolls -- the Appearance page pins its live preview here.
property Component header: null
+ // A view that replaces the page entirely while it is up. Containers' log
+ // reader is the case: logs need their own scrollback, and a scrolling view
+ // nested inside a scrolling page makes the wheel ambiguous over exactly the
+ // region where somebody wants to use it. A page that owns one stays a
+ // SettingsPage rather than becoming a bare Item wrapping two of them.
+ property Component drillIn: null
+ property bool drilledIn: false
+
Loader {
id: pinnedHeader
@@ -37,11 +45,19 @@ Item {
anchors.leftMargin: 34
anchors.rightMargin: 34
anchors.topMargin: 30
+ visible: !root.drilledIn
active: root.header !== null
sourceComponent: root.header
z: 1
}
+ Loader {
+ anchors.fill: parent
+ active: root.drillIn !== null && root.drilledIn
+ sourceComponent: root.drillIn
+ z: 2
+ }
+
Flickable {
id: pageScroll
@@ -50,6 +66,7 @@ Item {
anchors.top: pinnedHeader.implicitHeight > 0 ? pinnedHeader.bottom : parent.top
anchors.bottom: parent.bottom
anchors.topMargin: pinnedHeader.implicitHeight > 0 ? 16 : 0
+ visible: !root.drilledIn
clip: true
contentWidth: width
contentHeight: layout.implicitHeight + (pinnedHeader.implicitHeight > 0 ? 34 : 64)
diff --git a/config/dot/quickshell/modules/settings/SettingsShell.qml b/config/dot/quickshell/modules/settings/SettingsShell.qml
index 0d5263d..8dd6a89 100644
--- a/config/dot/quickshell/modules/settings/SettingsShell.qml
+++ b/config/dot/quickshell/modules/settings/SettingsShell.qml
@@ -11,9 +11,16 @@ Rectangle {
&& pageLoader.item.objectName === "my-home-page"
? pageLoader.item.pageDiagnostics
: ({})
+ // The objectName check alone is not enough. This binding re-evaluates
+ // while the loader is swapping pages, and for a moment `item` is the new
+ // page carrying the old objectName -- calling uiDiagnostics() on it threw
+ // a TypeError every time somebody navigated away from System Health.
+ // Asking whether the function is actually there is the only test that
+ // holds during the swap.
readonly property var healthDiagnostics: pageLoader.status === Loader.Ready
&& pageLoader.item
&& pageLoader.item.objectName === "system-health-page"
+ && typeof pageLoader.item.uiDiagnostics === "function"
? pageLoader.item.uiDiagnostics()
: ({})
@@ -181,7 +188,6 @@ Rectangle {
case "mouse": return mousePage;
case "dictation": return dictationPage;
case "privacy": return privacyPage;
- case "region": return regionPage;
case "accounts": return onlineAccountsPage;
case "accessibility": return accessibilityPage;
case "power": return powerPage;
@@ -267,7 +273,6 @@ Rectangle {
Component { id: mousePage; MousePage {} }
Component { id: dictationPage; DictationPage {} }
Component { id: privacyPage; PrivacyPage {} }
- Component { id: regionPage; RegionPage {} }
Component { id: onlineAccountsPage; OnlineAccountsPage {} }
Component { id: healthPage; HealthPage {} }
Component { id: manualPage; ManualPage {} }
diff --git a/config/dot/quickshell/modules/settings/SyncPage.qml b/config/dot/quickshell/modules/settings/SyncPage.qml
index b36b7ca..07565d9 100644
--- a/config/dot/quickshell/modules/settings/SyncPage.qml
+++ b/config/dot/quickshell/modules/settings/SyncPage.qml
@@ -8,6 +8,12 @@
// snapshots one tab over in System › Snapshots, which put the whole filesystem
// back -- the card says so, because the two used to share the word "Snapshots"
// and nothing distinguished them.
+//
+// Everything on this page that cannot be undone asks twice. Restoring a backup
+// and restoring defaults both used to happen on a single press, which is the
+// one interaction on this page nobody can take back: the two-stage confirm is
+// the same one Snapshots and Privacy use, where the first press only arms the
+// second and the second is the one wearing the danger colour.
import QtQuick
import qs.config
@@ -16,8 +22,35 @@ import qs.services
SettingsPage {
id: root
+ objectName: "sync"
+
title: "Sync & Backup"
- lede: "Carry your settings to another machine, keep copies of them, or start over."
+ lede: "Carry this desktop with you, and come back from anything."
+
+ // "restore:", "delete:", or "reset". One at a time: two armed
+ // destructive actions on screen at once is how the wrong one gets pressed.
+ property string confirming: ""
+
+ function arm(token: string): void {
+ root.confirming = root.confirming === token ? "" : token;
+ }
+
+ // Diff values come from a JSON file somebody may have edited by hand, so
+ // they are whatever they are: booleans, numbers, objects, missing. Long
+ // ones are cut rather than allowed to push the row off the card.
+ function shortValue(value: var): string {
+ if (value === null || value === undefined)
+ return "not set";
+ const text = typeof value === "object" ? JSON.stringify(value) : String(value);
+ return text.length > 44 ? text.slice(0, 43) + "…" : text;
+ }
+
+ // The helper already caps what it hands over and reports the true total
+ // separately; this caps again for the rows, so the count in the copy is
+ // the real one either way.
+ readonly property int previewLimit: 8
+ readonly property var shownChanges:
+ (SettingsSync.changes ?? []).slice(0, root.previewLimit)
// Distinct from the backups below, which put THIS machine back as it was.
// This carries settings to a different one, and deliberately leaves behind
@@ -41,32 +74,122 @@ SettingsPage {
ActionRow {
label: "See what an import would change"
detail: SettingsSync.previewed
- ? SettingsSync.changes.length + " would change, "
+ ? SettingsSync.changeCount + " would change, "
+ SettingsSync.skipped.length + " skipped"
: "Reads " + SettingsSync.defaultPath + " without applying anything"
action: "Preview"
enabled: !SettingsSync.busy
+ divider: !SettingsSync.previewed
onTriggered: SettingsSync.preview(SettingsSync.defaultPath)
}
+ // Key by key, old value and new. "14 would change" is a number nobody
+ // can act on; this is the same information in the form somebody can
+ // actually read before agreeing to it.
+ Item {
+ width: parent.width
+ visible: SettingsSync.previewed && SettingsSync.changeCount > 0
+ implicitHeight: visible ? diffFrame.implicitHeight + 18 : 0
+
+ Rectangle {
+ id: diffFrame
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.topMargin: 4
+ implicitHeight: diffRows.implicitHeight + 20
+ radius: Theme.cardRadius
+ color: Theme.alpha(Theme.bgDark, 0.55)
+ border.width: 1
+ border.color: Theme.alpha(Theme.fg, 0.07)
+
+ Column {
+ id: diffRows
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.margins: 10
+ spacing: 0
+
+ Repeater {
+ model: root.shownChanges
+
+ Column {
+ required property var modelData
+
+ width: parent.width
+ spacing: 0
+ bottomPadding: 6
+
+ Text {
+ width: parent.width
+ text: "− " + String(modelData.key ?? "")
+ + " " + root.shortValue(modelData.from)
+ color: Theme.danger
+ font.family: Theme.fontMono
+ font.features: Theme.tabularFigures
+ font.pixelSize: Theme.fontSizeSmall
+ elide: Text.ElideRight
+ }
+
+ Text {
+ width: parent.width
+ text: "+ " + String(modelData.key ?? "")
+ + " " + root.shortValue(modelData.to)
+ color: Theme.ok
+ font.family: Theme.fontMono
+ font.features: Theme.tabularFigures
+ font.pixelSize: Theme.fontSizeSmall
+ elide: Text.ElideRight
+ }
+ }
+ }
+
+ Text {
+ width: parent.width
+ visible: SettingsSync.changeCount > root.shownChanges.length
+ text: "· " + (SettingsSync.changeCount - root.shownChanges.length)
+ + " more would change"
+ color: Theme.fgMuted
+ font.family: Theme.fontMono
+ font.pixelSize: Theme.fontSizeSmall
+ }
+
+ Text {
+ width: parent.width
+ visible: SettingsSync.skipped.length > 0
+ text: "· " + SettingsSync.skipped.length
+ + " skipped — they describe this machine rather than your taste"
+ color: Theme.fgMuted
+ font.family: Theme.fontMono
+ font.pixelSize: Theme.fontSizeSmall
+ elide: Text.ElideRight
+ }
+ }
+ }
+ }
+
// Only offered once a preview has said what it would do. Importing
// settings sight unseen is how somebody ends up wondering why their
// desktop changed.
ActionRow {
- visible: SettingsSync.previewed && SettingsSync.changes.length > 0
- label: "Apply those " + SettingsSync.changes.length + " changes"
+ visible: SettingsSync.previewed && SettingsSync.changeCount > 0
+ label: "Apply those " + SettingsSync.changeCount + " changes"
detail: "Settings the file does not mention are left alone"
action: "Import"
enabled: !SettingsSync.busy
+ divider: false
onTriggered: SettingsSync.importFrom(SettingsSync.defaultPath)
}
Repeater {
model: SettingsSync.previewed ? SettingsSync.skipped : []
- delegate: TextRow {
+ TextRow {
required property var modelData
- width: parent.width
+
label: String(modelData.key ?? "")
detail: "Skipped: " + String(modelData.reason ?? "")
value: ""
@@ -84,51 +207,187 @@ SettingsPage {
}
}
+ // ── Backups ──────────────────────────────────────────────────────────────
+
SettingsCard {
title: "Settings backups"
subtitle: SettingsBackup.lastError !== ""
? SettingsBackup.lastError
: "Copies of your preferences, not of the filesystem — System › Snapshots keeps the btrfs ones. Your whole desktop configuration is a single file, so a backup is a copy of it. Restoring also backs up what it replaces, so it is itself undoable."
- ActionRow {
+ // Naming one is optional and worth it. "Before the displays
+ // experiment" is findable a week later; a timestamp is not.
+ FieldActionRow {
+ id: nameField
+
label: "Back up current settings"
detail: SettingsBackup.snapshots.length === 0
? "No backups yet"
- : SettingsBackup.snapshots.length + (SettingsBackup.snapshots.length === 1 ? " backup kept" : " backups kept") + ", newest first"
- action: "Back up now"
+ : SettingsBackup.snapshots.length
+ + (SettingsBackup.snapshots.length === 1 ? " backup kept" : " backups kept")
+ + ", newest first"
+ placeholder: "Name (optional)"
+ action: SettingsBackup.busy ? "Working…" : "Back up"
enabled: !SettingsBackup.busy
divider: SettingsBackup.snapshots.length > 0
- onTriggered: SettingsBackup.save()
+ onSubmitted: value => {
+ // An unnamed backup is the plain save the automatic path uses;
+ // naming one goes through create, which is the verb that knows
+ // how to sanitise a name.
+ if (value === "")
+ SettingsBackup.save();
+ else
+ SettingsBackup.create(value);
+ nameField.clear();
+ }
}
Repeater {
id: snapshotRows
model: SettingsBackup.snapshots
- ActionRow {
+ SettingRow {
+ id: backupRow
+
required property var modelData
required property int index
- label: modelData.when
- detail: modelData.keys + " settings"
- action: "Restore"
- enabled: !SettingsBackup.busy
- divider: index < snapshotRows.count - 1
- onTriggered: SettingsBackup.restore(modelData.name)
+ readonly property string backupName: String(backupRow.modelData.name ?? "")
+ readonly property bool confirmingRestore:
+ root.confirming === "restore:" + backupRow.backupName
+ readonly property bool confirmingDelete:
+ root.confirming === "delete:" + backupRow.backupName
+
+ // The helper names a file by its timestamp with whatever the
+ // person called it appended, so what is left after the
+ // timestamp is the name they gave it. An unnamed backup falls
+ // back to when it was taken rather than showing a filename.
+ readonly property string displayName: {
+ const given = String(backupRow.modelData.label ?? "");
+ if (given !== "")
+ return given;
+ const stem = backupRow.backupName.replace(/\.json$/, "");
+ const named = stem
+ .replace(/^[0-9][0-9T:_.\-]*/, "")
+ .replace(/^[-_]+/, "")
+ .replace(/[-_]+/g, " ")
+ .trim();
+ return named === "" ? String(backupRow.modelData.when ?? stem) : named;
+ }
+
+ width: parent.width
+ label: backupRow.displayName
+ detail: {
+ const when = String(backupRow.modelData.when ?? "");
+ const parts = when === backupRow.displayName ? [] : [when];
+ if (backupRow.modelData.keys)
+ parts.push(backupRow.modelData.keys + " settings");
+ const bytes = Number(backupRow.modelData.bytes ?? 0);
+ if (bytes > 0)
+ parts.push(root.formatBytes(bytes));
+ if (backupRow.confirmingRestore)
+ parts.push("Restoring replaces every setting on this machine, and reloads the desktop");
+ if (backupRow.confirmingDelete)
+ parts.push("Deleting a backup cannot be undone");
+ return parts.filter(part => part !== "").join(" · ");
+ }
+ controlWidth: 250
+ divider: backupRow.index < snapshotRows.count - 1
+
+ Row {
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 8
+
+ SettingsButton {
+ visible: !backupRow.confirmingDelete
+ text: backupRow.confirmingRestore ? "Cancel" : "Restore…"
+ enabled: !SettingsBackup.busy
+ onClicked: root.arm("restore:" + backupRow.backupName)
+ }
+
+ SettingsButton {
+ visible: backupRow.confirmingRestore
+ text: "Restore it"
+ tone: "danger"
+ enabled: !SettingsBackup.busy
+ onClicked: {
+ root.confirming = "";
+ SettingsBackup.restore(backupRow.backupName);
+ }
+ }
+
+ SettingsButton {
+ visible: !backupRow.confirmingRestore
+ text: backupRow.confirmingDelete ? "Cancel" : "Delete…"
+ enabled: !SettingsBackup.busy
+ onClicked: root.arm("delete:" + backupRow.backupName)
+ }
+
+ SettingsButton {
+ visible: backupRow.confirmingDelete
+ text: "Delete it"
+ tone: "danger"
+ enabled: !SettingsBackup.busy
+ onClicked: {
+ root.confirming = "";
+ SettingsBackup.deleteBackup(backupRow.backupName);
+ }
+ }
+ }
}
}
}
+ // ── Reset ────────────────────────────────────────────────────────────────
+
SettingsCard {
title: "Reset"
- subtitle: "Restores the appearance, dock, clock, focus, and display policy, and clears your Home accessory arrangement. Pinned applications, files, and paired devices are not changed."
+ subtitle: "Restores the appearance and theme, dock, clock, focus, and display policy, and clears your Home accessory arrangement. Pinned applications, files, and paired devices are not changed."
+
+ SettingRow {
+ id: resetRow
+
+ readonly property bool armed: root.confirming === "reset"
- ActionRow {
label: "Restore defaults"
- detail: "Applies immediately, including to the compositor"
- action: "Restore defaults"
+ detail: resetRow.armed
+ ? "Every setting goes back to how Panama ships, immediately and including the compositor. A backup is taken first, so this is itself undoable from the card above."
+ : "Applies immediately, including to the compositor. A backup is taken first."
+ controlWidth: 240
divider: false
- onTriggered: SystemSettings.restoreDefaults()
+
+ Row {
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 8
+
+ SettingsButton {
+ text: resetRow.armed ? "Cancel" : "Reset…"
+ onClicked: root.arm("reset")
+ }
+
+ SettingsButton {
+ visible: resetRow.armed
+ text: "Restore defaults"
+ tone: "danger"
+ onClicked: {
+ root.confirming = "";
+ SystemSettings.restoreDefaults();
+ }
+ }
+ }
}
}
+
+ // Decimal units, the way both the Storage page and About report sizes.
+ function formatBytes(bytes: real): string {
+ if (!(bytes > 0))
+ return "";
+ if (bytes >= 1e6)
+ return (bytes / 1e6).toFixed(1) + " MB";
+ if (bytes >= 1e3)
+ return Math.round(bytes / 1e3) + " KB";
+ return Math.round(bytes) + " B";
+ }
}
diff --git a/config/dot/quickshell/modules/settings/UpdatesPage.qml b/config/dot/quickshell/modules/settings/UpdatesPage.qml
index 509b0cd..0282d4e 100644
--- a/config/dot/quickshell/modules/settings/UpdatesPage.qml
+++ b/config/dot/quickshell/modules/settings/UpdatesPage.qml
@@ -9,6 +9,14 @@
// happen, so the Snapshots page shows "before 32 package updates" rather than a
// timestamp. That is the thing neither macOS nor Windows does cleanly, and it
// is nearly free here.
+//
+// A package row can say what it changes. "1 update available" tells you nothing
+// about whether to install it now or after the meeting; the changelog does, and
+// it is fetched per package on request rather than for a list nobody read.
+//
+// The automatic switches sit in their own card. The one that downloads packages
+// used to live at the bottom of Firmware, which is where somebody put it once
+// and nobody could find it since.
import Quickshell
import QtQuick
@@ -20,9 +28,16 @@ SettingsPage {
objectName: "updates"
title: "Software Update"
- lede: "Packages, applications, and firmware, each from the place it actually comes from."
+ lede: root.headline
- property string expandedSource: ""
+ // Rows are shown a screenful at a time. A dnf update is routinely thirty
+ // packages, and a page that opens on thirty rows is a page nobody reads.
+ readonly property int previewCount: 6
+
+ property bool showAllPackages: false
+
+ // "dnf:name" or "flatpak:id" -- the one row whose changelog is open.
+ property string expandedPackage: ""
Component.onCompleted: {
Updates.refresh();
@@ -32,6 +47,50 @@ SettingsPage {
Updates.check();
}
+ function joined(parts: var): string {
+ return parts.filter(part => String(part ?? "") !== "").join(" · ");
+ }
+
+ readonly property string headline: {
+ if (Updates.checking)
+ return "Checking every source…";
+ const security = Updates.securityCount > 0
+ ? Updates.securityCount + " carrying a security advisory"
+ : (Updates.total > 0 ? "nothing security-critical" : "");
+ return root.joined([
+ Updates.summary(), security,
+ Updates.lastCheckedText().replace("Checked", "checked")
+ ]) + ".";
+ }
+
+ readonly property var packages: Updates.dnf?.packages ?? []
+ readonly property var shownPackages: root.showAllPackages
+ ? root.packages
+ : root.packages.slice(0, root.previewCount)
+ readonly property var applications: Updates.flatpak?.applications ?? []
+ readonly property var firmwareDevices: Updates.firmware?.devices ?? []
+
+ function toggleChangelog(source: string, name: string): void {
+ const key = source + ":" + name;
+ root.expandedPackage = root.expandedPackage === key ? "" : key;
+ }
+
+ // Asking for a changelog is what starts fetching one -- the page requests
+ // it by rendering it, and Updates keeps the answer for the life of the
+ // shell. Null means the fetch is still out.
+ function changelogText(source: string, name: string): string {
+ const record = Updates.changelogFor(source, name);
+ if (record === null || record === undefined)
+ return "Reading the changelog…";
+ if (String(record.text ?? "") !== "")
+ return String(record.text);
+ return String(record.error ?? "") !== ""
+ ? String(record.error)
+ : "No changelog published for this update.";
+ }
+
+ // ── What just happened ───────────────────────────────────────────────────
+
TextRow {
visible: Updates.lastError !== ""
label: "Updates need attention"
@@ -54,43 +113,7 @@ SettingsPage {
divider: false
}
- // ── The headline ─────────────────────────────────────────────────────────
-
SettingsCard {
- Column {
- width: parent.width
- spacing: 10
-
- Row {
- width: parent.width
- spacing: 12
-
- Text {
- text: Updates.checking ? "Checking…" : Updates.summary()
- color: Theme.fg
- font.family: Theme.fontFamily
- font.pixelSize: Theme.fontSizeTitle
- font.weight: Font.DemiBold
- }
-
- Text {
- anchors.verticalCenter: parent.verticalCenter
- visible: Updates.securityCount > 0
- text: Updates.securityCount + " carry a security advisory"
- color: Theme.warn
- font.family: Theme.fontFamily
- font.pixelSize: Theme.fontSize
- }
- }
-
- Text {
- text: Updates.lastCheckedText()
- color: Theme.fgMuted
- font.family: Theme.fontFamily
- font.pixelSize: Theme.fontSizeSmall
- }
- }
-
ActionRow {
label: "Check for updates"
detail: "Refreshes package metadata, application remotes, and firmware. Takes a few seconds."
@@ -113,55 +136,90 @@ SettingsPage {
}
}
- // ── One card per source ──────────────────────────────────────────────────
+ // ── System packages ──────────────────────────────────────────────────────
SettingsCard {
title: "System packages"
subtitle: Updates.dnf?.available === false
? "dnf is not available on this machine."
- : (Number(Updates.dnf?.count ?? 0) === 0
+ : (root.packages.length === 0
? "Nothing waiting."
- : Updates.dnf.count + " package"
- + (Updates.dnf.count === 1 ? "" : "s") + " ready to install"
- + (Updates.securityCount > 0
- ? ", " + Updates.securityCount + " carrying an advisory" : ""))
-
- ActionRow {
- visible: Number(Updates.dnf?.count ?? 0) > 0
- label: "Install package updates"
- detail: "Asks for your password, and takes a snapshot first so this can be undone"
- action: Updates.applying ? "Working…" : "Install"
- enabled: !Updates.busy
- onTriggered: Updates.apply("dnf")
- }
-
- ActionRow {
- visible: Number(Updates.dnf?.count ?? 0) > 0
- label: "What would change"
- detail: root.expandedSource === "dnf"
- ? "Every package that would be replaced"
- : Updates.dnf.count + " packages"
- action: root.expandedSource === "dnf" ? "Hide" : "Show"
- divider: root.expandedSource === "dnf"
- onTriggered: root.expandedSource = root.expandedSource === "dnf" ? "" : "dnf"
- }
+ : root.joined([
+ root.packages.length + " ready to install",
+ Updates.securityCount > 0
+ ? Updates.securityCount + " carrying an advisory" : "",
+ Updates.sourceDownloadSize("dnf") === ""
+ ? "" : Updates.sourceDownloadSize("dnf") + " to download"
+ ]))
Repeater {
- model: root.expandedSource === "dnf" ? (Updates.dnf?.packages ?? []) : []
+ id: packageRows
+ model: root.shownPackages
+
+ Column {
+ id: packageRow
- delegate: TextRow {
required property var modelData
required property int index
+
+ readonly property string name: String(packageRow.modelData.name ?? "")
+ readonly property bool open: root.expandedPackage === "dnf:" + packageRow.name
+
width: parent.width
- label: String(modelData.name ?? "")
- detail: String(modelData.repository ?? "")
- value: String(modelData.version ?? "")
- divider: index < (Updates.dnf?.packages ?? []).length - 1
+
+ ActionRow {
+ width: parent.width
+ label: packageRow.name
+ detail: root.joined([
+ String(packageRow.modelData.version ?? ""),
+ String(packageRow.modelData.repository ?? "") === ""
+ ? "" : "from " + packageRow.modelData.repository
+ ])
+ action: packageRow.open ? "Hide" : "Changelog"
+ divider: !packageRow.open && packageRow.index < packageRows.count - 1
+ onTriggered: root.toggleChangelog("dnf", packageRow.name)
+ }
+
+ Text {
+ width: parent.width
+ visible: packageRow.open
+ text: root.changelogText("dnf", packageRow.name)
+ color: Theme.fgDim
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSizeSmall
+ wrapMode: Text.WordWrap
+ lineHeight: 1.3
+ topPadding: 2
+ bottomPadding: 14
+ }
}
}
+ ActionRow {
+ visible: root.packages.length > root.previewCount
+ label: root.showAllPackages
+ ? "Showing all " + root.packages.length + " packages"
+ : (root.packages.length - root.previewCount) + " more waiting"
+ detail: root.showAllPackages ? "" : "Each one can say what it changes"
+ action: root.showAllPackages ? "Show fewer" : "Show all"
+ onTriggered: {
+ root.expandedPackage = "";
+ root.showAllPackages = !root.showAllPackages;
+ }
+ }
+
+ ActionRow {
+ visible: root.packages.length > 0
+ label: "Install package updates"
+ detail: "Asks for your password, and takes a snapshot first — undo lives in System › Snapshots"
+ action: Updates.applying ? "Working…" : "Install updates"
+ enabled: !Updates.busy
+ divider: false
+ onTriggered: Updates.apply("dnf")
+ }
+
TextRow {
- visible: Number(Updates.dnf?.count ?? 0) === 0 && Updates.everChecked
+ visible: root.packages.length === 0 && Updates.everChecked
label: "Packages are current"
detail: "Nothing from the system repositories is waiting"
value: ""
@@ -169,36 +227,31 @@ SettingsPage {
}
}
+ // ── Automatic ────────────────────────────────────────────────────────────
+ //
+ // Both switches, in the one card about the same idea. The package one used
+ // to sit at the bottom of Firmware.
+
SettingsCard {
- title: "Applications"
- subtitle: Updates.flatpak?.available === false
- ? "Flatpak is not installed."
- : (Number(Updates.flatpak?.count ?? 0) === 0
- ? "Nothing waiting."
- : Updates.flatpak.count + " application"
- + (Updates.flatpak.count === 1 ? "" : "s") + " ready to update")
+ title: "Automatic"
- Repeater {
- model: Updates.flatpak?.applications ?? []
-
- delegate: TextRow {
- required property var modelData
- required property int index
- width: parent.width
- label: String(modelData.id ?? "")
- detail: "Flatpak"
- value: String(modelData.version ?? "")
- divider: true
- }
+ SwitchRow {
+ visible: Updates.automatic?.dnfAutomaticAvailable === true
+ label: "Download package updates automatically"
+ detail: "Fetches them in the background each morning so installing is quick. It does not install them: a machine that updates packages unattended can reboot into a kernel nobody chose."
+ checked: Updates.automatic?.dnfAutomaticEnabled === true
+ enabled: !Updates.busy
+ onToggled: value => Updates.setAutomaticDnf(value)
}
- ActionRow {
- visible: Number(Updates.flatpak?.count ?? 0) > 0
- label: "Update applications"
- detail: "Needs no password: these are installed for your account"
- action: Updates.applying ? "Working…" : "Update"
- enabled: !Updates.busy
- onTriggered: Updates.apply("flatpak")
+ // Offered only when the machinery exists. When it does not, this says
+ // so rather than showing a switch that could not do anything --
+ // installing software is not a settings action.
+ TextRow {
+ visible: Updates.automatic?.dnfAutomaticAvailable === false
+ label: "Download package updates automatically"
+ detail: "Not set up. dnf-automatic is not installed, and Settings does not install software."
+ value: "Off"
}
SwitchRow {
@@ -211,32 +264,122 @@ SettingsPage {
}
}
+ // ── Applications & firmware ──────────────────────────────────────────────
+
SettingsCard {
- title: "Firmware"
- subtitle: Updates.firmware?.available === false
- ? "Firmware updating is not available on this machine."
- : (Number(Updates.firmware?.count ?? 0) === 0
- ? "No firmware updates are offered for this hardware."
- : Updates.firmware.count + " device"
- + (Updates.firmware.count === 1 ? "" : "s") + " have firmware available")
+ title: "Applications & firmware"
+ TextRow {
+ visible: Updates.flatpak?.available === false
+ label: "Flatpak"
+ detail: "Flatpak is not installed."
+ value: "Unavailable"
+ }
+
+ TextRow {
+ visible: Updates.flatpak?.available !== false && root.applications.length === 0
+ label: "Flatpak"
+ detail: "Everything current — each application gets its own row when an update appears"
+ value: "Current"
+ }
+
+ // Per application, because they update independently and one of them
+ // being 400 MB is a reason to do the other three now and that one
+ // later.
Repeater {
- model: Updates.firmware?.devices ?? []
+ id: applicationRows
+ model: root.applications
+
+ Column {
+ id: applicationRow
- delegate: TextRow {
required property var modelData
required property int index
+
+ readonly property string appId: String(applicationRow.modelData.id ?? "")
+ readonly property bool open: root.expandedPackage === "flatpak:" + applicationRow.appId
+
width: parent.width
- label: String(modelData.name ?? "")
- detail: String(modelData.version ?? "") + " → " + String(modelData.target ?? "")
- + (modelData.needsReboot ? " · installs on restart" : "")
- value: ""
- divider: true
+
+ ActionRow {
+ width: parent.width
+ label: applicationRow.appId
+ detail: root.joined([
+ String(applicationRow.modelData.version ?? ""), "Flatpak",
+ Updates.formatBytes(Number(applicationRow.modelData.downloadBytes ?? 0))
+ ])
+ action: Updates.applying ? "Working…" : "Update"
+ enabled: !Updates.busy
+ divider: false
+ onTriggered: Updates.applyFlatpakApp(applicationRow.appId)
+ }
+
+ ActionRow {
+ width: parent.width
+ label: "What changed"
+ detail: applicationRow.open
+ ? ""
+ : "Release notes, when the application publishes them"
+ action: applicationRow.open ? "Hide" : "Changelog"
+ divider: !applicationRow.open
+ onTriggered: root.toggleChangelog("flatpak", applicationRow.appId)
+ }
+
+ Text {
+ width: parent.width
+ visible: applicationRow.open
+ text: root.changelogText("flatpak", applicationRow.appId)
+ color: Theme.fgDim
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSizeSmall
+ wrapMode: Text.WordWrap
+ lineHeight: 1.3
+ topPadding: 2
+ bottomPadding: 14
+ }
}
}
ActionRow {
- visible: Number(Updates.firmware?.count ?? 0) > 0
+ visible: root.applications.length > 1
+ label: "Update every application"
+ detail: "Needs no password: these are installed for your account"
+ action: Updates.applying ? "Working…" : "Update all"
+ enabled: !Updates.busy
+ onTriggered: Updates.apply("flatpak")
+ }
+
+ TextRow {
+ visible: Updates.firmware?.available === false
+ label: "Firmware"
+ detail: "Firmware updating is not available on this machine."
+ value: "Unavailable"
+ divider: false
+ }
+
+ TextRow {
+ visible: Updates.firmware?.available !== false && root.firmwareDevices.length === 0
+ label: "Firmware"
+ detail: "No firmware updates are offered for this hardware"
+ value: "Current"
+ divider: false
+ }
+
+ Repeater {
+ model: root.firmwareDevices
+
+ TextRow {
+ required property var modelData
+
+ label: String(modelData.name ?? "")
+ detail: String(modelData.version ?? "") + " → " + String(modelData.target ?? "")
+ + (modelData.needsReboot ? " · installs on restart" : "")
+ value: ""
+ }
+ }
+
+ ActionRow {
+ visible: root.firmwareDevices.length > 0
label: "Install firmware"
detail: "Some devices only finish updating after a restart"
action: Updates.applying ? "Working…" : "Install"
@@ -244,27 +387,6 @@ SettingsPage {
divider: false
onTriggered: Updates.apply("firmware")
}
-
- // Offered only when the machinery exists. When it does not, this says
- // so rather than showing a switch that could not do anything --
- // installing software is not a settings action.
- SwitchRow {
- visible: Updates.automatic?.dnfAutomaticAvailable === true
- label: "Download package updates automatically"
- detail: "Fetches them in the background each morning so installing is quick. It does not install them: a machine that updates packages unattended can reboot into a kernel nobody chose."
- checked: Updates.automatic?.dnfAutomaticEnabled === true
- enabled: !Updates.busy
- divider: false
- onToggled: value => Updates.setAutomaticDnf(value)
- }
-
- TextRow {
- visible: Updates.automatic?.dnfAutomaticAvailable === false
- label: "Automatic package updates"
- detail: "Not set up. dnf-automatic is not installed, and Settings does not install software."
- value: "Off"
- divider: false
- }
}
// Worth showing because automatic updates leave no other trace. Something
@@ -288,10 +410,10 @@ SettingsPage {
Repeater {
model: Updates.history
- delegate: TextRow {
+ TextRow {
required property var modelData
required property int index
- width: parent.width
+
label: String(modelData.summary ?? "")
detail: Updates.agoText(Number(modelData.at ?? 0))
value: Updates.describeHistory(modelData)
diff --git a/config/dot/quickshell/modules/settings/qmldir b/config/dot/quickshell/modules/settings/qmldir
index a7d9313..a48fdf9 100644
--- a/config/dot/quickshell/modules/settings/qmldir
+++ b/config/dot/quickshell/modules/settings/qmldir
@@ -114,7 +114,6 @@ 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
TextFieldRow 1.0 TextFieldRow.qml
@@ -135,3 +134,5 @@ FingerprintEnrollPanel 1.0 FingerprintEnrollPanel.qml
OnlineAccountRow 1.0 OnlineAccountRow.qml
IdleTimeline 1.0 IdleTimeline.qml
PowerProfileTiles 1.0 PowerProfileTiles.qml
+FieldActionRow 1.0 FieldActionRow.qml
+ManualChapters 1.0 ManualChapters.qml
diff --git a/config/dot/quickshell/scripts/panama-about b/config/dot/quickshell/scripts/panama-about
index aeeed0b..8265bc8 100755
--- a/config/dot/quickshell/scripts/panama-about
+++ b/config/dot/quickshell/scripts/panama-about
@@ -3,10 +3,11 @@
# What this machine is, as JSON: {label, value} pairs in display order.
#
# GNOME's About panel answers "what am I running on" in one screen, and
-# fastfetch answers it in more detail; this covers both -- model, OS, kernel,
-# uptime, package counts, shell, resolution, processor, memory, swap, disk and
-# locale. Rows are ordered roughly the way fastfetch presents them: what the
-# system is, then what is installed on it, then the hardware underneath.
+# fastfetch answers it in more detail; this covers both -- model, OS, Panama's
+# own revision, firmware, Secure Boot, kernel, uptime, package counts, shell,
+# resolution, processor, memory, swap, disk and locale. Rows are ordered
+# roughly the way fastfetch presents them: what the system is, then what is
+# installed on it, then the hardware underneath.
#
# Graphics is deliberately absent: GraphicsDevices already enumerates GPUs for
# the vitals readout, and naming them again here would be a second source of
@@ -43,6 +44,20 @@ dmi() {
printf '%s' "$value"
}
+# Which Panama this is, from the checkout it is running out of. A version
+# string restated in a file would be a second source of truth that goes stale
+# the moment somebody forgets to bump it; the commit cannot. Absent outside a
+# git checkout -- an installed copy without .git legitimately has no answer.
+# The script's own directory, not a counted number of "..": this tree is
+# symlinked into ~/.config/quickshell, and git discovers the checkout by
+# walking up from wherever it is told to start.
+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+if command -v git >/dev/null 2>&1 && git -C "$repo_root" rev-parse --git-dir >/dev/null 2>&1; then
+ panama_rev="$(git -C "$repo_root" describe --tags --always --dirty 2>/dev/null)"
+ panama_age="$(git -C "$repo_root" log -1 --format=%cr 2>/dev/null)"
+ emit "Panama" "${panama_rev}${panama_age:+ · $panama_age}"
+fi
+
vendor="$(dmi sys_vendor)"
product="$(dmi product_name)"
if [[ -n "$vendor" && -n "$product" ]]; then
@@ -51,6 +66,29 @@ else
emit "Model" "${product:-$vendor}"
fi
+# The BIOS/UEFI version, which is the one firmware fact a person is ever asked
+# for. DMI first because it is a free file read; bootctl only as the fallback,
+# since it reports "n/a" on this class of machine and shelling out for that
+# would be a slower way to learn nothing.
+firmware="$(dmi bios_version)"
+if [[ -z "$firmware" ]] && command -v bootctl >/dev/null 2>&1; then
+ firmware="$(bootctl status 2>/dev/null \
+ | awk -F': *' '/^ *Firmware:/ { print $2; exit }' \
+ | sed 's/ *(n\/a)$//')"
+ [[ "$firmware" == "n/a"* ]] && firmware=""
+fi
+bios_date="$(dmi bios_date)"
+emit "Firmware" "${firmware:+${firmware}${bios_date:+ · $bios_date}}"
+
+# Absent-tolerant on purpose: mokutil is not installed everywhere, and a
+# machine that cannot answer "is Secure Boot on" should not claim it is off.
+if command -v mokutil >/dev/null 2>&1; then
+ case "$(mokutil --sb-state 2>/dev/null)" in
+ *"SecureBoot enabled"*) emit "Secure Boot" "Enabled" ;;
+ *"SecureBoot disabled"*) emit "Secure Boot" "Disabled" ;;
+ esac
+fi
+
emit "Hostname" "$(hostnamectl hostname 2>/dev/null || hostname 2>/dev/null)"
emit "Kernel" "$(uname -r 2>/dev/null)"
diff --git a/config/dot/quickshell/scripts/panama-doctor b/config/dot/quickshell/scripts/panama-doctor
index b818587..a37723c 100755
--- a/config/dot/quickshell/scripts/panama-doctor
+++ b/config/dot/quickshell/scripts/panama-doctor
@@ -200,6 +200,10 @@ CHECK_TITLES = {
"integration.bluebubbles": "BlueBubbles",
"integration.home-assistant": "Home Assistant",
"integration.calendar": "Calendar",
+ # Every id in CHECK_ORDER needs an entry here: unavailable_check() looks the
+ # title up by id, so a missing one turned a probe that merely timed out into
+ # a KeyError that took the whole scan down with it.
+ "panama.updates": "Software updates",
"panama.runtime-links": "Panama runtime links",
"panama.vicinae-commands": "Panama commands",
"panama.selected-terminal": "Selected terminal",
@@ -294,6 +298,14 @@ def check_json(check: Check) -> dict[str, object]:
result: dict[str, object] = {"id": check.id, "group": check.group, "title": check.title, "status": check.status, "detail": check.detail}
if check.action is not None:
result["action"] = action_json(check.action)
+ # The exact command a repair would run, so the page can show it before
+ # anybody presses the button. Taken from REPAIR_COMMANDS rather than
+ # written out again in the UI -- a second copy is a copy that can be wrong,
+ # and the whole point of showing it is that it is what actually happens.
+ # Only the authored-command repairs have one; the three in-process repairs
+ # are Python, not a command line, and claiming otherwise would be a lie.
+ if check.id in REPAIR_COMMANDS:
+ result["repairCommand"] = " ".join(REPAIR_COMMANDS[check.id])
return result
@@ -659,14 +671,14 @@ def check_updates(config: DoctorConfig) -> Check:
if newest != running:
return Check("panama.updates", "panama-tools", "Software updates", "warning",
f"A newer kernel is installed than the one running ({running} → {newest}). Restart to use it.",
- action=Action("open", "Open Software Update"))
+ action=Action("open", "Open Software Update", target="updates"))
try:
payload = json.loads(cache.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return Check("panama.updates", "panama-tools", "Software updates", "unconfigured",
"Updates have not been checked yet.",
- action=Action("open", "Open Software Update"))
+ action=Action("open", "Open Software Update", target="updates"))
checked_at = int(payload.get("checkedAt", 0))
age_days = (time.time() - checked_at) / 86400 if checked_at else 999
@@ -677,11 +689,11 @@ def check_updates(config: DoctorConfig) -> Check:
if security > 0:
return Check("panama.updates", "panama-tools", "Software updates", "warning",
f"{security} pending update{'' if security == 1 else 's'} carry a security advisory.",
- action=Action("open", "Open Software Update"))
+ action=Action("open", "Open Software Update", target="updates"))
if age_days > 7:
return Check("panama.updates", "panama-tools", "Software updates", "unconfigured",
"Updates have not been checked in over a week.",
- action=Action("open", "Open Software Update"))
+ action=Action("open", "Open Software Update", target="updates"))
if total > 0:
return Check("panama.updates", "panama-tools", "Software updates", "ok",
f"{total} update{'' if total == 1 else 's'} available, none carrying a security advisory.")
@@ -799,14 +811,18 @@ def unavailable_versions() -> list[dict[str, str]]:
return [{"id": name, "version": "unavailable"} for name in ("hyprland", "quickshell", "fedora", "panama")]
-def collect_checks(config: DoctorConfig) -> list[Check]:
- probes: dict[str, Callable[[], Check]] = {
+def probe_table(config: DoctorConfig) -> dict[str, Callable[[], Check]]:
+ return {
"desktop.hyprland": lambda: check_hyprland(config), "desktop.quickshell": lambda: check_quickshell(config), "desktop.notifications": lambda: check_notifications(config), "desktop.portals": lambda: check_portals(config), "desktop.portal-stability": lambda: check_portal_stability(config), "desktop.document-portal": lambda: check_document_portal(config),
"desktop.hyprpaper": lambda: service_check("desktop.hyprpaper", "Hyprpaper", "hyprpaper", config, Action("repair", "Restart Hyprpaper")), "desktop.hypridle": lambda: service_check("desktop.hypridle", "Hypridle", "hypridle", config, Action("repair", "Restart Hypridle")), "desktop.hyprlock": lambda: check_hyprlock(config), "desktop.vicinae": lambda: service_check("desktop.vicinae", "Vicinae", "vicinae", config, Action("repair", "Restart Vicinae")), "input.pipewire": lambda: service_check("input.pipewire", "PipeWire", "pipewire", config),
"input.clipboard": lambda: simple_ipc_check("input.clipboard", "Clipboard", "clipboard", config), "input.wallpaper": lambda: simple_ipc_check("input.wallpaper", "Wallpaper", "wallpaper", config), "input.video-wallpaper": lambda: check_video_wallpaper(config), "input.capture": lambda: simple_ipc_check("input.capture", "Capture", "capture", config), "input.ocr": lambda: executable_check("input.ocr", "OCR", "tesseract", config), "input.brightness": lambda: check_brightness(config),
"integration.nextcloud": lambda: check_nextcloud(config), "integration.rustdesk": lambda: check_rustdesk(config), "integration.kdeconnect": lambda: check_kdeconnect(config), "integration.bluebubbles": lambda: check_bluebubbles(config), "integration.home-assistant": lambda: check_home_assistant(config), "integration.calendar": lambda: check_calendar(config),
"panama.updates": lambda: check_updates(config), "panama.runtime-links": lambda: check_runtime_links(config), "panama.vicinae-commands": lambda: check_vicinae_commands(config), "panama.selected-terminal": lambda: executable_check("panama.selected-terminal", "Selected terminal", "kitty", config), "panama.selected-launcher": lambda: executable_check("panama.selected-launcher", "Selected launcher", "vicinae", config), "panama.processes": lambda: check_processes(config), "panama.caffeine": lambda: check_caffeine(config),
}
+
+
+def collect_checks(config: DoctorConfig) -> list[Check]:
+ probes = probe_table(config)
with ThreadPoolExecutor(max_workers=8) as executor:
futures = {check_id: executor.submit(probes[check_id]) for check_id in CHECK_ORDER}
checks: list[Check] = []
@@ -818,11 +834,7 @@ def collect_checks(config: DoctorConfig) -> list[Check]:
return checks
-def snapshot(config: DoctorConfig) -> dict[str, object]:
- try:
- checks = collect_checks(config)
- except Exception:
- checks = [unavailable_check(check_id) for check_id in CHECK_ORDER]
+def snapshot_of(config: DoctorConfig, checks: list[Check]) -> dict[str, object]:
counts = {status: sum(check.status == status for check in checks) for status in ("ok", "warning", "error", "unconfigured")}
overall: Literal["healthy", "warning", "error"] = "error" if counts["error"] else "warning" if counts["warning"] else "healthy"
session = "hyprland" if "hyprland" in os.environ.get("XDG_CURRENT_DESKTOP", "").casefold() else "other"
@@ -833,6 +845,30 @@ def snapshot(config: DoctorConfig) -> dict[str, object]:
return {"schemaVersion": 1, "generatedAt": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"), "summary": {"status": overall, "healthy": counts["ok"], "warnings": counts["warning"], "errors": counts["error"], "unconfigured": counts["unconfigured"]}, "context": {"session": session, "versions": versions}, "checks": [check_json(check) for check in checks]}
+def snapshot(config: DoctorConfig) -> dict[str, object]:
+ try:
+ checks = collect_checks(config)
+ except Exception:
+ checks = [unavailable_check(check_id) for check_id in CHECK_ORDER]
+ return snapshot_of(config, checks)
+
+
+def single_check(check_id: str, config: DoctorConfig) -> dict[str, object]:
+ """One probe, in the shape of a whole snapshot.
+
+ Re-checking a single row after a repair should not cost the other
+ twenty-nine probes. The reply is the same envelope a full scan produces --
+ same schema, same summary arithmetic, same check object -- so the caller
+ validates it with the code it already has, rather than growing a second
+ reader for a second shape that could drift from the first.
+ """
+ try:
+ checks = [probe_table(config)[check_id]()]
+ except Exception:
+ checks = [unavailable_check(check_id)]
+ return snapshot_of(config, checks)
+
+
def repair_authored_command(check_id: str, config: DoctorConfig) -> RepairResult:
command = REPAIR_COMMANDS[check_id]
if check_id == "desktop.quickshell":
@@ -1139,8 +1175,22 @@ def main(argv: list[str]) -> int:
output.add_argument("--json", action="store_true")
output.add_argument("--summary", action="store_true")
parser.add_argument("--repair", metavar="CHECK_ID")
+ parser.add_argument("verb", nargs="*", metavar="check CHECK_ID",
+ help="check CHECK_ID -- re-run one probe and print it as a snapshot")
args = parser.parse_args(argv)
+ if args.verb:
+ # An unknown id is refused rather than answered with an empty snapshot:
+ # a caller that asked for a check that does not exist has a bug, and a
+ # valid-looking reply with no rows in it would hide it.
+ if len(args.verb) != 2 or args.verb[0] != "check" or args.verb[1] not in CHECK_ORDER:
+ parser.error("usage: panama-doctor check CHECK_ID")
+ if args.repair is not None or args.summary:
+ parser.error("check takes no other output mode")
+ print(json.dumps(single_check(args.verb[1], config_from_environment()),
+ separators=(",", ":"), sort_keys=False))
+ return 0
+
if args.repair is not None:
if args.repair not in REPAIR_IDS or args.summary:
result = RepairResult(args.repair, False, 2, "This health check has no authored repair.")
diff --git a/config/dot/quickshell/scripts/panama-locale b/config/dot/quickshell/scripts/panama-locale
index cd8c8ee..0856474 100755
--- a/config/dot/quickshell/scripts/panama-locale
+++ b/config/dot/quickshell/scripts/panama-locale
@@ -2,9 +2,25 @@
# System locale, via localectl.
#
-# panama-locale list -> [{value, label, detail}]
-# panama-locale get -> the current LANG, e.g. en_US.UTF-8
+# panama-locale list -> [{value, label, detail}]
+# panama-locale get -> the current LANG, e.g. en_US.UTF-8
# panama-locale set
+# panama-locale categories -> the category names, one per line
+# panama-locale overrides -> {LC_TIME: "...", ...}, "" for none
+# panama-locale get -> the override, or "" for "match language"
+# panama-locale set
+#
+# The categories are the five that a person actually chooses independently of
+# their language: LC_TIME, LC_NUMERIC, LC_MONETARY, LC_MEASUREMENT, LC_PAPER.
+# Someone reading in English while writing dates and currency the way their
+# country does is the ordinary case, not an exotic one.
+#
+# An empty value means "match language", which is the ABSENCE of an override
+# rather than a value equal to LANG -- the two behave the same today and
+# diverge the moment the language changes. localectl replaces /etc/locale.conf
+# with exactly the assignments it is given, so unsetting one category means
+# re-issuing all the others. That is why cmd_set_category reads the current
+# file first: passing only the survivors is the only way to remove one.
#
# Locale codes are not names. "pt_BR.UTF-8" tells you what it means only if you
# already know, which defeats the point of a picker, so codes are resolved
@@ -24,11 +40,63 @@ set -uo pipefail
readonly ISO_LANG=/usr/share/iso-codes/json/iso_639-2.json
readonly ISO_COUNTRY=/usr/share/iso-codes/json/iso_3166-1.json
+readonly LOCALE_CONF=/etc/locale.conf
+readonly CATEGORIES=(LC_TIME LC_NUMERIC LC_MONETARY LC_MEASUREMENT LC_PAPER)
+
+is_category() {
+ local candidate="$1" name
+ for name in "${CATEGORIES[@]}"; do
+ [[ "$name" == "$candidate" ]] && return 0
+ done
+ return 1
+}
+
+# LANG plus every category override, as VAR=value lines. /etc/locale.conf is
+# what localectl writes and is world-readable, so it is read directly rather
+# than scraped out of `localectl status`, whose multi-variable output wraps
+# across continuation lines and has no stable machine form.
+current_assignments() {
+ [[ -r "$LOCALE_CONF" ]] || return 0
+ while IFS= read -r line; do
+ line="${line%%#*}"
+ [[ "$line" =~ ^[[:space:]]*(LANG|LANGUAGE|LC_[A-Z_]+)=(.*)$ ]] || continue
+ local name="${BASH_REMATCH[1]}" value="${BASH_REMATCH[2]}"
+ # locale.conf quotes values; localectl takes them bare.
+ value="${value%\"}"; value="${value#\"}"
+ value="${value%\'}"; value="${value#\'}"
+ [[ -n "$value" ]] && printf '%s=%s\n' "$name" "$value"
+ done <"$LOCALE_CONF"
+}
cmd_get() {
- localectl status 2>/dev/null \
- | awk -F'LANG=' '/System Locale:/ { print $2; exit }' \
- | tr -d '[:space:]'
+ local name="${1:-LANG}"
+ if [[ "$name" != "LANG" ]] && ! is_category "$name"; then
+ printf 'panama-locale: %s is not a category this manages\n' "$name" >&2
+ return 2
+ fi
+ local value
+ value="$(current_assignments | awk -F= -v want="$name" '$1 == want { print $2; exit }')"
+ # A machine with no /etc/locale.conf still has a LANG, and localectl is the
+ # one that knows it. Categories have no such fallback: absent there means
+ # absent, which is exactly "match language".
+ if [[ -z "$value" && "$name" == "LANG" ]]; then
+ value="$(localectl status 2>/dev/null \
+ | awk -F'LANG=' '/System Locale:/ { print $2; exit }' \
+ | tr -d '[:space:]')"
+ fi
+ printf '%s\n' "$value"
+}
+
+# Every category override in one answer: {"LC_TIME": "de_DE.UTF-8", ...} with
+# "" for the ones that match the language. Five separate `get` calls would be
+# five processes for one screenful of state.
+cmd_overrides() {
+ local assignments name
+ assignments="$(current_assignments)"
+ { for name in "${CATEGORIES[@]}"; do
+ printf '%s\t%s\n' "$name" \
+ "$(awk -F= -v want="$name" '$1 == want { print $2; exit }' <<<"$assignments")"
+ done; } | jq -Rn '[inputs | split("\t") | {key: .[0], value: (.[1] // "")}] | from_entries'
}
cmd_list() {
@@ -75,10 +143,11 @@ cmd_list() {
' <<<"$locales"
}
-cmd_set() {
- local locale="${1:-}"
- # Constrained rather than passed through: this reaches a privileged
- # command, and the set of legal locale names is narrow and well known.
+# A locale name that is both well formed and actually installed. Everything
+# reaching localectl goes through this: it is a privileged command, and the set
+# of legal locale names is narrow and well known.
+installed_locale() {
+ local locale="$1"
[[ "$locale" =~ ^[a-zA-Z0-9_@.-]+$ ]] || {
printf 'panama-locale: refusing a locale name with unexpected characters\n' >&2
return 2
@@ -87,12 +156,59 @@ cmd_set() {
printf 'panama-locale: %s is not an installed locale\n' "$locale" >&2
return 2
}
+}
+
+cmd_set() {
+ local locale="${1:-}"
+ installed_locale "$locale" || return 2
+ # LANG is set on its own rather than through the rewrite path: changing the
+ # language must not quietly drop category overrides somebody chose, and
+ # localectl merges a lone LANG= assignment into the existing file.
localectl set-locale "LANG=$locale"
}
+cmd_set_category() {
+ local name="${1:-}" locale="${2:-}"
+ is_category "$name" || {
+ printf 'panama-locale: %s is not a category this manages\n' "$name" >&2
+ return 2
+ }
+
+ local assignments=() line
+ while IFS= read -r line; do
+ [[ "${line%%=*}" == "$name" ]] && continue
+ assignments+=("$line")
+ done < <(current_assignments)
+
+ if [[ -n "$locale" ]]; then
+ installed_locale "$locale" || return 2
+ assignments+=("$name=$locale")
+ fi
+
+ # An empty file would leave the system with no LANG at all. Nothing here
+ # should be able to produce that, but refusing is cheaper than explaining.
+ (( ${#assignments[@]} > 0 )) || {
+ printf 'panama-locale: refusing to clear every locale setting\n' >&2
+ return 2
+ }
+ localectl set-locale "${assignments[@]}"
+}
+
case "${1:-list}" in
list) cmd_list ;;
- get) cmd_get ;;
- set) shift; cmd_set "${1:-}" ;;
- *) printf 'usage: panama-locale [list|get|set ]\n' >&2; exit 2 ;;
+ get) shift; cmd_get "${1:-LANG}" ;;
+ set)
+ shift
+ if is_category "${1:-}"; then
+ cmd_set_category "${1:-}" "${2:-}"
+ else
+ cmd_set "${1:-}"
+ fi
+ ;;
+ categories) printf '%s\n' "${CATEGORIES[@]}" ;;
+ overrides) cmd_overrides ;;
+ *)
+ printf 'usage: panama-locale [list|categories|overrides|get [category]|set [category] ]\n' >&2
+ exit 2
+ ;;
esac
diff --git a/config/dot/quickshell/scripts/panama-settings-backup b/config/dot/quickshell/scripts/panama-settings-backup
index 5ed589d..eba8cdc 100755
--- a/config/dot/quickshell/scripts/panama-settings-backup
+++ b/config/dot/quickshell/scripts/panama-settings-backup
@@ -39,6 +39,13 @@ KEEP = 15
SNAPSHOT_RE = re.compile(r"^settings-[0-9]{8}-[0-9]{9}\.json$")
ENTITY_RE = re.compile(r"^light\.[a-z0-9_]+$")
+# A label somebody types, kept inside the envelope rather than in the filename.
+# The name on disk stays the timestamp SNAPSHOT_RE describes: it is what
+# orders the list, what prune and restore match against, and what confines a
+# restore to this directory. A user-supplied filename would put all three of
+# those in the caller's hands.
+LABEL_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 ._-]{0,79}$")
+
class BackupError(RuntimeError):
pass
@@ -210,12 +217,27 @@ def clean_transaction_artifacts() -> None:
fsync_directory(TRANSACTION_PARENT)
+# Quickshell's FileView writes atomically through QSaveFile, which stages at
+# ".XXXXXX" -- no leading dot, six random characters -- and renames. A
+# shell killed mid-write leaves that file behind forever: nothing ever looks at
+# it again, and ~/.config/panama slowly fills with half-remembered copies of
+# the settings store. Panama's own writer uses the dotted prefixes above, so
+# this pattern is only ever somebody else's leftover.
+QSAVEFILE_RE = re.compile(r"^(settings\.json|panama-home\.json)\.[A-Za-z0-9]{6}$")
+
+# A write in flight looks exactly like a leaked one. Only files older than this
+# are swept, which is several orders of magnitude longer than a settings write
+# takes and short enough that nobody accumulates them.
+STALE_AFTER_SECONDS = 3600
+
+
def clean_stale_atomic_files() -> None:
locations = (
(SETTINGS.parent, (".settings.json.",)),
(HOME_STATE.parent, (".panama-home.json.",)),
(BACKUP_DIR, (".settings-",)),
)
+ now = time.time()
for directory, prefixes in locations:
if not directory.exists():
continue
@@ -223,8 +245,15 @@ def clean_stale_atomic_files() -> None:
fail(f"{directory} is not a safe directory.")
changed = False
for child in directory.iterdir():
- if not any(child.name.startswith(prefix) for prefix in prefixes):
- continue
+ owned = any(child.name.startswith(prefix) for prefix in prefixes)
+ if not owned:
+ if QSAVEFILE_RE.fullmatch(child.name) is None:
+ continue
+ try:
+ if now - child.stat().st_mtime < STALE_AFTER_SECONDS:
+ continue
+ except OSError:
+ continue
# Only Panama's hidden atomic-write names are eligible. A matching
# directory is unexpected and is never recursively removed.
if child.is_dir() and not child.is_symlink():
@@ -359,7 +388,23 @@ def prune_snapshots() -> None:
durable_remove(old)
-def save_snapshot(*, require_any: bool, validate: bool) -> Path | None:
+def sanitize_label(raw: str) -> str:
+ """A typed name, reduced to what can safely sit in a JSON envelope.
+
+ Collapsed rather than refused: somebody who typed two spaces or a trailing
+ one meant the obvious thing, and losing their snapshot over it would be
+ absurd. Anything still outside the charset after that is refused, because
+ at that point they typed something this does not mean to carry.
+ """
+ collapsed = " ".join(raw.split())
+ if not collapsed:
+ return ""
+ if LABEL_RE.fullmatch(collapsed) is None:
+ fail("That name cannot be used.")
+ return collapsed
+
+
+def save_snapshot(*, require_any: bool, validate: bool, label: str = "") -> Path | None:
try:
desktop_present, desktop = current_store(
SETTINGS, "The current settings file"
@@ -386,6 +431,11 @@ def save_snapshot(*, require_any: bool, validate: bool) -> Path | None:
envelope["desktop"]["data"] = desktop
if home_present:
envelope["home"]["data"] = home
+ # Absent rather than empty when there is no label, so a snapshot taken
+ # automatically before a risky action is distinguishable from one somebody
+ # deliberately named "".
+ if label:
+ envelope["label"] = label
destination = next_snapshot_path()
atomic_write_json(destination, envelope)
@@ -515,6 +565,37 @@ def command_save(arguments: list[str]) -> None:
print(json.dumps({"saved": destination.name}, separators=(",", ":")))
+def command_create(arguments: list[str]) -> None:
+ """save, with a name on it.
+
+ Kept as its own verb rather than an optional argument to `save`: `save`
+ already takes live Home state as its first argument, and overloading that
+ position by type is how a Home payload eventually gets read as a label.
+ """
+ label = sanitize_label(arguments[0]) if arguments else ""
+ if len(arguments) > 1:
+ write_live_home(arguments[1])
+ destination = save_snapshot(require_any=True, validate=True, label=label)
+ assert destination is not None
+ print(json.dumps({"saved": destination.name, "label": label},
+ separators=(",", ":")))
+
+
+def command_delete(arguments: list[str]) -> None:
+ """Remove one snapshot, named the way restore names one.
+
+ Goes through snapshot_source, which is what confines the name to this
+ directory -- the same gate a restore passes. A delete that resolved paths
+ its own way would be a second boundary to keep correct, and the weaker of
+ the two is the one that gets used.
+ """
+ if not arguments:
+ fail("Which snapshot?")
+ source = snapshot_source(arguments[0])
+ durable_remove(source)
+ print(json.dumps({"deleted": source.name}, separators=(",", ":")))
+
+
def snapshot_files() -> list[Path]:
ensure_directory(BACKUP_DIR)
return sorted(
@@ -533,6 +614,7 @@ def snapshot_files() -> list[Path]:
def command_list() -> None:
output: list[dict[str, Any]] = []
for path in snapshot_files():
+ label = ""
try:
value = read_json(path, "A snapshot")
if is_v2_envelope(value):
@@ -540,6 +622,9 @@ def command_list() -> None:
keys = len(desktop["data"]) if desktop["present"] else 0
else:
keys = len(value)
+ raw_label = value.get("label")
+ if isinstance(raw_label, str) and LABEL_RE.fullmatch(raw_label):
+ label = raw_label
except BackupError:
keys = 0
raw = path.name.removeprefix("settings-").removesuffix(".json")
@@ -547,7 +632,14 @@ def command_list() -> None:
f"{raw[0:4]}-{raw[4:6]}-{raw[6:8]} "
f"{raw[9:11]}:{raw[11:13]}:{raw[13:15]}"
)
- output.append({"name": path.name, "when": pretty, "keys": keys})
+ # Size on disk, so the page can say what fifteen snapshots actually
+ # cost rather than leaving it as an unbounded mystery.
+ try:
+ size = path.stat().st_size
+ except OSError:
+ size = 0
+ output.append({"name": path.name, "when": pretty, "keys": keys,
+ "bytes": size, "label": label})
print(json.dumps(output, separators=(",", ":")))
@@ -628,12 +720,17 @@ def main() -> None:
arguments = sys.argv[2:]
if command == "save":
command_save(arguments)
+ elif command == "create":
+ command_create(arguments)
elif command == "list":
command_list()
elif command == "restore":
command_restore(arguments)
+ elif command == "delete":
+ command_delete(arguments)
else:
- fail("usage: panama-settings-backup [save|list|restore ]")
+ fail("usage: panama-settings-backup "
+ "[save|create [name]|list|restore |delete ]")
if __name__ == "__main__":
diff --git a/config/dot/quickshell/scripts/panama-settings-commands b/config/dot/quickshell/scripts/panama-settings-commands
index 034d7e1..7e5a8e0 100755
--- a/config/dot/quickshell/scripts/panama-settings-commands
+++ b/config/dot/quickshell/scripts/panama-settings-commands
@@ -64,6 +64,8 @@ CATEGORY = re.compile(
r'\{\s*page:\s*"([a-z-]+)",\s*label:\s*"([^"]+)",\s*icon:\s*"[^"]*",\s*tabs:\s*\[([^\]]*)\]\s*\}',
re.S)
TAB = re.compile(r'\{\s*page:\s*"([a-z-]+)",\s*label:\s*"([^"]+)"\s*\}')
+HIDDEN = re.compile(
+ r'\{\s*page:\s*"([a-z-]+)",\s*label:\s*"([^"]+)",\s*category:\s*"([a-z-]+)"\s*\}')
def categories() -> list[tuple[str, str, list[tuple[str, str]]]]:
@@ -87,6 +89,33 @@ def categories() -> list[tuple[str, str, list[tuple[str, str]]]]:
return found
+def hidden_leaves() -> list[tuple[str, str]]:
+ """Leaves that are routable but draw no tab, as (id, label).
+
+ The manual is the one: reference material rather than a control surface, so
+ it is opened from About, a deep link, or a launcher command rather than
+ found by scanning a tab strip. Which makes the command below the main way
+ anybody reaches it, and dropping it because it has no tab would be exactly
+ the wrong conclusion.
+
+ They live outside `categories` because the reader above requires each
+ category to end `tabs: [...] }` and cross-checks every `page:` inside that
+ array; a hidden leaf declared in there would break both.
+ """
+ source = read(ROUTES)
+ block = re.search(r"readonly property var hiddenLeaves: \[(.*?)\n \]", source, re.S)
+ if not block:
+ return []
+ found = HIDDEN.findall(block.group(1))
+ declared = len(re.findall(r'\bpage:\s*"', block.group(1)))
+ if len(found) != declared:
+ raise ParseError(
+ f"read {len(found)} of the {declared} hidden leaves in "
+ "SettingsRoutes.qml; that array no longer looks the way this "
+ "reader expects")
+ return [(page, label) for page, label, _category in found]
+
+
def pages() -> list[tuple[str, str]]:
"""The leaf pages, in sidebar order, as (id, label).
@@ -97,6 +126,7 @@ def pages() -> list[tuple[str, str]]:
leaves: list[tuple[str, str]] = []
for page, label, tabs in categories():
leaves += tabs or [(page, label)]
+ leaves += hidden_leaves()
ids = [page for page, _label in leaves]
duplicated = sorted({page for page in ids if ids.count(page) > 1})
if duplicated:
diff --git a/config/dot/quickshell/scripts/panama-settings-docs b/config/dot/quickshell/scripts/panama-settings-docs
index 1ac0a22..cafae64 100755
--- a/config/dot/quickshell/scripts/panama-settings-docs
+++ b/config/dot/quickshell/scripts/panama-settings-docs
@@ -43,6 +43,8 @@ CATEGORY = re.compile(
r'\{\s*page:\s*"([a-z-]+)",\s*label:\s*"([^"]+)",\s*icon:\s*"[^"]*",\s*tabs:\s*\[([^\]]*)\]\s*\}',
re.S)
TAB = re.compile(r'\{\s*page:\s*"([a-z-]+)",\s*label:\s*"([^"]+)"\s*\}')
+HIDDEN = re.compile(
+ r'\{\s*page:\s*"([a-z-]+)",\s*label:\s*"([^"]+)",\s*category:\s*"([a-z-]+)"\s*\}')
class SchemaError(RuntimeError):
@@ -61,9 +63,11 @@ def read_titles():
if not block:
raise SchemaError("could not find the categories array in SettingsRoutes.qml")
titles = {}
+ labels = {}
read = 0
for page, label, tabs in CATEGORY.findall(block.group(1)):
found = TAB.findall(tabs)
+ labels[page] = label
read += 1 + len(found)
if found:
titles.update({tab: f"{label} › {tab_label}" for tab, tab_label in found})
@@ -77,6 +81,19 @@ def read_titles():
raise SchemaError(
f"read {read} of the {declared} pages in SettingsRoutes.qml; the "
"categories array no longer looks the way this reader expects")
+
+ # Leaves that are routable but draw no tab -- the manual -- are declared
+ # outside the categories array, because the reader above requires each
+ # category to end `tabs: [...] }` and accounts for every `page:` inside it.
+ # They are still pages somebody lands on, so they are still named here.
+ hidden = re.search(r"readonly property var hiddenLeaves: \[(.*?)\n \]", text, re.S)
+ if hidden:
+ found = HIDDEN.findall(hidden.group(1))
+ if len(found) != len(re.findall(r'\bpage:\s*"', hidden.group(1))):
+ raise SchemaError(
+ "the hiddenLeaves array no longer looks the way this reader expects")
+ for page, label, category in found:
+ titles[page] = f"{labels.get(category, category)} › {label}"
return titles
diff --git a/config/dot/quickshell/scripts/panama-settings-sync b/config/dot/quickshell/scripts/panama-settings-sync
index 8123926..8c80e21 100755
--- a/config/dot/quickshell/scripts/panama-settings-sync
+++ b/config/dot/quickshell/scripts/panama-settings-sync
@@ -58,6 +58,17 @@ MACHINE_SPECIFIC = {
# machine where the file exists. Carried, then checked on arrival.
PATH_VALUED = {"wallpaperPath", "wallpaperSlideshowPaths"}
+# A preview is something a person reads before pressing Import. Past a screen or
+# two it stops being read and starts being scrolled, so the list is capped and
+# the count says how many there really are -- the import itself still applies
+# every change, because the cap is about what is shown, not what is done.
+CHANGE_LIMIT = 40
+
+# Long enough for a wallpaper path or a theme name, short enough that no single
+# row can push the rest off the screen. Values are rendered for display here,
+# never re-parsed, so a truncated one costs nothing.
+VALUE_LIMIT = 120
+
class BoundaryError(RuntimeError):
"""A user-visible validation or file failure."""
@@ -158,6 +169,29 @@ def fits(entry: dict, value) -> str:
return ""
+def render(value) -> str:
+ """One setting's value as a line of text a person can compare.
+
+ Rendered here rather than in the page because the page would have to know
+ the difference between a JSON setting and a scalar one to do it, and that
+ knowledge already lives in the schema on this side. A value with no entry
+ at all is "not set" rather than "null": the two look identical in JSON and
+ mean quite different things to somebody reading a diff.
+ """
+ if value is None:
+ return "not set"
+ if isinstance(value, bool):
+ return "on" if value else "off"
+ if isinstance(value, (int, float)):
+ return f"{value:g}" if isinstance(value, float) else str(value)
+ if isinstance(value, str):
+ text = value
+ else:
+ text = json.dumps(value, separators=(",", ":"), sort_keys=True)
+ text = " ".join(text.split())
+ return text if len(text) <= VALUE_LIMIT else text[:VALUE_LIMIT - 1] + "…"
+
+
def exportable() -> tuple[dict, list[dict]]:
known = schema()
current = stored()
@@ -248,14 +282,17 @@ def plan(path: str) -> dict:
continue
apply[key] = value
changes.append({"key": key,
- "from": current.get(key, None),
- "to": value})
+ "from": render(current.get(key)),
+ "to": render(value)})
return {
"path": str(Path(path).expanduser()),
"exportedFrom": str(bundle.get("exportedFrom", "")),
"exportedAt": int(bundle.get("exportedAt", 0)),
- "changes": changes,
+ "changes": changes[:CHANGE_LIMIT],
+ # What the list would have held uncapped, so the page can say "and 12
+ # more" rather than quietly showing forty of fifty-two.
+ "changeCount": len(changes),
"skipped": skipped,
"apply": apply,
}
diff --git a/config/dot/quickshell/scripts/panama-updates b/config/dot/quickshell/scripts/panama-updates
index dfd2cae..b646e87 100755
--- a/config/dot/quickshell/scripts/panama-updates
+++ b/config/dot/quickshell/scripts/panama-updates
@@ -15,7 +15,10 @@ updater does, instead of pretending the number is live.
panama-updates snapshot
panama-updates check
panama-updates apply dnf|flatpak|firmware
+ panama-updates apply flatpak
+ panama-updates changelog dnf|flatpak|firmware
panama-updates set-auto-flatpak true|false
+ panama-updates set-auto-dnf true|false
"""
from __future__ import annotations
@@ -30,10 +33,22 @@ import sys
import time
from pathlib import Path
-# The user timer this ships for keeping applications current. dnf has no
-# equivalent here because dnf-automatic is not installed, and installing
-# software is not this script's job.
+# The two unattended-update timers this machine can have. The flatpak one is
+# Panama's own user timer; the dnf one is dnf5-automatic's system timer, which
+# ships with dnf5 and downloads without installing. Both are only ever enabled
+# or disabled -- installing software is not this script's job, so a timer that
+# is not present is reported as unavailable rather than offered.
FLATPAK_TIMER = "panama-flatpak-update.timer"
+DNF_TIMER = "dnf5-automatic.timer"
+
+# Package and application names reach a subprocess as argv, never a shell. They
+# are still constrained: rpm names and flatpak application IDs both live well
+# inside this, and anything outside it is not a name either source would emit.
+NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$")
+
+# How much changelog text is worth carrying into a settings expander. Beyond
+# this it stops being something anybody reads and starts being a scroll.
+CHANGELOG_LIMIT = 6000
# Anything carrying an advisory of these severities is reported as a security
# fix. "none" is excluded deliberately: an advisory with no severity is a
@@ -103,6 +118,30 @@ def kernel_state() -> dict:
}
+def dnf_download_sizes() -> dict[str, int]:
+ """Bytes to fetch per pending package, straight from the repo metadata.
+
+ `dnf5 repoquery --queryformat` is the only place dnf5 reports this as a
+ number rather than as a rendered "172.9 MiB". A repo it cannot reach, or a
+ format it renders differently one day, yields nothing at all -- the size is
+ a nicety, and a wrong size is worse than no size.
+ """
+ result = run(["dnf5", "repoquery", "--upgrades",
+ "--queryformat", "%{name} %{downloadsize}\\n"], timeout=180)
+ if result.returncode != 0:
+ return {}
+ sizes: dict[str, int] = {}
+ for line in result.stdout.splitlines():
+ parts = line.split()
+ if len(parts) != 2:
+ continue
+ try:
+ sizes[parts[0]] = int(parts[1])
+ except ValueError:
+ continue
+ return sizes
+
+
def dnf_updates() -> dict:
if not shutil.which("dnf5"):
return {"available": False, "count": 0, "packages": [], "securityCount": 0}
@@ -131,24 +170,74 @@ def dnf_updates() -> dict:
except json.JSONDecodeError:
security = 0
+ sizes = dnf_download_sizes() if packages else {}
+ for package in packages:
+ if package["name"] in sizes:
+ package["bytes"] = sizes[package["name"]]
+
packages.sort(key=lambda item: item["name"])
- return {"available": True, "count": len(packages), "packages": packages,
- "securityCount": security}
+ source = {"available": True, "count": len(packages), "packages": packages,
+ "securityCount": security}
+ # Only when every pending package was priced. A partial total reads as the
+ # whole download and would understate it, which is the direction that
+ # surprises somebody on a metered connection.
+ if packages and all("bytes" in package for package in packages):
+ source["downloadBytes"] = sum(package["bytes"] for package in packages)
+ return source
+
+
+def human_bytes(text: str) -> int:
+ """flatpak's "36.2 MB" back into a number, or 0 when it is not one.
+
+ flatpak has no machine-readable size: its --json output omits the column
+ entirely, so the rendered string is the only source there is. Parsed with
+ the decimal units flatpak actually prints, and zero on anything else.
+ """
+ match = re.match(r"^\s*([0-9]+(?:\.[0-9]+)?)\s*([kMGT]?B)\s*$", text)
+ if not match:
+ return 0
+ scale = {"B": 1, "kB": 10 ** 3, "MB": 10 ** 6, "GB": 10 ** 9, "TB": 10 ** 12}
+ return int(float(match.group(1)) * scale[match.group(2)])
def flatpak_updates() -> dict:
if not shutil.which("flatpak"):
return {"available": False, "count": 0, "applications": []}
- result = run(["flatpak", "remote-ls", "--updates", "--columns=application,version"],
- timeout=120)
+ result = run(["flatpak", "remote-ls", "--updates",
+ "--columns=application,version,origin,download-size"], timeout=120)
applications = []
if result.returncode == 0:
for line in result.stdout.splitlines():
parts = [part.strip() for part in line.split("\t")]
- if parts and parts[0]:
- applications.append({"id": parts[0],
- "version": parts[1] if len(parts) > 1 else ""})
- return {"available": True, "count": len(applications), "applications": applications}
+ if not parts or not parts[0]:
+ continue
+ application = {"id": parts[0],
+ "version": parts[1] if len(parts) > 1 else "",
+ "origin": parts[2] if len(parts) > 2 else ""}
+ size = human_bytes(parts[3]) if len(parts) > 3 else 0
+ if size:
+ application["bytes"] = size
+ applications.append(application)
+ source = {"available": True, "count": len(applications), "applications": applications}
+ if applications and all("bytes" in application for application in applications):
+ source["downloadBytes"] = sum(application["bytes"] for application in applications)
+ return source
+
+
+def strip_markup(text: str) -> str:
+ """fwupd release notes are a small AppStream XML dialect, not prose.
+
+ Paragraphs and list items become lines; everything else is dropped. Doing
+ this here rather than in the page keeps the helper's answer plain text, the
+ same shape the dnf and flatpak paths return.
+ """
+ if not text.strip():
+ return ""
+ text = re.sub(r"
|", "\n", text)
+ text = re.sub(r"
", "• ", text)
+ text = re.sub(r"<[^>]+>", "", text)
+ lines = [line.strip() for line in text.splitlines()]
+ return "\n".join(line for line in lines if line)
def firmware_updates() -> dict:
@@ -160,13 +249,20 @@ def firmware_updates() -> dict:
payload = json.loads(result.stdout or "{}")
for device in payload.get("Devices", []):
releases = device.get("Releases", [])
- devices.append({
+ entry = {
"name": str(device.get("Name", "Unknown device")),
"version": str(device.get("Version", "")),
"target": str(releases[0].get("Version", "")) if releases else "",
# Firmware that needs a reboot to flash is worth saying up front.
"needsReboot": "needs-reboot" in json.dumps(device.get("Flags", [])),
- })
+ }
+ # fwupd already has the vendor's release notes in hand, so they are
+ # kept here rather than re-fetched: the changelog for firmware costs
+ # nothing beyond the scan that found the update.
+ notes = strip_markup(str(releases[0].get("Description", ""))) if releases else ""
+ if notes:
+ entry["changelog"] = notes[:CHANGELOG_LIMIT]
+ devices.append(entry)
except json.JSONDecodeError:
pass
return {"available": True, "count": len(devices), "devices": devices}
@@ -174,12 +270,13 @@ def firmware_updates() -> dict:
def automatic_state() -> dict:
flatpak_timer = run(["systemctl", "--user", "is-enabled", FLATPAK_TIMER], timeout=20)
- dnf_timer = run(["systemctl", "is-enabled", "dnf5-automatic.timer"], timeout=20)
+ dnf_timer = run(["systemctl", "is-enabled", DNF_TIMER], timeout=20)
return {
"flatpakEnabled": flatpak_timer.stdout.strip() == "enabled",
"flatpakAvailable": flatpak_timer.stdout.strip() not in ("", "not-found"),
- # Reported, never offered: dnf-automatic is a package this machine does
- # not have, and installing software is not a settings action.
+ # Offered when the timer exists, reported as unavailable when it does
+ # not. See set_auto_dnf for what enabling it actually does -- it
+ # downloads, it does not install.
"dnfAutomaticEnabled": dnf_timer.stdout.strip() == "enabled",
"dnfAutomaticAvailable": dnf_timer.stdout.strip() not in ("", "not-found"),
}
@@ -226,11 +323,22 @@ def take_restore_point(reason: str) -> str:
return result.stdout.strip() if result.returncode == 0 else ""
-def apply(source: str) -> dict:
+def apply(source: str, target: str = "") -> dict:
if source == "flatpak":
if not shutil.which("flatpak"):
raise BoundaryError("Flatpak is not installed.")
- result = run(["flatpak", "update", "-y", "--noninteractive"], timeout=3600)
+ command = ["flatpak", "update", "-y", "--noninteractive"]
+ if target:
+ # One application, by ID. Checked against the IDs the last scan
+ # actually found rather than passed through: this is the only verb
+ # that takes a name from the page, and the page is not the
+ # authority on what is pending.
+ pending = {str(entry.get("id", ""))
+ for entry in read_cache().get("flatpak", {}).get("applications", [])}
+ if target not in pending:
+ raise BoundaryError("That application does not have an update waiting.")
+ command.append(target)
+ result = run(command, timeout=3600)
if result.returncode != 0:
raise BoundaryError(_refusal(result, "The applications could not be updated."))
return {"restorePoint": ""}
@@ -257,6 +365,115 @@ def apply(source: str) -> dict:
raise BoundaryError("That is not an update source.")
+def dnf_changelog(name: str) -> dict:
+ """The best text dnf5 will actually give for one pending package.
+
+ Two sources, in the order a person cares about them. An advisory says why
+ the update exists and what it fixes, which is the answer when there is one.
+ Failing that, the rpm changelog DELTA -- `--upgrades` prints only entries
+ newer than what is installed, which is exactly the question being asked and
+ not the package's whole history.
+
+ Plenty of packages have neither. Third-party repos routinely ship with no
+ changelog at all, and dnf5 answers that with a header and nothing under it.
+ Saying so is the honest result, not a failure.
+ """
+ advisory = run(["dnf5", "advisory", "info", "--json", "--updates",
+ f"--contains-pkgs={name}"], timeout=180)
+ if advisory.returncode == 0:
+ try:
+ entries = json.loads(advisory.stdout or "[]")
+ except json.JSONDecodeError:
+ entries = []
+ blocks = []
+ for entry in entries if isinstance(entries, list) else []:
+ heading = " · ".join(part for part in (
+ str(entry.get("Name", "")).strip(),
+ str(entry.get("Type", "")).strip().title(),
+ str(entry.get("Severity", "")).strip(),
+ ) if part)
+ body = "\n".join(part for part in (
+ str(entry.get("Title", "")).strip(),
+ str(entry.get("Description", "")).strip(),
+ ) if part)
+ if heading or body:
+ blocks.append((heading + "\n" + body).strip())
+ if blocks:
+ return {"kind": "advisory", "text": "\n\n".join(blocks)[:CHANGELOG_LIMIT]}
+
+ result = run(["dnf5", "changelog", "--upgrades", name], timeout=180)
+ if result.returncode == 0:
+ # dnf5 prints "Listing only new changelogs..." and "Changelogs for
+ # " before the entries. Both are dnf talking about itself.
+ lines = [line for line in result.stdout.splitlines()
+ if not line.startswith(("Listing only ", "Changelogs for "))]
+ text = "\n".join(lines).strip()
+ if text:
+ return {"kind": "changelog", "text": text[:CHANGELOG_LIMIT]}
+
+ return {"kind": "none", "text": "This package publishes no changelog for the update."}
+
+
+def flatpak_changelog(name: str) -> dict:
+ """Whatever the remote already has cached, and nothing more.
+
+ `flatpak remote-info --log` without --cached is an ostree history walk
+ against the network, which is far too much work for an expander somebody
+ clicked. With --cached it answers from metadata already on disk, and most
+ remotes have nothing there -- Flathub's commit history is not part of the
+ summary. Absence is reported as absence.
+ """
+ origin = ""
+ for entry in read_cache().get("flatpak", {}).get("applications", []):
+ if str(entry.get("id", "")) == name:
+ origin = str(entry.get("origin", ""))
+ break
+ if not origin:
+ listed = run(["flatpak", "list", "--app", "--columns=application,origin"], timeout=60)
+ for line in listed.stdout.splitlines():
+ parts = [part.strip() for part in line.split("\t")]
+ if len(parts) > 1 and parts[0] == name:
+ origin = parts[1]
+ break
+ if not origin or not NAME_PATTERN.match(origin):
+ return {"kind": "none", "text": "This application publishes no release notes."}
+
+ result = run(["flatpak", "remote-info", "--cached", "--log", origin, name], timeout=90)
+ if result.returncode == 0:
+ history = result.stdout.partition("History:")[2].strip()
+ if history:
+ return {"kind": "changelog", "text": history[:CHANGELOG_LIMIT]}
+ return {"kind": "none", "text": "This application publishes no release notes."}
+
+
+def firmware_changelog(name: str) -> dict:
+ for device in read_cache().get("firmware", {}).get("devices", []):
+ if str(device.get("name", "")) == name:
+ notes = str(device.get("changelog", "")).strip()
+ if notes:
+ return {"kind": "changelog", "text": notes[:CHANGELOG_LIMIT]}
+ break
+ return {"kind": "none", "text": "This firmware update ships no release notes."}
+
+
+def changelog(source: str, name: str) -> dict:
+ if not NAME_PATTERN.match(name):
+ raise BoundaryError("That is not a name this machine would have produced.")
+ if source == "dnf":
+ if not shutil.which("dnf5"):
+ raise BoundaryError("dnf is not installed.")
+ result = dnf_changelog(name)
+ elif source == "flatpak":
+ if not shutil.which("flatpak"):
+ raise BoundaryError("Flatpak is not installed.")
+ result = flatpak_changelog(name)
+ elif source == "firmware":
+ result = firmware_changelog(name)
+ else:
+ raise BoundaryError("That is not an update source.")
+ return {"source": source, "name": name, **result, "error": ""}
+
+
def set_auto_dnf(enabled: bool) -> None:
"""Enable the packaging timer, which DOWNLOADS updates but does not apply them.
@@ -269,7 +486,7 @@ def set_auto_dnf(enabled: bool) -> None:
if not state["dnfAutomaticAvailable"]:
raise BoundaryError("Automatic package updates are not installed.")
action = ["enable", "--now"] if enabled else ["disable", "--now"]
- result = run(["pkexec", "systemctl", *action, "dnf5-automatic.timer"], timeout=120)
+ result = run(["pkexec", "systemctl", *action, DNF_TIMER], timeout=120)
if result.returncode != 0:
raise BoundaryError(_refusal(result, "Automatic package updates could not be changed."))
@@ -383,13 +600,24 @@ def main(arguments: list[str]) -> int:
check()
print(json.dumps(snapshot(), separators=(",", ":")))
return 0
- if len(arguments) == 2 and arguments[0] == "apply":
- outcome = apply(arguments[1])
+ # Changelogs answer on their own, not folded into a snapshot: this is
+ # the one verb whose reply is about a single package, and putting it
+ # inside the state blob would make every reader guess which package it
+ # was talking about.
+ if len(arguments) == 3 and arguments[0] == "changelog":
+ print(json.dumps(changelog(arguments[1], arguments[2]),
+ separators=(",", ":")))
+ return 0
+ if len(arguments) in (2, 3) and arguments[0] == "apply":
+ target = arguments[2] if len(arguments) == 3 else ""
+ if target and arguments[1] != "flatpak":
+ raise BoundaryError("Only applications can be updated one at a time.")
+ outcome = apply(arguments[1], target)
# Re-check, so the page reflects what is actually left rather than
# assuming the update cleared everything it listed.
check()
state = snapshot()
- state["applied"] = {"source": arguments[1], **outcome}
+ state["applied"] = {"source": arguments[1], "target": target, **outcome}
print(json.dumps(state, separators=(",", ":")))
return 0
if len(arguments) == 2 and arguments[0] == "set-auto-flatpak":
@@ -398,9 +626,20 @@ def main(arguments: list[str]) -> int:
set_auto_dnf(arguments[1] == "true")
else:
raise BoundaryError(
- "Usage: panama-updates snapshot | check | apply dnf|flatpak|firmware | "
+ "Usage: panama-updates snapshot | check | history | "
+ "apply dnf|flatpak|firmware [app-id] | "
+ "changelog dnf|flatpak|firmware NAME | "
"set-auto-flatpak true|false | set-auto-dnf true|false")
except BoundaryError as error:
+ # A failed changelog answers in the changelog's own shape. Returning a
+ # whole state blob here would hand the caller a payload with no text
+ # field at all, which reads as "no changelog" rather than as a refusal.
+ if arguments[:1] == ["changelog"]:
+ print(json.dumps({"source": arguments[1] if len(arguments) > 1 else "",
+ "name": arguments[2] if len(arguments) > 2 else "",
+ "kind": "none", "text": "", "error": str(error)},
+ separators=(",", ":")))
+ return 0
state = snapshot()
state["error"] = str(error)
print(json.dumps(state, separators=(",", ":")))
diff --git a/config/dot/quickshell/services/Containers.qml b/config/dot/quickshell/services/Containers.qml
index fb81d25..a74b80f 100644
--- a/config/dot/quickshell/services/Containers.qml
+++ b/config/dot/quickshell/services/Containers.qml
@@ -40,9 +40,11 @@ Singleton {
readonly property bool busy: query.running || mutation.running
// Published on every interface AND currently running: reachable now, as
- // opposed to a stopped container that merely would be.
+ // opposed to a stopped container that merely would be. The stopped half had
+ // a property of its own that nothing ever read -- `exposed` already holds
+ // both, and a second derived list nobody asks for is a list that can only
+ // go wrong quietly.
readonly property var reachable: root.exposed.filter(entry => entry.running === true)
- readonly property var wouldExpose: root.exposed.filter(entry => entry.running !== true)
readonly property var unusedImages: root.disk.unusedImages ?? []
readonly property var unusedVolumes: root.disk.unusedVolumes ?? []
diff --git a/config/dot/quickshell/services/DateTime.qml b/config/dot/quickshell/services/DateTime.qml
index eefb870..6ca871a 100644
--- a/config/dot/quickshell/services/DateTime.qml
+++ b/config/dot/quickshell/services/DateTime.qml
@@ -24,15 +24,44 @@ Singleton {
property string timezone: ""
property bool ntpEnabled: false
property bool ntpSynchronized: false
+
+ // Whether timedatectl has answered even once. The status query is async, so
+ // for the first moment of the shell's life `ntpEnabled` is false because
+ // nothing has looked, not because network time is off -- and setTime's
+ // whole job is to refuse while it is on. Treating "not looked yet" as "off"
+ // is the same wrong answer that looks fine as everywhere else in Panama.
+ property bool statusRead: false
+
+ // What timedatectl last said the three clocks read. localTime is what the
+ // Clock card shows and what seeds the manual-set field, so it is kept
+ // ticking (see clockTick below) rather than frozen at the last scan;
+ // universalTime and rtcTime are facts, shown as read.
property string localTime: ""
property string universalTime: ""
property string rtcTime: ""
property string lastError: ""
+ // Milliseconds between this shell's own clock and the one timedatectl
+ // reported. Normally zero -- both read the same system clock -- but
+ // deriving the displayed time from the system's own answer rather than
+ // from QML's assumption means a clock that moves under us shows the move.
+ property real systemOffsetMs: 0
+
+ // Set by the page while the Clock card is on screen. The tick is a local
+ // recomputation, never another timedatectl call: a process per second to
+ // learn a value the local clock already tracks exactly would be a
+ // remarkable amount of work for a second hand.
+ property bool tracking: false
+
property var zones: []
readonly property bool busy: statusQuery.running || zonesQuery.running || writeRun.running
+ // The shape `setTime` accepts, and the shape the field should be seeded
+ // with. Seconds are optional because "set it to 9:30" is a whole request.
+ readonly property var timePattern:
+ new RegExp("^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}(?::\\d{2})?$")
+
// "America/New_York" -> "New York" for display, keeping the region as a
// separate field so the list can be grouped and searched sensibly.
function regionOf(zone: string): string {
@@ -102,10 +131,83 @@ Singleton {
root.ntpEnabled = value === "yes";
else if (key === "NTPSynchronized")
root.ntpSynchronized = value === "yes";
+ else if (key === "TimeUSec")
+ root.anchorClock(value);
+ else if (key === "RTCTimeUSec")
+ root.rtcTime = root.tidyStamp(value);
}
+ root.statusRead = true;
root.lastError = "";
}
+ // timedatectl prints "Mon 2026-08-24 21:22:55 EDT". The weekday is a
+ // duplicate of the date and the zone is already its own row, so what is
+ // left is the part a clock actually shows.
+ function tidyStamp(value: string): string {
+ const match = String(value).match(/(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})/);
+ return match ? match[1] : String(value).trim();
+ }
+
+ function anchorClock(value: string): void {
+ const stamp = root.tidyStamp(value);
+ // Parsed as local wall time, which is what timedatectl printed. A
+ // stamp this cannot read leaves the offset alone rather than jumping
+ // the displayed clock by whatever the misparse happened to produce.
+ const parsed = Date.parse(stamp.replace(" ", "T"));
+ root.systemOffsetMs = Number.isFinite(parsed) ? parsed - Date.now() : 0;
+ root.tickClock();
+ }
+
+ function tickClock(): void {
+ const now = new Date(Date.now() + root.systemOffsetMs);
+ root.localTime = Qt.formatDateTime(now, "yyyy-MM-dd HH:mm:ss");
+ // toISOString is already UTC, which is the whole question here; doing
+ // the offset arithmetic by hand is a way to get it wrong twice a year.
+ root.universalTime = now.toISOString().slice(0, 19).replace("T", " ") + " UTC";
+ }
+
+ Timer {
+ id: clockTick
+ interval: 1000
+ repeat: true
+ running: root.tracking
+ onTriggered: root.tickClock()
+ }
+
+ // Setting the clock by hand, which is only a coherent request while
+ // network time is off. With NTP on, timedatectl refuses outright and a
+ // toggle-then-set from the page would race the daemon putting the time
+ // back -- so the refusal happens here, where it can be explained, rather
+ // than as an opaque failure from a command the user did not type.
+ function setTime(iso: string): bool {
+ if (!root.statusRead) {
+ root.lastError = "The clock settings have not been read yet.";
+ root.refresh();
+ return false;
+ }
+ if (root.ntpEnabled) {
+ root.lastError = "Turn off network time before setting the clock by hand.";
+ return false;
+ }
+ const stamp = String(iso).trim();
+ if (!root.timePattern.test(stamp)) {
+ root.lastError = "Enter the time as YYYY-MM-DD HH:MM, with optional seconds.";
+ return false;
+ }
+ // Shape is not enough: "2026-13-45 99:99" matches the pattern and is
+ // not a moment. Round-tripping through Date is what rejects it.
+ const parsed = new Date(stamp.replace(" ", "T"));
+ if (!Number.isFinite(parsed.getTime())
+ || Qt.formatDateTime(parsed, "yyyy-MM-dd HH:mm") !== stamp.slice(0, 16)) {
+ root.lastError = "That is not a real date and time.";
+ return false;
+ }
+ if (writeRun.running)
+ return false;
+ writeRun.exec(["timedatectl", "set-time", stamp]);
+ return true;
+ }
+
function refresh(): void {
if (!statusQuery.running)
statusQuery.running = true;
diff --git a/config/dot/quickshell/services/Health.qml b/config/dot/quickshell/services/Health.qml
index be26e9a..b6684b7 100644
--- a/config/dot/quickshell/services/Health.qml
+++ b/config/dot/quickshell/services/Health.qml
@@ -27,13 +27,19 @@ Singleton {
property bool postRepairScanPending: false
readonly property bool actionable: root.status === "warning" || root.status === "error"
- readonly property bool busy: scanProcess.running || repairProcess.running || root.postRepairScanPending
+ readonly property bool busy: scanProcess.running || repairProcess.running
+ || singleCheckProcess.running || root.postRepairScanPending
readonly property string helperPath: Quickshell.env("PANAMA_HEALTH_HELPER")
|| Quickshell.shellDir + "/scripts/panama-doctor"
readonly property var statuses: ["ok", "warning", "error", "unconfigured"]
readonly property var groups: ["desktop-foundation", "input-media", "integrations", "panama-tools"]
readonly property var overallStatuses: ["healthy", "warning", "error"]
- readonly property var settingsTargets: ["my-home", "datetime"]
+ // Every page a check's "open" action is allowed to send someone to. This
+ // list and the doctor's authored targets are one change, not two: a target
+ // the doctor emits but this does not accept fails validAction, and a single
+ // rejected action invalidates the WHOLE snapshot -- so half of the pair
+ // does not degrade the row, it blanks the page.
+ readonly property var settingsTargets: ["my-home", "datetime", "updates"]
readonly property var instructionTargets: ["ddc-permissions"]
Process {
@@ -113,6 +119,54 @@ Singleton {
}
}
+ Process {
+ id: singleCheckProcess
+
+ property string checkId: ""
+ property int baseGeneration: 0
+ property string outputText: ""
+ property int exitCode: -1
+ property bool exited: false
+ property bool streamFinished: false
+ property bool settled: false
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ singleCheckProcess.outputText = this.text;
+ singleCheckProcess.streamFinished = true;
+ root.settleSingleCheck();
+ }
+ }
+
+ onExited: (exitCode, exitStatus) => {
+ singleCheckProcess.exitCode = exitCode;
+ singleCheckProcess.exited = true;
+ root.settleSingleCheck();
+ }
+ }
+
+ // `tee` rather than a shell redirect: the path is a value, not a fragment
+ // of a command line, so nothing about it can be read as syntax.
+ Process {
+ id: saveProcess
+
+ property string payload: ""
+ property string targetPath: ""
+
+ stdinEnabled: true
+ stdout: StdioCollector {}
+ onStarted: {
+ saveProcess.write(saveProcess.payload);
+ saveProcess.stdinEnabled = false;
+ }
+ onExited: (exitCode, exitStatus) => {
+ root.lastSaveResult = exitCode === 0
+ ? "Report saved to " + saveProcess.targetPath + "."
+ : "Could not save the health report.";
+ saveProcess.payload = "";
+ }
+ }
+
Process {
id: failureNotification
}
@@ -229,6 +283,11 @@ Singleton {
};
if (candidate.action !== undefined)
check.action = root.safeAction(candidate.action);
+ // Carried through rather than reconstructed: the page shows the exact
+ // command a repair will run before running it, and the helper is the
+ // only thing that knows what that is.
+ if (candidate.repairCommand !== undefined)
+ check.repairCommand = candidate.repairCommand;
return check;
}
@@ -343,6 +402,154 @@ Singleton {
&& candidate.message.length > 0;
}
+ // ── Re-checking one row ─────────────────────────────────────────────────
+ //
+ // A full scan runs thirty probes. Asking again about the one row somebody
+ // just repaired should not cost the other twenty-nine, so the helper is
+ // asked for that check alone and the answer is spliced into the accepted
+ // snapshot. The reply arrives in the full snapshot shape, which means it
+ // goes through exactly the same validation as a whole scan -- an invalid
+ // single-check reply leaves the existing row alone rather than replacing a
+ // good answer with a bad one.
+
+ property string refreshingId: ""
+
+ readonly property bool refreshingCheck: singleCheckProcess.running
+
+ function refreshCheck(id: string): bool {
+ if (root.busy || singleCheckProcess.running)
+ return false;
+ if (!root.checks.some(candidate => candidate.id === id))
+ return false;
+
+ root.refreshingId = id;
+ singleCheckProcess.checkId = id;
+ singleCheckProcess.baseGeneration = root.acceptedGeneration;
+ singleCheckProcess.outputText = "";
+ singleCheckProcess.exitCode = -1;
+ singleCheckProcess.exited = false;
+ singleCheckProcess.streamFinished = false;
+ singleCheckProcess.settled = false;
+ singleCheckProcess.exec([root.helperPath, "check", id]);
+ return true;
+ }
+
+ function settleSingleCheck(): void {
+ if (singleCheckProcess.settled || !singleCheckProcess.exited
+ || !singleCheckProcess.streamFinished)
+ return;
+ singleCheckProcess.settled = true;
+ root.finishSingleCheck(singleCheckProcess.exitCode, singleCheckProcess.checkId,
+ singleCheckProcess.baseGeneration,
+ singleCheckProcess.outputText);
+ }
+
+ function finishSingleCheck(exitCode: int, id: string, baseGeneration: int, text: string): bool {
+ root.refreshingId = "";
+ // A full scan that landed while this one row was being re-checked is
+ // the newer answer for every row including this one. Splicing a stale
+ // row back into it would undo part of a scan nobody asked to undo.
+ if (baseGeneration !== root.acceptedGeneration)
+ return false;
+ if (exitCode !== 0) {
+ root.lastError = "That check could not be re-run.";
+ return false;
+ }
+
+ let candidate;
+ try {
+ candidate = JSON.parse(text.trim());
+ } catch (error) {
+ root.lastError = "That check returned an unreadable response.";
+ return false;
+ }
+ if (!root.validSnapshot(candidate) || candidate.checks.length !== 1
+ || candidate.checks[0].id !== id) {
+ root.lastError = "That check returned an invalid response.";
+ return false;
+ }
+
+ const replacement = root.safeCheck(candidate.checks[0]);
+ const merged = root.checks.map(check => check.id === id ? replacement : check);
+ root.checks = merged;
+ root.summary = root.countsFor(merged);
+ root.status = root.summary.status;
+ root.snapshot = Object.assign({}, root.snapshot, {
+ checks: merged,
+ summary: root.summary
+ });
+ root.lastError = "";
+ return true;
+ }
+
+ // The same arithmetic the helper does, applied to a list that has had one
+ // row replaced. Recomputed rather than left alone: a warning that repaired
+ // itself must leave the headline count, not just its own row.
+ function countsFor(checks: var): var {
+ const counts = { ok: 0, warning: 0, error: 0, unconfigured: 0 };
+ for (const check of checks)
+ counts[check.status] += 1;
+ return {
+ status: counts.error > 0 ? "error" : counts.warning > 0 ? "warning" : "healthy",
+ healthy: counts.ok,
+ warnings: counts.warning,
+ errors: counts.error,
+ unconfigured: counts.unconfigured
+ };
+ }
+
+ // ── Saving the report ───────────────────────────────────────────────────
+
+ property string lastSaveResult: ""
+
+ readonly property string defaultReportPath:
+ (Quickshell.env("HOME") ?? "") + "/panama-health-report.txt";
+
+ // Plain text rather than the JSON copyReport puts on the clipboard: a file
+ // somebody saves is a file somebody opens, and a report they can read
+ // without a JSON viewer is worth more than one that round-trips.
+ function reportText(): string {
+ const lines = [
+ "Panama system health",
+ "Generated " + String(root.snapshot?.generatedAt ?? "at an unknown time"),
+ "Status: " + root.status
+ + " (" + root.summary.healthy + " ok, "
+ + root.summary.warnings + " warnings, "
+ + root.summary.errors + " errors, "
+ + root.summary.unconfigured + " unconfigured)",
+ ""
+ ];
+ for (const version of root.snapshot?.context?.versions ?? [])
+ lines.push(version.id + ": " + version.version);
+ lines.push("");
+ for (const check of root.checks) {
+ lines.push("[" + check.status + "] " + check.title + " (" + check.id + ")");
+ lines.push(" " + check.detail);
+ if (check.repairCommand)
+ lines.push(" repair: " + check.repairCommand);
+ }
+ return lines.join("\n") + "\n";
+ }
+
+ function saveReport(path: string): bool {
+ if (saveProcess.running)
+ return false;
+ const target = path && path.length > 0 ? path : root.defaultReportPath;
+ if (!target || target.indexOf("/") !== 0) {
+ root.lastSaveResult = "That is not a path this can write to.";
+ return false;
+ }
+ root.lastSaveResult = "";
+ saveProcess.targetPath = target;
+ saveProcess.payload = root.reportText();
+ // Re-arm stdin: the previous run closed it, and a disabled channel
+ // stays closed even after being set back to true mid-run. Same
+ // discipline as copyProcess above.
+ saveProcess.stdinEnabled = true;
+ saveProcess.exec(["tee", target]);
+ return true;
+ }
+
function copyReport(): bool {
if (copyProcess.running)
return false;
@@ -366,7 +573,9 @@ Singleton {
generation: root.generation,
acceptedGeneration: root.acceptedGeneration,
repairingId: root.repairingId,
+ refreshingId: root.refreshingId,
lastRepair: root.lastRepair,
+ lastSaveResult: root.lastSaveResult,
lastError: root.lastError,
checks: root.checks.map(check => check.id),
checkStates: root.checks.map(check => ({ id: check.id, status: check.status }))
@@ -427,6 +636,10 @@ Singleton {
|| typeof candidate.title !== "string" || candidate.title.length === 0
|| typeof candidate.detail !== "string" || candidate.detail.length === 0)
return false;
+ if (candidate.repairCommand !== undefined
+ && (typeof candidate.repairCommand !== "string"
+ || candidate.repairCommand.length === 0))
+ return false;
return candidate.action === undefined || root.validAction(candidate.action);
}
diff --git a/config/dot/quickshell/services/SettingsBackup.qml b/config/dot/quickshell/services/SettingsBackup.qml
index 6d04241..8adf654 100644
--- a/config/dot/quickshell/services/SettingsBackup.qml
+++ b/config/dot/quickshell/services/SettingsBackup.qml
@@ -15,6 +15,7 @@ import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
+import "DisplayLayout.js" as DisplayLayout
Singleton {
id: root
@@ -23,7 +24,6 @@ Singleton {
property var snapshots: []
property string lastError: ""
- property string lastAction: ""
// Narrow service boundaries keep restore sequencing explicit and make it
// possible to verify the real handler in an isolated shell without ever
@@ -84,6 +84,9 @@ Singleton {
Process {
id: actionRun
property bool restoring: false
+ // Which verb is in flight, so a failure can name what failed. A
+ // restore has its own flag because it also drives the display handoff.
+ property string doneAction: "saved"
property string outputText: ""
stdout: StdioCollector {
onStreamFinished: actionRun.outputText = this.text
@@ -93,7 +96,9 @@ Singleton {
if (exitCode !== 0) {
root.lastError = actionRun.restoring
? "That snapshot could not be restored."
- : "The settings could not be backed up.";
+ : actionRun.doneAction === "deleted"
+ ? "That snapshot could not be deleted."
+ : "The settings could not be backed up.";
if (actionRun.restoring) {
root.setDisplayBlocked(false);
root.protectedDisplays = ({});
@@ -101,7 +106,6 @@ Singleton {
}
return;
}
- root.lastAction = actionRun.restoring ? "restored" : "saved";
if (actionRun.restoring) {
const restoreAccepted = root.handleRestoreOutput(actionRun.outputText);
if (restoreAccepted)
@@ -209,9 +213,39 @@ Singleton {
if (actionRun.running)
return;
actionRun.restoring = false;
+ actionRun.doneAction = "saved";
actionRun.exec([root.helperPath, "save", root.serializeHomeState()]);
}
+ // A snapshot with a name on it. The name is passed through untouched --
+ // the helper owns what a usable name is, and a second sanitiser here would
+ // be a second answer to that question, guaranteed to disagree eventually.
+ function create(name: string): void {
+ if (actionRun.running)
+ return;
+ actionRun.restoring = false;
+ actionRun.doneAction = "saved";
+ actionRun.exec([root.helperPath, "create", String(name ?? ""),
+ root.serializeHomeState()]);
+ }
+
+ // Matched against the list rather than trusted, exactly as restore() does:
+ // no caller-supplied name reaches the helper even though it validates as
+ // well. This is the only verb that destroys a snapshot, so the page is
+ // expected to confirm before calling it.
+ function deleteBackup(name: string): bool {
+ if (actionRun.running)
+ return false;
+ if (!root.snapshots.some(snapshot => snapshot.name === name)) {
+ root.lastError = "That snapshot is not in the list.";
+ return false;
+ }
+ actionRun.restoring = false;
+ actionRun.doneAction = "deleted";
+ actionRun.exec([root.helperPath, "delete", name]);
+ return true;
+ }
+
function serializeHomeState(): string {
const current = root.readHomeState();
@@ -250,6 +284,34 @@ Singleton {
applyRestoredState.restart();
}
+ // The colour, depth and mirror fields a stored arrangement may carry beyond
+ // its geometry. Optional exactly as Displays.isPersistedLayoutEntry has
+ // them: an arrangement written before these existed still restores, and a
+ // field that is present but invalid refuses the whole entry rather than
+ // being guessed at.
+ //
+ // Leaving them out was a real loss, not a cosmetic one. The restored record
+ // is built on top of the LIVE layout, so a snapshot's colour profile,
+ // bit depth and SDR levels were silently replaced by whatever the display
+ // is showing right now -- and layoutsEqual, comparing only geometry, then
+ // judged the two identical and skipped the apply that would have put them
+ // back. A snapshot taken in HDR restored to whatever was on screen.
+ readonly property var storedDisplayFields:
+ ["vrrMode", "colorProfile", "bitdepth", "sdrBrightness", "sdrSaturation", "mirrorOf"]
+
+ function validStoredField(field: string, value: var): bool {
+ switch (field) {
+ case "vrrMode": return DisplayLayout.validVrrMode(value);
+ case "colorProfile": return DisplayLayout.validColorProfile(value);
+ case "bitdepth": return DisplayLayout.validBitdepth(value);
+ case "sdrBrightness": return DisplayLayout.validSdrBrightness(value);
+ case "sdrSaturation": return DisplayLayout.validSdrSaturation(value);
+ case "mirrorOf": return typeof value === "string"
+ && (value === "" || /^[A-Za-z0-9_.-]+$/.test(value));
+ default: return false;
+ }
+ }
+
function layoutFromStoredDisplays(stored: var): var {
if (!stored || typeof stored !== "object")
return null;
@@ -267,7 +329,7 @@ Singleton {
|| !Number.isInteger(entry.x) || !Number.isInteger(entry.y)
|| typeof entry.primary !== "boolean")
return null;
- layout.push(Object.assign({}, live, {
+ const record = Object.assign({}, live, {
width: Number(match[1]),
height: Number(match[2]),
refreshRate: Number(match[3]),
@@ -277,7 +339,15 @@ Singleton {
x: entry.x,
y: entry.y,
primary: entry.primary
- }));
+ });
+ for (const field of root.storedDisplayFields) {
+ if (entry[field] === undefined)
+ continue;
+ if (!root.validStoredField(field, entry[field]))
+ return null;
+ record[field] = entry[field];
+ }
+ layout.push(record);
}
return layout.filter(record => record.primary).length === 1 ? layout : null;
}
@@ -285,11 +355,23 @@ Singleton {
function layoutsEqual(left: var, right: var): bool {
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length)
return false;
- const fields = ["name", "mode", "scale", "transform", "x", "y", "primary"];
+ const fields = ["name", "mode", "scale", "transform", "x", "y", "primary",
+ "vrrMode", "colorProfile", "bitdepth", "mirrorOf"];
+ // Compared with a tolerance rather than by identity, the same way
+ // Displays.storedFieldsDiffer does: these come back from the compositor
+ // as floats, and 1.0 read back as 0.9999999 is not a change anybody made.
+ const approximate = ["sdrBrightness", "sdrSaturation"];
const a = Array.from(left).sort((x, y) => x.name.localeCompare(y.name));
const b = Array.from(right).sort((x, y) => x.name.localeCompare(y.name));
return a.every((record, index) => fields.every(
- field => record[field] === b[index][field]));
+ field => record[field] === b[index][field])
+ && approximate.every(field => {
+ const one = record[field];
+ const other = b[index][field];
+ if (one === undefined || other === undefined)
+ return one === other;
+ return Math.abs(Number(one) - Number(other)) < 0.001;
+ }));
}
function failDisplayRestore(message: string): bool {
diff --git a/config/dot/quickshell/services/SettingsRoutes.qml b/config/dot/quickshell/services/SettingsRoutes.qml
index f976bb2..4384ee6 100644
--- a/config/dot/quickshell/services/SettingsRoutes.qml
+++ b/config/dot/quickshell/services/SettingsRoutes.qml
@@ -80,13 +80,28 @@ Singleton {
{ page: "storage", label: "Storage" },
{ page: "snapshots", label: "Snapshots" },
{ page: "containers", label: "Containers" },
- { page: "datetime", label: "Date & Time" },
- { page: "region", label: "Region & Language" },
- { page: "manual", label: "Manual" },
+ { page: "datetime", label: "Date, Time & Region" },
{ page: "sync", label: "Sync & Backup" }
] }
]
+ // Leaves that are routable but are not tabs.
+ //
+ // The manual is reference material rather than a control surface: it is
+ // reached from About's Manual card, from a deep link, or from the search
+ // box, and a tenth tab spent on something nobody switches to while
+ // adjusting a setting made the System strip harder to read than the pages
+ // it addressed. It still needs to be a leaf -- every one of those callers
+ // hands `resolve()` the id "manual" and expects the page, not Home.
+ //
+ // Deliberately a separate array rather than a field inside a category: the
+ // two generators that regex-parse `categories` require each category to end
+ // `tabs: [...] }` and cross-check that every `page:` inside the array is
+ // accounted for, so a hidden leaf declared in there would break both.
+ readonly property var hiddenLeaves: [
+ { page: "manual", label: "Manual", category: "system" }
+ ]
+
// Pages whose entire backing stack can be absent hide once a scan has
// proven it absent: a Containers tab with no podman and a Snapshots tab
// with no snapper configuration render permanently empty, which reads as
@@ -103,19 +118,37 @@ Singleton {
return true;
}
+ // The hidden leaf with this id, or undefined.
+ function hiddenLeaf(page: string): var {
+ return root.hiddenLeaves.find(leaf => leaf.page === page);
+ }
+
// The category owning a leaf. Tab membership is checked before category
// ids so the three doubled ids land on their category either way.
function categoryOf(leaf: string): var {
const byTab = root.categories.find(cat => cat.tabs.some(tab => tab.page === leaf));
if (byTab)
return byTab;
+ const hidden = root.hiddenLeaf(leaf);
+ if (hidden) {
+ const owner = root.categories.find(cat => cat.page === hidden.category);
+ if (owner)
+ return owner;
+ }
return root.categories.find(cat => cat.page === leaf) ?? root.categories[0];
}
// Retired page ids keep resolving forever: old Vicinae commands, shell
// history, and muscle memory all hold them. Each maps to the leaf that
// absorbed its content.
- readonly property var retired: ({ "home-phone": "my-home", "desktop": "bar" })
+ readonly property var retired: ({
+ "home-phone": "my-home",
+ "desktop": "bar",
+ // Region & Language merged into Date, Time & Region: one tab now owns
+ // the clock, the timezone, the language and the per-category formats,
+ // which were three answers to "what does this machine consider local".
+ "region": "datetime"
+ })
// Any id a caller may hold — leaf, category, retired id, or garbage — to
// the leaf that should render: a leaf resolves to itself, a category to
@@ -128,7 +161,7 @@ Singleton {
return root.retired[id];
const asLeaf = root.categories.some(cat => (cat.page === id && cat.tabs.length === 0)
|| cat.tabs.some(tab => tab.page === id));
- if (asLeaf)
+ if (asLeaf || root.hiddenLeaf(id) !== undefined)
return id;
const category = root.categories.find(cat => cat.page === id);
if (category) {
@@ -141,6 +174,10 @@ Singleton {
// The available tabs of the category owning a leaf, for the strip above
// the page. One entry (or none) means no strip is worth rendering.
+ //
+ // A hidden leaf still gets its category's strip, with nothing selected:
+ // the manual is somewhere you arrive on purpose and leave again, and the
+ // strip is how you leave.
function tabsFor(leaf: string): var {
return root.categoryOf(leaf).tabs.filter(tab => root.pageAvailable(tab.page));
}
@@ -149,7 +186,8 @@ Singleton {
// what search results show, so a hit says where it will land.
function breadcrumb(leaf: string): string {
const category = root.categoryOf(leaf);
- const tab = category.tabs.find(t => t.page === leaf);
+ const tab = category.tabs.find(t => t.page === leaf)
+ ?? root.hiddenLeaves.find(entry => entry.page === leaf);
return tab ? category.label + " › " + tab.label : category.label;
}
}
diff --git a/config/dot/quickshell/services/SettingsSearch.qml b/config/dot/quickshell/services/SettingsSearch.qml
index a63a98a..bce8272 100644
--- a/config/dot/quickshell/services/SettingsSearch.qml
+++ b/config/dot/quickshell/services/SettingsSearch.qml
@@ -207,8 +207,21 @@ Singleton {
{ label: "Clear recent files", detail: "Empty the list of documents this desktop remembers you opening", page: "privacy" },
{ label: "Thumbnails", detail: "The cached previews of your pictures and videos, and clearing them", page: "privacy" },
{ label: "Application permissions", detail: "What the desktop portal has recorded: camera, microphone, screen, background", page: "privacy" },
- { label: "Language", detail: "The system language, applied to programs started afterwards", page: "region" },
- { label: "Regional formats", detail: "How dates, times, and numbers are written", page: "region" },
+ // Region & Language stopped being a tab of its own and became the
+ // Language & formats card on Date, Time & Region: a date format and the
+ // clock that shows it are one subject, and splitting them across two
+ // tabs meant changing how a date is written on a page that never showed
+ // one. These route to the merged tab, and `region` itself is a retired
+ // id that SettingsRoutes still resolves.
+ { label: "Language", detail: "The system language, applied to programs started afterwards", page: "datetime" },
+ { label: "Regional formats", detail: "How dates, times, and numbers are written", page: "datetime" },
+ { label: "Date format", detail: "The locale that decides how dates and times are written", page: "datetime" },
+ { label: "Number format", detail: "Which locale's decimal and thousands separators are used", page: "datetime" },
+ { label: "Currency", detail: "The locale that decides how amounts of money are written", page: "datetime" },
+ { label: "Measurement units", detail: "Metric or imperial, for programs that ask the system", page: "datetime" },
+ { label: "Paper size", detail: "A4 or Letter, for programs that ask the system", page: "datetime" },
+ { label: "First day of the week", detail: "Whether the week starts on Sunday or Monday, as your formats say", page: "datetime" },
+ { label: "Set the clock by hand", detail: "Type a date and time, once network time is off", page: "datetime" },
{ label: "Online accounts", detail: "Sign in to mail, calendar, and contacts", page: "accounts" },
// Adding an account is the thing people search for, and they search for
// it by the name of the service. Nextcloud and mail are added on the
@@ -228,8 +241,25 @@ Singleton {
{ label: "iMessage", detail: "Opens BlueBubbles", page: "phone" },
{ label: "System information", detail: "Kernel, distribution, and hardware", page: "about" },
{ label: "Desktop version", detail: "Which Hyprland and Quickshell this session runs", page: "about" },
+ // About answers "what is this machine". Each fact on it is something
+ // people arrive looking for by its own name -- a hostname to hand to
+ // somebody, a serial number for a warranty claim, a kernel version for
+ // a bug report -- and none of them is the label of a preference, so
+ // none was findable before.
+ { label: "Hostname", detail: "The name this machine answers to", page: "about" },
+ { label: "Kernel version", detail: "The Linux kernel this session is running", page: "about" },
+ { label: "Device model", detail: "The manufacturer and model of this machine", page: "about" },
+ { label: "Installed memory", detail: "How much RAM this machine has", page: "about" },
+ { label: "Uptime", detail: "How long this machine has been running since it last started", page: "about" },
+ { label: "Serial number", detail: "The number on the machine, for a warranty or support call", page: "about" },
+ { label: "BIOS version", detail: "The firmware version this machine boots with", page: "about" },
{ label: "Restore defaults", detail: "Return every Panama setting to its shipped value", page: "sync" },
{ label: "Carry settings to another machine", detail: "Export, preview, and import a settings file", page: "sync" },
+ // The verbs, not just the sentence. People arrive knowing they want to
+ // export or import, and searching either of those words used to find
+ // nothing at all.
+ { label: "Export settings", detail: "Write your preferences to a file you can carry elsewhere", page: "sync" },
+ { label: "Import settings", detail: "Preview a settings file from another machine, then apply it", page: "sync" },
{ label: "Settings backups", detail: "Snapshots of your preferences, restorable any time", page: "sync" },
{ label: "Pinned applications", detail: "Reorder the dock by dragging, here or on the dock itself", page: "dock" },
{ label: "Bar text", detail: "Keep the bar legible on any wallpaper", page: "bar" },
diff --git a/config/dot/quickshell/services/SettingsSync.qml b/config/dot/quickshell/services/SettingsSync.qml
index dbfdb46..a124111 100644
--- a/config/dot/quickshell/services/SettingsSync.qml
+++ b/config/dot/quickshell/services/SettingsSync.qml
@@ -27,8 +27,13 @@ Singleton {
property string lastAction: ""
property int carried: 0
property int applied: 0
- property var left: []
+
+ // What an import would do, one setting per row, with both values already
+ // rendered as text by the helper. Capped for display; changeCount is how
+ // many there really are, so the page can say what it is not showing.
property var changes: []
+ property int changeCount: 0
+
property var skipped: []
property string exportedFrom: ""
property bool previewed: false
@@ -54,8 +59,8 @@ Singleton {
root.lastError = String(parsed.error ?? "");
root.carried = Number(parsed.carried ?? 0);
root.applied = Number(parsed.applied ?? 0);
- root.left = Array.isArray(parsed.left) ? parsed.left : [];
root.changes = Array.isArray(parsed.changes) ? parsed.changes : [];
+ root.changeCount = Number(parsed.changeCount ?? root.changes.length);
root.skipped = Array.isArray(parsed.skipped) ? parsed.skipped : [];
root.exportedFrom = String(parsed.exportedFrom ?? "");
root.previewed = root.lastAction === "preview" && root.lastError === "";
diff --git a/config/dot/quickshell/services/SystemLocale.qml b/config/dot/quickshell/services/SystemLocale.qml
index 773cbe3..d71e116 100644
--- a/config/dot/quickshell/services/SystemLocale.qml
+++ b/config/dot/quickshell/services/SystemLocale.qml
@@ -33,16 +33,100 @@ Singleton {
// so the UI can stop claiming the new locale is already in use.
property bool pendingRestart: false
+ // Guards read the Process objects directly; a derived binding is stale
+ // inside the handler that changes it. See DefaultApps.qml.
+ readonly property bool busy: apply.running || applyCategory.running || root.scanning
+
readonly property string currentLabel: {
const match = root.locales.find(locale => locale.value === root.current);
return match ? match.label : root.current;
}
+ // ── Per-category formats ────────────────────────────────────────────────
+ //
+ // Reading in one language while writing dates, numbers and currency the way
+ // your country does is the ordinary case. Each category is an OVERRIDE, and
+ // its absence -- "" here -- means "match language", which is not the same
+ // as an override that happens to equal LANG today: the two diverge the
+ // moment the language changes.
+ //
+ // Same restart discipline as the language itself. localectl only affects
+ // programs started afterwards, so an accepted change sets pendingRestart
+ // and the page says a sign-out is needed rather than claiming the new
+ // format is already in use.
+
+ readonly property var categories:
+ ["LC_TIME", "LC_NUMERIC", "LC_MONETARY", "LC_MEASUREMENT", "LC_PAPER"]
+
+ property var categoryValues: ({})
+
+ // Bumped whenever an override is read or accepted, and read at the top of
+ // categoryValue() so a binding built on that call has something to
+ // invalidate. A bare function call captures no dependency and every reader
+ // would go stale -- see DesktopPreferences.get() for the same reason.
+ property int categoryRevision: 0
+
+ // "" means match language. Anything else is an installed locale name.
+ function categoryValue(category: string): string {
+ root.categoryRevision;
+ return String(root.categoryValues[category] ?? "");
+ }
+
+ function categoryLabel(category: string): string {
+ const value = root.categoryValue(category);
+ if (value === "")
+ return "Match language";
+ const match = root.locales.find(locale => locale.value === value);
+ return match ? match.label : value;
+ }
+
+ // Pass "" to remove the override. Refused for anything that is not one of
+ // the five categories: this reaches a privileged command, and the caller
+ // does not get to name the variable being written.
+ function setCategory(category: string, locale: string): bool {
+ if (root.categories.indexOf(category) < 0)
+ return false;
+ if (locale !== "" && !root.locales.some(entry => entry.value === locale))
+ return false;
+ if (root.categoryValue(category) === locale)
+ return true;
+
+ // A click while a change is still applying is queued rather than
+ // fired: assigning running = true to a running Process is a no-op, so
+ // a second setCategory would be silently dropped and applyCategory's
+ // handler would then adopt it as though it had been applied. Same
+ // queue discipline as set() above.
+ root.requestedCategories = Object.assign({}, root.requestedCategories);
+ root.requestedCategories[category] = locale;
+ if (!applyCategory.running)
+ root._applyNextCategory();
+ return true;
+ }
+
+ property var requestedCategories: ({})
+
+ function _applyNextCategory(): void {
+ const keys = Object.keys(root.requestedCategories);
+ if (keys.length === 0)
+ return;
+ const category = keys[0];
+ const value = String(root.requestedCategories[category]);
+ const remaining = Object.assign({}, root.requestedCategories);
+ delete remaining[category];
+ root.requestedCategories = remaining;
+
+ applyCategory.pendingCategory = category;
+ applyCategory.pendingValue = value;
+ applyCategory.command = [root.helperPath, "set", category, value];
+ applyCategory.running = true;
+ }
+
function refresh(): void {
if (root.scanning)
return;
root.scanning = true;
readCurrent.running = true;
+ readCategories.running = true;
list.running = true;
}
@@ -95,6 +179,49 @@ Singleton {
}
}
+ Process {
+ id: readCategories
+ command: [root.helperPath, "overrides"]
+ stdout: StdioCollector {
+ onStreamFinished: {
+ try {
+ const parsed = JSON.parse(this.text);
+ root.categoryValues = (parsed && typeof parsed === "object") ? parsed : ({});
+ } catch (error) {
+ root.categoryValues = ({});
+ console.warn("SystemLocale: could not parse the format overrides:", error);
+ }
+ root.categoryRevision += 1;
+ }
+ }
+ }
+
+ Process {
+ id: applyCategory
+
+ property string pendingCategory: ""
+ property string pendingValue: ""
+
+ // A refused change -- polkit dismissed, or a locale the system does not
+ // have -- must not move the UI. The value is only adopted on a zero
+ // exit, exactly as the language apply above does.
+ onExited: code => {
+ if (code === 0) {
+ const next = Object.assign({}, root.categoryValues);
+ next[applyCategory.pendingCategory] = applyCategory.pendingValue;
+ root.categoryValues = next;
+ root.categoryRevision += 1;
+ root.pendingRestart = true;
+ root.lastError = "";
+ } else {
+ root.lastError = "The system did not accept that format. It may have needed a password.";
+ }
+ applyCategory.pendingCategory = "";
+ applyCategory.pendingValue = "";
+ root._applyNextCategory();
+ }
+ }
+
Process {
id: apply
diff --git a/config/dot/quickshell/services/SystemSettings.qml b/config/dot/quickshell/services/SystemSettings.qml
index 578cdd2..a6550aa 100644
--- a/config/dot/quickshell/services/SystemSettings.qml
+++ b/config/dot/quickshell/services/SystemSettings.qml
@@ -30,7 +30,14 @@ Singleton {
property bool bluebubblesDetected: false
property string hyprlandVersion: ""
+
+ // Asked of the binary, never restated. The literal is only what About falls
+ // back to on a machine where `qs --version` cannot be run: a hardcoded
+ // version is a fact that goes stale the first time Quickshell updates and
+ // then reports the wrong number with complete confidence.
property string quickshellVersion: "0.3.0"
+ property bool quickshellVersionRead: false
+
property string lastError: ""
// Explicit seams keep reset sequencing testable without changing the live
@@ -53,6 +60,7 @@ Singleton {
property var lockBusy: function() { return LockScreen.busy; }
readonly property bool busy: monitorQuery.running || serviceQuery.running || versionQuery.running
+ || quickshellVersionQuery.running
|| configWrite.running || configVerify.running || bluebubblesQuery.running
readonly property bool bluebubblesAvailable: root.bluebubblesDetected
@@ -142,6 +150,25 @@ Singleton {
}
}
+ // Started once, from refresh(), and never again once it has answered --
+ // the version cannot change under a running shell. `qs --version` prints
+ // "Quickshell 0.3.1 (revision ..., distributed by ...)"; only the number
+ // is a fact About needs, and a line that does not match leaves the
+ // fallback in place rather than putting a parse failure on screen.
+ Process {
+ id: quickshellVersionQuery
+ command: ["qs", "--version"]
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const match = this.text.match(/Quickshell\s+(\d+(?:\.\d+)*)/);
+ if (match) {
+ root.quickshellVersion = match[1];
+ root.quickshellVersionRead = true;
+ }
+ }
+ }
+ }
+
Process {
id: bluebubblesQuery
command: ["flatpak", "info", "app.bluebubbles.BlueBubbles"]
@@ -176,6 +203,8 @@ Singleton {
serviceQuery.running = true;
if (!versionQuery.running && !root.hyprlandVersion)
versionQuery.running = true;
+ if (!quickshellVersionQuery.running && !root.quickshellVersionRead)
+ quickshellVersionQuery.running = true;
if (!bluebubblesQuery.running)
bluebubblesQuery.running = true;
}
diff --git a/config/dot/quickshell/services/Updates.qml b/config/dot/quickshell/services/Updates.qml
index 044aa86..f863c45 100644
--- a/config/dot/quickshell/services/Updates.qml
+++ b/config/dot/quickshell/services/Updates.qml
@@ -48,6 +48,40 @@ Singleton {
readonly property bool rebootNeeded: root.kernel?.rebootNeeded === true
readonly property bool everChecked: root.checkedAt > 0
+ // How much there is to fetch, when every pending item was priced. The
+ // helper omits the figure for a source it could only partly price, and a
+ // partial total presented as the whole download understates it -- which is
+ // the direction that surprises somebody on a metered connection.
+ readonly property int downloadBytes: Number(root.dnf?.downloadBytes ?? 0)
+ + Number(root.flatpak?.downloadBytes ?? 0)
+ + Number(root.firmware?.downloadBytes ?? 0)
+
+ readonly property string downloadSize: root.formatBytes(root.downloadBytes)
+
+ // Decimal units, matching the Storage page and About's disk row, and the
+ // way both dnf and flatpak report sizes themselves.
+ function formatBytes(bytes: int): string {
+ const value = Number(bytes ?? 0);
+ if (!(value > 0))
+ return "";
+ const units = ["B", "KB", "MB", "GB", "TB"];
+ let scaled = value;
+ let index = 0;
+ while (scaled >= 1000 && index < units.length - 1) {
+ scaled /= 1000;
+ index += 1;
+ }
+ return (scaled < 10 && index > 1 ? scaled.toFixed(1) : Math.round(scaled))
+ + " " + units[index];
+ }
+
+ function sourceDownloadSize(source: string): string {
+ const record = source === "dnf" ? root.dnf
+ : source === "flatpak" ? root.flatpak
+ : source === "firmware" ? root.firmware : null;
+ return root.formatBytes(Number(record?.downloadBytes ?? 0));
+ }
+
// What has actually been installed, newest first, from both sources at
// once. Loaded on demand rather than with the snapshot: it asks dnf and
// flatpak for their whole transaction log, which is not worth doing every
@@ -155,6 +189,100 @@ Singleton {
applyProcess.running = true;
}
+ // One application rather than all of them. The ID is checked against the
+ // last scan by the helper, so a page that has gone stale cannot ask for
+ // something that is not actually waiting.
+ function applyFlatpakApp(id: string): void {
+ if (applyProcess.running || !id)
+ return;
+ root.lastError = "";
+ root.lastApplied = null;
+ applyProcess.command = [root.helperPath, "apply", "flatpak", id];
+ applyProcess.running = true;
+ }
+
+ // ── Changelogs, fetched once per item ───────────────────────────────────
+ //
+ // Asking dnf what changed costs a metadata load, so an expander that
+ // re-asked every time it opened would spend seconds re-learning the same
+ // answer. Records are keyed "source/name" and kept for the life of the
+ // shell; the update they describe cannot change while it is pending.
+
+ property var changelogs: ({})
+
+ // Bumped whenever a record lands, and read at the top of changelogFor() so
+ // a binding built on that call has something to invalidate. A bare
+ // function call captures no dependency and every reader would go stale --
+ // the same reason DesktopPreferences.get() reads its revision.
+ property int changelogRevision: 0
+
+ // [{source, name}] waiting their turn. One at a time rather than one
+ // process per expander: each fetch loads repository metadata, and running
+ // several concurrently would multiply that work for no benefit -- nobody
+ // reads two changelogs at once.
+ property var changelogQueue: []
+
+ readonly property bool loadingChangelog: changelogProcess.running
+
+ // Returns the record for an item, or null while one is being fetched.
+ // Starting the fetch is a side effect on purpose: the page asks for a
+ // changelog by rendering one, and there is nothing else to ask.
+ function changelogFor(source: string, name: string): var {
+ root.changelogRevision;
+ const key = source + "/" + name;
+ const known = root.changelogs[key];
+ if (known !== undefined)
+ return known;
+ if (!source || !name)
+ return null;
+ if (changelogProcess.key === key
+ || root.changelogQueue.some(entry => entry.source + "/" + entry.name === key))
+ return null;
+ root.changelogQueue = root.changelogQueue.concat([{ source: source, name: name }]);
+ root.pumpChangelogs();
+ return null;
+ }
+
+ function pumpChangelogs(): void {
+ if (changelogProcess.running || root.changelogQueue.length === 0)
+ return;
+ const next = root.changelogQueue[0];
+ root.changelogQueue = root.changelogQueue.slice(1);
+ changelogProcess.key = next.source + "/" + next.name;
+ changelogProcess.outputText = "";
+ changelogProcess.exited = false;
+ changelogProcess.streamFinished = false;
+ changelogProcess.command = [root.helperPath, "changelog", next.source, next.name];
+ changelogProcess.running = true;
+ }
+
+ function settleChangelog(): void {
+ if (!changelogProcess.exited || !changelogProcess.streamFinished
+ || changelogProcess.key === "")
+ return;
+ root.absorbChangelog(changelogProcess.key, changelogProcess.outputText);
+ changelogProcess.key = "";
+ root.pumpChangelogs();
+ }
+
+ function absorbChangelog(key: string, text: string): void {
+ let record = { kind: "none", text: "", error: "The changelog could not be read." };
+ try {
+ const parsed = JSON.parse(text);
+ record = {
+ kind: String(parsed.kind ?? "none"),
+ text: String(parsed.text ?? ""),
+ error: String(parsed.error ?? "")
+ };
+ } catch (error) {
+ console.warn("Updates: could not parse changelog output:", error);
+ }
+ const next = Object.assign({}, root.changelogs);
+ next[key] = record;
+ root.changelogs = next;
+ root.changelogRevision += 1;
+ }
+
function setAutomaticDnf(enabled: bool): void {
if (applyProcess.running)
return;
@@ -220,4 +348,34 @@ Singleton {
id: historyProcess
stdout: StdioCollector { onStreamFinished: root.absorbHistory(this.text) }
}
+
+ Process {
+ id: changelogProcess
+
+ // Which record the output belongs to. Held on the process rather than
+ // read back from the payload so a reply that failed to name itself
+ // still lands under the key that was asked for, instead of silently
+ // going nowhere and leaving the expander spinning forever.
+ property string key: ""
+
+ // Exit and stream-close arrive in either order. Settling on both --
+ // the same pair Health.qml waits on -- is what stops a reply being
+ // filed under an already-cleared key, which would leave the expander
+ // waiting on an answer that had in fact already arrived.
+ property string outputText: ""
+ property bool exited: false
+ property bool streamFinished: false
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ changelogProcess.outputText = this.text;
+ changelogProcess.streamFinished = true;
+ root.settleChangelog();
+ }
+ }
+ onExited: (exitCode, exitStatus) => {
+ changelogProcess.exited = true;
+ root.settleChangelog();
+ }
+ }
}
diff --git a/config/dot/quickshell/settings-backup-harness.qml b/config/dot/quickshell/settings-backup-harness.qml
index c246001..7a9165a 100644
--- a/config/dot/quickshell/settings-backup-harness.qml
+++ b/config/dot/quickshell/settings-backup-harness.qml
@@ -141,6 +141,25 @@ ShellRoot {
return SettingsBackup.handleRestoreOutput(output);
}
+ // The two pure functions behind a display restore, exposed directly.
+ // Everything else here is exercised through handleRestoreOutput, but
+ // the colour half of a layout is invisible from there: a snapshot
+ // whose colour fields were dropped restores geometry perfectly and
+ // reports success, which is exactly why nothing caught it for as long
+ // as it did.
+ function layoutFor(storedJson: string): string {
+ return JSON.stringify(
+ SettingsBackup.layoutFromStoredDisplays(JSON.parse(storedJson)));
+ }
+
+ // Both layouts in one object rather than two arguments: the IPC client
+ // flattens a top-level JSON array into one argument per element, so a
+ // pair of two-monitor layouts arrives as four arguments.
+ function layoutsMatch(pairJson: string): bool {
+ const pair = JSON.parse(pairJson);
+ return SettingsBackup.layoutsEqual(pair.left, pair.right);
+ }
+
function status(): string {
return JSON.stringify({
calls: root.calls,
diff --git a/config/local/share/vicinae/scripts/settings-about b/config/local/share/vicinae/scripts/settings-about
index 37b7906..df90912 100755
--- a/config/local/share/vicinae/scripts/settings-about
+++ b/config/local/share/vicinae/scripts/settings-about
@@ -5,6 +5,6 @@
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Open About in Settings.
-# @vicinae.keywords ["settings", "system information", "desktop version"]
+# @vicinae.keywords ["settings", "system information", "desktop version", "hostname", "kernel version", "device model", "installed memory", "uptime", "serial number", "bios version"]
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page about
diff --git a/config/local/share/vicinae/scripts/settings-datetime b/config/local/share/vicinae/scripts/settings-datetime
index 7e8a3bd..13e70fe 100755
--- a/config/local/share/vicinae/scripts/settings-datetime
+++ b/config/local/share/vicinae/scripts/settings-datetime
@@ -1,10 +1,10 @@
#!/usr/bin/env bash
# Generated by scripts/panama-settings-commands -- do not edit by hand.
# @vicinae.schemaVersion 1
-# @vicinae.title Settings: Date & Time
+# @vicinae.title Settings: Date, Time & Region
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
-# @vicinae.description Open Date & Time in Settings.
-# @vicinae.keywords ["settings", "24-hour time", "timezone", "network time"]
+# @vicinae.description Open Date, Time & Region in Settings.
+# @vicinae.keywords ["settings", "24-hour time", "timezone", "network time", "language", "regional formats", "date format", "number format", "currency", "measurement units", "paper size", "first day of the week", "set the clock by hand"]
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page datetime
diff --git a/config/local/share/vicinae/scripts/settings-region b/config/local/share/vicinae/scripts/settings-region
deleted file mode 100755
index be0e2b9..0000000
--- a/config/local/share/vicinae/scripts/settings-region
+++ /dev/null
@@ -1,10 +0,0 @@
-#!/usr/bin/env bash
-# Generated by scripts/panama-settings-commands -- do not edit by hand.
-# @vicinae.schemaVersion 1
-# @vicinae.title Settings: Region & Language
-# @vicinae.mode silent
-# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
-# @vicinae.description Open Region & Language in Settings.
-# @vicinae.keywords ["settings", "language", "regional formats"]
-
-exec "$HOME/.config/quickshell/scripts/panama-action" settings-page region
diff --git a/config/local/share/vicinae/scripts/settings-sync b/config/local/share/vicinae/scripts/settings-sync
index 36876d9..da62fc3 100755
--- a/config/local/share/vicinae/scripts/settings-sync
+++ b/config/local/share/vicinae/scripts/settings-sync
@@ -5,6 +5,6 @@
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Open Sync & Backup in Settings.
-# @vicinae.keywords ["settings", "restore defaults", "carry settings to another machine", "settings backups"]
+# @vicinae.keywords ["settings", "restore defaults", "carry settings to another machine", "export settings", "import settings", "settings backups"]
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page sync
diff --git a/docs/settings.md b/docs/settings.md
index f716ee0..42897b6 100644
--- a/docs/settings.md
+++ b/docs/settings.md
@@ -87,7 +87,7 @@ Found on **Shell › Control Center**.
## datetime
-Found on **System › Date & Time**.
+Found on **System › Date, Time & Region**.
| Setting | Default | What it does |
|---|---|---|
diff --git a/docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md b/docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md
index 8f3db47..a86eee0 100644
--- a/docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md
+++ b/docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md
@@ -1672,3 +1672,235 @@ settings generator writes 38 of them).
`search-routing-contract`, `ipc-targets-contract`, `settings-jump-contract`,
`settings-docs-contract`, `readme-contract`, all eight of `tests/hypr/`),
then the sandboxed-harness ones, then the live settings window last.
+
+## Phase 15 (System) — the last one
+
+Spec: `2026-08-24-system-redesign.md`. System went from ten tabs to eight:
+Region & Language merged into **Date, Time & Region** — a date format and the
+clock that shows it are one subject, and splitting them meant changing how a
+date is written on a page that never showed one — and the Manual became a
+routable leaf with no tab, opened from About's Manual card, a deep link, or the
+search box rather than found by scanning a strip. With that, the settings
+redesign is complete: fourteen categories, thirty-eight leaves, every one of
+them rebuilt.
+
+The bug list this phase carried was longer than the layout change. The
+hardcoded Quickshell version, the auto-download switch nested inside the
+Firmware card, `CHECK_TITLES` missing `panama.updates`, two Health actions with
+no target, RegionPage's stale "Open appearance" handoff, `panama-updates`'
+stale dnf-automatic comment, the backup colour gap, Restore without
+confirmation, and four pieces of dead code.
+
+Three agents edited the tree concurrently; everything below was reconciled
+against the landed files rather than against the spec's pinned shapes, and
+every contract named here was **run**.
+
+### No new contracts (175 → 175)
+
+Every pin fitted a file that already existed, so the suite did not grow. That
+was the judgement call this phase offered and it went the honest way: a
+`system-pages-contract` would have been a new file holding assertions that
+belong beside the ones they are variations of — the nav counts beside the nav
+taxonomy, the repair command beside the repair, the single-check verb beside
+the scan. `setup/readme-contract` — RUN, PASS ("175 contracts, as documented").
+
+### What each contract gained, and why
+
+- **`quickshell/settings-nav-contract` — RUN, PASS** (14 categories, 38 leaves
+ of which 1 hidden, 3 retired ids). It learned the third leaf shape:
+ `SettingsRoutes.hiddenLeaves`, which is how the manual stays addressable
+ without a tab. A hidden leaf that is also a tab, or also a category, now
+ fails — that overlap is the one the existing checks could not see, because
+ the hidden half draws no row anywhere. Then: the System strip is pinned at
+ exactly eight tabs in order (the number IS the point — it is the horizontal
+ space one row has, and an eleventh subject needs a decision, not another
+ entry), `region` is pinned to resolve specifically to `datetime` rather than
+ merely to *a* leaf, `RegionPage.qml` must be gone, and the manual must be a
+ leaf and must not be a tab.
+ It also grew the check that would have caught the retired search entries:
+ every page id in `SettingsSearch` — extra entries and group routes alike —
+ must be a live leaf, with a distinct failure message when it is a retired id,
+ since that is the case that still opens a window and lands somewhere else.
+ Plus a table of the fifteen subjects the consolidation moved, each asserted
+ to be findable by its own name on the tab that now owns it.
+- **`quickshell/settings-pages-contract` — RUN, PASS** (production PIDs
+ preserved). `Updates`, `DateTime`, `Containers` and `Manual` joined the
+ page-scaffold sweep; Containers is the one that was waiting for it, since its
+ root was a bare `Item` and every convention the scaffold carries was
+ hand-rolled there. The live routing list gained `updates`, `datetime` and
+ `containers`, and the retired `region` id is now driven through the real IPC
+ and asserted to land on `datetime`.
+- **`quickshell/updates-contract` — RUN, PASS** (2 dnf, 3 flatpak, 0 firmware).
+ Three additions. **Download sizes add up or are not offered**: a total is
+ present only when every pending item was priced, because a partial figure
+ shown as the download understates it, and understating it is the direction
+ that costs somebody money on a metered connection. **A changelog is a read**,
+ proved by construction rather than by inspection: `dnf5`, `flatpak`,
+ `fwupdmgr`, `pkexec` and `systemctl` are replaced with stubs that record
+ their argv and answer nothing, and the recorded argv is then checked for
+ mutating tokens — which also exercises the honest-absence path, the common
+ case on a machine with third-party repositories. Names outside
+ `NAME_PATTERN` must be refused before anything is launched. **One
+ application, by name**: the per-app argv is asserted exactly
+ (`flatpak update -y --noninteractive `) against a stub, and an ID the
+ last scan did not list must be refused without running flatpak at all.
+- **`quickshell/panama-doctor-contract` — RUN, PASS.** The `CHECK_TITLES`
+ KeyError is pinned twice: as the invariant (every id in `CHECK_ORDER` has a
+ title, asserted by importing the module) and as the path (a probe forced to
+ raise must come back as a titled warning row rather than taking the report
+ with it). Worth both — the bug needed the updates probe to fail, which it
+ almost never does, and the containment that was supposed to catch it *was*
+ the KeyError, raised inside the `except` handling the original failure.
+ Then: the updates action's `target: "updates"`, driven by a fixture cache
+ with a security count; `repairCommand` asserted against `REPAIR_COMMANDS`
+ itself rather than restated, so an argv change cannot leave the row
+ describing the old one; and the `check CHECK_ID` verb — full snapshot
+ envelope, exactly one check, summary arithmetic covering only that check,
+ refusal of unauthored ids with no report and no filesystem mutation, and no
+ second output mode.
+ The fixture also gained `XDG_CACHE_HOME`, which was leaking: `check_updates`
+ reads the Updates cache from the environment rather than through
+ `DoctorConfig`, so the contract had been reading the real machine's pending
+ updates.
+- **`quickshell/health-service-contract` — RUN, PASS.** The both-sides pin is
+ derived from both files: every `open`/`instructions` target the doctor emits
+ must appear in Health's `settingsTargets`/`instructionTargets`. This is the
+ one where half a change does not degrade a row, it blanks the page —
+ `validAction` false makes `validCheck` false makes `consumeSnapshot` reject
+ the whole report. Live fixtures either side of it: a check targeting
+ `updates` is accepted intact, and one targeting `storage` — a real page the
+ list deliberately omits — is rejected, which is what makes the allow-list an
+ allow-list. Also `repairCommand` surviving the projection (a new known key is
+ dropped silently unless it is added, and the failure is invisible: the repair
+ still works, only the sentence saying what it runs is gone), a check without
+ one still accepted, `refreshCheck` asking for `check ` and never `--json`
+ while leaving the other rows and recomputing the headline counts, an unknown
+ id starting no process, and `saveReport` writing the same redacted projection
+ to a named file.
+- **`quickshell/health-ui-contract` — RUN, PASS.** The Colour profiles handoff
+ assertion is inverted rather than deleted (Displays owns colour profile, bit
+ depth, SDR brightness and saturation per output, which is more than GNOME's
+ panel can say in a session it does not manage); Digital wellbeing stays. The
+ repair command must be shown, and must not be gated on `lastRepair`,
+ `repairingId` or a working state — after the fact is not the same answer,
+ because by then the decision is made. A per-row re-check and a save-report
+ row must exist. The fixture grew a `panama.updates` check whose action is
+ followed for real through `requestHealthAction`, landing on `updates`: the
+ third piece of the chain the doctor and the service each pin one end of.
+- **`quickshell/settings-backup-contract` — RUN, PASS.** Named snapshots
+ (fifteen rows reading `2026-08-24 11:03:07` are fifteen rows nobody can
+ choose between), with the name reaching a filename — which is the oldest way
+ a helper gets talked into writing outside its own directory. Seven hostile
+ names, each of which must either fail or produce a file inside the backup
+ directory. Delete gets the same confinement as restore in the other
+ direction, and a worse failure mode: restore reading the wrong file
+ overwrites settings, delete resolving the wrong name destroys something that
+ is not a backup. Traversal, absolute paths, an escaping symlink, an
+ already-deleted name and an empty name all refused. `bytes` per row, checked
+ against `stat`.
+- **`quickshell/settings-backup-live-contract` — RUN, PASS.** The colour gap,
+ both halves. `layoutFromStoredDisplays` must carry `vrrMode`,
+ `colorProfile`, `bitdepth`, `sdrBrightness`, `sdrSaturation` and `mirrorOf`
+ through, and must still accept a record written before they existed.
+ `layoutsEqual` must detect a difference in each of them — **this is the
+ assertion the shipped behaviour fails**, and the reason the restore silently
+ did nothing: the seven-field comparison called a snapshot with different
+ colour settings equal, so the restore took the early return and reported
+ success while leaving HDR off. Plus the inverse for the two float fields: a
+ value that came back one ulp different is the same value, or every restore
+ reapplies the layout it already has.
+ Two harness seams were added for this (`layoutFor`, `layoutsMatch`), because
+ the colour half is invisible from `handleRestoreOutput` — geometry restores
+ perfectly and success is reported either way.
+- **`quickshell/settings-sync-contract` — RUN, PASS.** The preview's `changes`
+ must be `{key, from, to}` with both sides already text, a `changeCount`
+ agreeing with what would be applied, a rendered list no longer than that
+ count, and no side over 200 characters — with a bundle carrying a 4000-
+ character `weatherLocation` proving the cap is real rather than incidental.
+ Stringifying in the helper rather than in QML is what makes the cap
+ enforceable at all. Observed on this machine: 115 changes, 40 rendered,
+ absent values rendered as "not set".
+- **`quickshell/manual-contract` — RUN, PASS** (5 chapters). Titles now come
+ from the files: the chapter list moved to `ManualChapters.qml`, which both
+ the reader and About's card instantiate, and the contract fails on any
+ `label:` beside a filename — the drift it is guarding against had already
+ happened, under a comment claiming it could not. It also fails if either page
+ names chapter files itself. In-app links: the handler must route
+ `panama://settings/` through `ShellState.openSettings` while still
+ sending everything else out of the desktop, asserted by following the call
+ (inline block or named function, brace-matched) rather than by reading one
+ line, and every such link written in a chapter must name a real leaf. Plus
+ the taxonomy check inverted: the manual must be a hidden leaf and must NOT be
+ a System tab.
+- **`quickshell/gnome-handoff-contract` — RUN, PASS** (6 handoffs checked
+ against 37 pages). Two panels joined `OWNED`: `system region`, because Date,
+ Time & Region now offers a language picker, four per-category format
+ dropdowns backed by the installed locales, and a live preview of what each
+ renders; and `color`, because Displays has offered a colour profile per
+ output for some time. Both are derived rather than hand-asserted, so the door
+ cannot come back under a name nobody thought to list. Plus two inverses:
+ `RegionPage.qml` must stay gone, and `DateTimePage` must not open a GNOME
+ panel for something it now does itself.
+- **`quickshell/search-routing-contract` — RUN, PASS** (146 routed settings).
+ Unchanged; run because `SettingsSearch` moved.
+
+### Search entries
+
+Two moved: Language and Regional formats, from `region` to `datetime`. Fifteen
+added: the seven About facts nobody could search for (Hostname, Kernel version,
+Device model, Installed memory, Uptime, Serial number, BIOS version — none of
+them the label of a preference, all of them things people arrive looking for by
+name, and "Installed memory" rather than "Memory" because that one IS already a
+schema label on Bar); the two Sync verbs (Export settings, Import settings —
+"Carry settings to another machine" was the only entry, and nobody searches for
+a sentence); and six format subjects on the merged tab (Date format, Number
+format, Currency, Measurement units, Paper size, First day of the week) plus
+"Set the clock by hand".
+
+### Schema docs and launcher commands regenerated
+
+`quickshell/scripts/panama-settings-docs` and `panama-settings-commands` both
+run without `--check` and their output committed. `docs/settings.md` stays at
+**174 settings across 36 groups** — no preference was added this phase — but
+the `datetime` group is now "Found on **System › Date, Time & Region**", which
+is the whole point of a generated document: the tab was renamed in one place.
+
+The launcher went 38 generated commands to **37**: `settings-region` was
+removed, and `settings-manual` survives because the generator learned about
+`hiddenLeaves` — a leaf with no tab is the one leaf that cannot be found by
+scanning, so it is the one that most needs a launcher entry. Keywords picked up
+the new search entries on their own: `settings-about` gained hostname, kernel
+version, device model, installed memory, uptime, serial number and BIOS
+version; `settings-datetime` gained language, regional formats and the six
+format subjects; `settings-sync` gained export and import.
+
+`quickshell/settings-docs-contract` — RUN, PASS (174 settings documented).
+`quickshell/panama-commands-contract` — RUN, PASS (76 commands), after teaching
+its own leaf derivation about `hiddenLeaves` too: it counted 75 and found 76,
+which is the generator and the contract disagreeing about what a leaf is
+rather than a stale command. `quickshell/qmldir-registration-contract` — RUN,
+PASS (189 components), covering the two new ones, `ManualChapters` and
+`FieldActionRow`.
+
+### Deferred, and why
+
+- **`quickshell/settings-search-contract` — NOT RUN.** It daemonizes a
+ Quickshell instance against the user's real XDG directories rather than a
+ scratch set, and constructing `SettingsSearch` calls
+ `DesktopStyle.ensureStarted()`, which replays application preferences to the
+ live GTK configuration. Every case it pins was checked statically instead:
+ none of the fifteen new labels equals or prefixes any of the twenty-one
+ pinned queries, so none can outrank one, and every schema label is still
+ reachable because the schema half of the index did not change. The new static
+ page-id sweep in `settings-nav-contract` covers the failure this phase could
+ actually have introduced.
+- **Nobody has typed a locale into the four format dropdowns.** The category
+ round-trip is A's, through `panama-locale`; no `localectl` write was made
+ from any contract here, and none should be.
+- **The manual's in-app links have not been clicked.** The handler is pinned
+ structurally and every link a chapter writes is checked against the taxonomy,
+ but no chapter currently writes one, so the routing has been proven correct
+ and never exercised.
+- **No backup has been restored against the real store**, and no update has
+ been applied. Both are pinned through stubs and scratch directories, which is
+ the sanctioned path and the only one that should ever run here.
diff --git a/docs/superpowers/specs/2026-08-24-system-redesign.md b/docs/superpowers/specs/2026-08-24-system-redesign.md
new file mode 100644
index 0000000..dab3138
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-24-system-redesign.md
@@ -0,0 +1,143 @@
+# System redesign — the finale, 10 tabs become 8
+
+Approved mock: `home-mocks/system.html` (scratchpad, :8642). Spec wins over mock on conflict.
+Storage, Snapshots, Containers keep their shipped designs (Containers gets the root-type fix
+only). TEST GRANT ACTIVE: agents may RUN contracts related to their work as they build; the
+full suite stays the orchestrator's call.
+
+## Consolidation (pinned)
+
+System tabs become: About · Software Update · System Health (`services`) · Storage ·
+Snapshots · Containers · **Date, Time & Region** (`datetime`) · Sync & Backup. The `region`
+tab retires (retired-map → `datetime`). The `manual` tab retires as a TAB but `manual` stays
+a routable leaf (opened from About's Manual card and existing deep links) — B determines the
+mechanism (hidden-leaf list or equivalent) and C reconciles `settings-nav-contract`'s
+category/leaf counts.
+
+## Bug kills (all in scope)
+
+`SystemSettings.quickshellVersion` hardcoded "0.3.0" → read `qs --version`; the auto-download
+switch nested in the Firmware card; Health's target-less "Open Software Update" actions (add
+`target: "updates"` AND extend `Health.settingsTargets` in the same change — adding one
+without the other invalidates whole snapshots); `CHECK_TITLES` missing `panama.updates`
+(KeyError on timeout); the `SettingsShell.healthDiagnostics` TypeError (guard the binding on
+`typeof item.uiDiagnostics === "function"`); RegionPage's stale "Open appearance" handoff;
+`panama-updates`' stale dnf-automatic comment; the backup **color gap**
+(`layoutFromStoredDisplays` + `layoutsEqual` extended to `vrrMode`, `colorProfile`,
+`bitdepth`, `sdrBrightness`, `sdrSaturation`, `mirrorOf` — optional/back-compat like
+`Displays.isPersistedLayoutEntry`); Restore without confirmation; stray `settings.json.*`
+temp files (clean once, and if the writer can leak them, fix the leak); dead code
+(`Containers.wouldExpose`, `SettingsSync.left`, `SettingsBackup.lastAction`,
+`HealthPage.statusLabel`; `DateTime.localTime/universalTime/rtcTime` become USED by the
+manual-set flow rather than deleted).
+
+## A — services & scripts (pinned APIs)
+
+- `scripts/panama-about`: rows gain `Firmware` (DMI `bios_version` + `bios_date`, bootctl
+ only as fallback — it reports "n/a" here) and `Secure Boot` (mokutil, absent-tolerant); a
+ `Panama` row (`git describe --tags --always --dirty` + `%cr`, absent-tolerant outside a
+ checkout), placed right after `Operating system`. `SystemSettings.quickshellVersion` now
+ parsed from `qs --version`, kept as a one-shot Process started from `refresh()` and
+ guarded by `quickshellVersionRead` — so a plain binding on `quickshellVersion` is enough
+ and B needs no extra call. The literal stays as the fallback only.
+- `scripts/panama-updates`: `changelog ` → `{source, name, kind, text, error}`
+ where `kind` is `advisory` | `changelog` | `none` (dnf: `dnf5 advisory info --json
+ --updates --contains-pkgs=` first, else `dnf5 changelog --upgrades` with dnf's two header
+ lines stripped; flatpak: `remote-info --cached --log` History, which Flathub does not
+ populate, so in practice the honest "publishes no release notes"; firmware: the fwupd
+ release notes cached by `check`). Names constrained by `NAME_PATTERN` before argv.
+ `check` gains per-item `bytes` and per-source `downloadBytes`, present only when EVERY
+ item in that source was priced (dnf from `dnf5 repoquery --queryformat %{downloadsize}`,
+ flatpak parsed from its rendered `download-size` column — its `--json` omits the column).
+ `apply flatpak ` appends one ID, checked against the last scan. Stale
+ dnf-automatic comments fixed; `DNF_TIMER` named. `services/Updates.qml`:
+ `changelogFor(source, name)` → record or `null`, cached by `source/name`, serial queue,
+ reactive via `changelogRevision` (read it in the binding, DesktopPreferences.get pattern);
+ `applyFlatpakApp(id)`; `downloadBytes` + `downloadSize` (string) + `sourceDownloadSize(s)`
+ + `formatBytes(n)`; `loadingChangelog`.
+- `scripts/panama-doctor`: `CHECK_TITLES["panama.updates"]`; updates actions get
+ `target: "updates"`; snapshot gains `repairCommand` (a joined argv string) on the five ids
+ in REPAIR_COMMANDS only — the three in-process repairs have no command line and claim
+ none; new `panama-doctor check ` verb printing a FULL snapshot envelope holding that
+ one check (same schema, same summary arithmetic, so callers reuse `validSnapshot`).
+ `services/Health.qml`: `settingsTargets` gains "updates"; `refreshCheck(id)` (splices one
+ validated row in, recomputes the summary, drops a reply the newer full scan superseded)
+ with `refreshingId` / `refreshingCheck`; `saveReport(path)` via `tee` (default
+ `defaultReportPath` = `~/panama-health-report.txt`) reporting through `lastSaveResult`,
+ and `reportText()` for the plain-text body; `safeCheck`/`validCheck` carry `repairCommand`.
+- `services/DateTime.qml`: `setTime(iso)` via `timedatectl set-time` (validated against
+ `timePattern` = `YYYY-MM-DD HH:MM[:SS]` AND round-tripped through Date, refused while NTP
+ is on AND while `statusRead` is still false — "not looked yet" is not "off"); `localTime`
+ / `universalTime` / `rtcTime` wired from `TimeUSec`/`RTCTimeUSec`, advanced locally once a
+ second while `DateTime.tracking` is true (B sets it while the Clock card is on screen; no
+ process per tick).
+- `scripts/panama-locale` + `services/SystemLocale.qml`: `get [cat]` / `set `
+ / `categories` / `overrides` (one JSON object for all five) — `LC_TIME`, `LC_NUMERIC`,
+ `LC_MONETARY`, `LC_MEASUREMENT`, `LC_PAPER`. "Match language" is `""` and is implemented by
+ re-issuing `localectl set-locale` with every OTHER assignment, since localectl replaces
+ locale.conf with exactly what it is given. Service exposes `categories`,
+ `categoryValue(cat)` (`""` = match language), `categoryLabel(cat)`, `setCategory(cat,
+ locale)`, `categoryRevision` (read it in bindings), `busy`, and the pendingRestart
+ discipline.
+- `scripts/panama-settings-backup`: `create [name] [homeState]` (label sanitized by
+ `LABEL_RE`, stored INSIDE the envelope — the filename stays the timestamp SNAPSHOT_RE
+ pins, since that is what ordering, pruning and restore confinement rely on), `delete
+ ` (through `snapshot_source`, the same confinement gate restore uses), list gains
+ `bytes` and `label`. Stale-temp sweep extended to Quickshell's QSaveFile leftovers
+ (`settings.json.XXXXXX`, no leading dot) with a one-hour age guard so an in-flight write
+ is never destroyed. `services/SettingsBackup.qml`: `create(name)`, `deleteBackup(name)`,
+ dead `lastAction` removed, the color-gap fix per above (`storedDisplayFields`,
+ `validStoredField`, both used by `layoutFromStoredDisplays` and `layoutsEqual`).
+- `services/SettingsSync.qml` + helper: preview output gains `changes: [{key, from, to}]`
+ with both values rendered to text by the helper (`render()`, `VALUE_LIMIT` 120), capped at
+ `CHANGE_LIMIT` 40 with a `changeCount` for what is not shown; the import still applies
+ everything. Service gains `changeCount`; dead `left` removed.
+- `modules/settings/SettingsShell.qml`: the healthDiagnostics guard (A owns this single
+ edit — B is told hands-off that binding).
+
+## B — UI
+
+`AboutPage.qml` showpiece per mock (hero, truthful versions block, Hardware card with
+GraphicsDevices joined, Device-name → Sharing row, Manual card listing chapters with titles
+READ FROM THE FILES (first heading), Read → `openSettings("manual")` + section); Design
+principles card dropped (the manual carries the philosophy). `ManualPage.qml`: chapter titles
+from file first-headings (make the old comment true); `onLinkActivated` routes
+`panama://settings/` (or equivalent scheme) links through `ShellState.openSettings`,
+everything else external; keep the tabs/reader shape. `UpdatesPage.qml`: restructure per
+mock (headline; System packages with changelog expanders + download size; Automatic card
+with BOTH switches; Applications & firmware rows with per-app flatpak updates when present;
+history kept). `DateTimePage.qml` becomes the merged tab (Clock card with manual-set
+revealed when NTP off; Timezone; Language & formats card — language picker, four category
+dropdowns [Match language + installed locales], first-day-of-week fact row, live preview
+grid from `Qt.locale()` for the *chosen* format locales); `RegionPage.qml` DELETED (retired
+route). `HealthPage.qml`: repair rows show `repairCommand` in the detail before running,
+per-check Re-check button (`refreshCheck`), Save-report row, Color-profiles handoff row
+REMOVED (Digital wellbeing stays), the updates action now navigating. `SyncPage.qml`:
+preview diff list from `changes` (mono-ish tabular rows, del/add tones), Restore + Reset
+two-stage confirms (the house danger pattern), backup rows with name field on create +
+size + Delete…, reset subtitle mentions themes. `ContainersPage.qml`: root becomes
+`SettingsPage` per convention (logs drill-in preserved). `SettingsRoutes.qml`: the 8-tab
+strip + region retired + manual leaf mechanism (KEEP the categories array literal-shaped).
+
+## C — periphery
+
+Search: hostname/kernel/model/memory/uptime/serial → about; export settings/import
+settings → sync; currency/formats/measurement/paper/first day → datetime; region retired
+routing verified; manual entries keep working (leaf). Contracts: `settings-nav-contract`
+(counts + retired map), `settings-pages-contract` (routing list: add updates/containers/
+datetime, drop region), `updates-contract` (changelog verb read-only pins; per-app flatpak
+apply argv), health contracts (target "updates" validity BOTH sides, repairCommand shown
+before run, single-check verb full-shape, KeyError fixture), `settings-backup-contract` +
+`-live` (extended fields round-trip — a stored record with color fields restores them and
+`layoutsEqual` detects color-only drift; delete confinement; name sanitation),
+`settings-sync-contract` (changes shape, no secrets in stringified values), `manual-contract`
+(titles-from-files, in-app link routing), `gnome-handoff-contract` (region handoffs gone —
+consider OWNED), NEW `system-pages-contract` if the pins don't fit existing files (C's
+judgment; keep count growth honest). Backlog Phase 15. README count line. Docs/commands
+regen at the end. RUN the contracts you write/touch as you go (grant active); report results.
+
+Hard rules still: no live mutations of system state (no dnf/flatpak installs, no
+timedatectl/localectl writes, no backup restores against the real store — the
+scratch-daemonizing harness pattern and hermetic stubs are the sanctioned test paths, and
+the grant means you may RUN those freely). Valid QML at every save. B programs against A's
+pinned APIs; A updates this spec before changing them.
diff --git a/tests/quickshell/declared-assets-contract b/tests/quickshell/declared-assets-contract
index 3c041f8..a7ddcea 100755
--- a/tests/quickshell/declared-assets-contract
+++ b/tests/quickshell/declared-assets-contract
@@ -159,7 +159,9 @@ qml_package() {
# Provided by the base system or the shell itself; nothing installs these
# separately, and listing them would be noise.
-QML_BASELINE='^(sh|bash|rm|test|pkill|systemd-inhibit|loginctl|timedatectl|gsettings|gapplication|systemctl|busctl)$'
+# `qs` is Quickshell itself -- if the shell is running QML, qs is by
+# definition present, so it needs no package list entry.
+QML_BASELINE='^(sh|bash|rm|test|pkill|systemd-inhibit|loginctl|timedatectl|gsettings|gapplication|systemctl|busctl|qs)$'
while IFS= read -r command_name; do
[[ -n "$command_name" ]] || continue
diff --git a/tests/quickshell/declared-dependencies-contract b/tests/quickshell/declared-dependencies-contract
index 5c34593..bfa9ea8 100755
--- a/tests/quickshell/declared-dependencies-contract
+++ b/tests/quickshell/declared-dependencies-contract
@@ -38,7 +38,9 @@ SHELL_WORDS='^(if|then|else|elif|fi|for|while|until|do|done|case|esac|in|functio
# fingerprint aliases in config/bash can rely on it without declaring it.
BASELINE='^(sh|bash|cat|cut|sed|awk|gawk|grep|egrep|head|tail|sort|uniq|tr|wc|find|xargs|basename|dirname|mkdir|rm|cp|mv|ln|chmod|chown|stat|df|du|date|sleep|env|id|tee|touch|mktemp|readlink|realpath|seq|comm|join|paste|od|file|nl|fold|column|tput|timeout|flock|install|sha256sum|md5sum|base64|nproc|uptime|free|uname|hostname|whoami|ps|pgrep|pkill|kill|killall|lsblk|mount|umount|sudo|su|rpm|dnf|flatpak|git|python3|ss|ip|ls|rfkill|lsof|authselect|setsid|nohup|grub2-mkconfig)$'
-SESSION='^(systemctl|busctl|journalctl|loginctl|hostnamectl|localectl|systemd-inhibit|systemd-run|udevadm|gsettings|dconf|dbus-send|dbus-monitor|hyprctl|qs|quickshell|gnf|panama|wl-copy|wl-paste)$'
+# bootctl ships in systemd-udev, which every Fedora install carries -- it is
+# the udev half of systemd, not an optional tool.
+SESSION='^(systemctl|busctl|journalctl|loginctl|hostnamectl|localectl|systemd-inhibit|systemd-run|udevadm|bootctl|gsettings|dconf|dbus-send|dbus-monitor|hyprctl|qs|quickshell|gnf|panama|wl-copy|wl-paste)$'
# Installed by install-packages itself rather than by a package list. Two
# reasons, both deliberate: bun and claude have no RPM or flatpak at all, and
diff --git a/tests/quickshell/gnome-handoff-contract b/tests/quickshell/gnome-handoff-contract
index 2b8e0ef..e3d7ccf 100755
--- a/tests/quickshell/gnome-handoff-contract
+++ b/tests/quickshell/gnome-handoff-contract
@@ -55,6 +55,21 @@ fail() {
# Fedora", explaining that GNOME's file-history switches would not take effect
# in a Hyprland session anyway -- is gone, because the switches it was
# apologizing for are now buttons that work.
+# "color" joined with the same argument, one page over. System Health's Fedora
+# card offered Color profiles because Panama's Displays page did not have them;
+# it has had them per display for some time -- automatic, sRGB, wide gamut or
+# HDR, with bit depth and SDR brightness beside them -- so the row was pointing
+# at GNOME for something the desktop's own display page does better, since it
+# is the page that knows which output you mean.
+#
+# "system region" joined when Region & Language stopped being a page that
+# offered a language picker and a link, and its contents became the Language &
+# formats card on Date, Time & Region: a language picker, four per-category
+# format dropdowns backed by the installed locales, and a live preview of what
+# each choice actually renders. GNOME's panel does the same job with the same
+# locales, so a row pointing at it is now a door out of a page that does the
+# thing. The retired page carried two: that one, and an "Open appearance"
+# handoff left over from when the fonts lived here.
declare -A OWNED=(
[network]=connectivity
[wifi]=connectivity
@@ -64,6 +79,8 @@ declare -A OWNED=(
[users]=users
[system\ users]=users
[privacy]=privacy
+ [system\ region]=datetime
+ [color]=displays
)
# Handoffs that are correct despite naming an owned panel, with the reason.
@@ -127,6 +144,20 @@ if grep -q 'openGnomePanel' "$settings_dir/PrivacyPage.qml"; then
violations=$((violations + 1))
fi
+# The same, for the page that no longer exists. RegionPage's two handoffs went
+# with the file, but the card they sat in moved to DateTimePage, and a card can
+# be moved with its rows intact -- so this is asserted against the page that
+# received the content rather than against the one that was deleted.
+if [[ -e "$settings_dir/RegionPage.qml" ]]; then
+ printf 'gnome handoff contract: RegionPage.qml still exists; its route retired to datetime\n' >&2
+ violations=$((violations + 1))
+fi
+if grep -q 'openGnomePanel' "$settings_dir/DateTimePage.qml"; then
+ printf 'gnome handoff contract: DateTimePage opens a GNOME panel for something it now does itself:\n' >&2
+ grep -n 'openGnomePanel' "$settings_dir/DateTimePage.qml" >&2
+ violations=$((violations + 1))
+fi
+
if (( violations > 0 )); then
printf 'Each of these sends someone to GNOME for a page this app already has.\n' >&2
exit 1
diff --git a/tests/quickshell/health-service-contract b/tests/quickshell/health-service-contract
index b52fba6..997a2d0 100755
--- a/tests/quickshell/health-service-contract
+++ b/tests/quickshell/health-service-contract
@@ -23,6 +23,16 @@ confirm_snapshot="$(jq -c '
action: {kind: "repair", label: "Restart Panama", confirm: true}
}]
' <<<"$warning_snapshot")"
+updates_snapshot="$(jq -c '
+ .checks[0].action = {kind: "open", label: "Open Software Update", confirm: false, target: "updates"}
+' <<<"$warning_snapshot")"
+# "storage" is a real Settings page that `settingsTargets` deliberately does
+# not list. The allow-list has to be an allow-list: if any page id were taken
+# on trust, extending the doctor's vocabulary would stop being a change that
+# has to be made on both sides, and this whole coupling would be decorative.
+unlisted_snapshot="$(jq -c '
+ .checks[0].action = {kind: "open", label: "Open Storage", confirm: false, target: "storage"}
+' <<<"$warning_snapshot")"
projection_snapshot="$(jq -c '
.fixtureSecret = "fixture-secret"
| .summary.fixtureSecret = "fixture-secret"
@@ -30,6 +40,11 @@ projection_snapshot="$(jq -c '
| .context.versions[0].fixtureSecret = "fixture-secret"
| .checks[0].fixtureSecret = "fixture-secret"
' <<<"$warning_snapshot")"
+# One probe, in the same envelope as a full report. Re-checking a single row
+# after fixing something by hand is the reason it exists: rescanning all thirty
+# takes long enough that people stop doing it, and a row that never updates is
+# a row that stops being believed.
+single_check='{"schemaVersion":1,"generatedAt":"2026-08-24T00:00:00Z","summary":{"status":"healthy","healthy":1,"warnings":0,"errors":0,"unconfigured":0},"context":{"session":"hyprland","versions":[{"id":"quickshell","version":"0.3.0"}]},"checks":[{"id":"panama.caffeine","group":"panama-tools","title":"Caffeine","status":"ok","detail":"No duplicate sleep inhibitors."}]}'
adversarial_snapshot="$(jq -c '
.fixtureSecret = "fixture-secret"
| .summary.fixtureSecret = "fixture-secret"
@@ -85,6 +100,57 @@ if keys != ["summary", "busy", "generation", "acceptedGeneration", "checks"]:
raise SystemExit(1)
PY
+# ── Both sides of a Settings target ─────────────────────────────────────────
+#
+# An `open` action carries the id of a Settings page, and the two halves of
+# that agreement live in different languages in different directories: the
+# doctor writes `target="updates"`, and Health decides whether it will accept
+# one by looking the value up in `settingsTargets`.
+#
+# Getting one without the other is not a dead button. `validAction` returning
+# false makes `validCheck` return false, which makes `consumeSnapshot` reject
+# the WHOLE report -- so a single unlisted target takes every other check down
+# with it and System Health goes blank, for a reason nothing on screen names.
+# That is what "Open Software Update" would have done had it been given the
+# target it was missing without `settingsTargets` being extended to match.
+#
+# Derived from both files rather than restated, so the next target added on
+# either side has to be added on the other.
+python3 - "$repo_dir/config/dot/quickshell/scripts/panama-doctor" "$service" <<'PY' \
+ || fail 'the doctor emits a Settings or instructions target that Health would reject, which rejects the entire snapshot'
+import re
+import sys
+
+doctor = open(sys.argv[1], encoding="utf-8").read()
+health = open(sys.argv[2], encoding="utf-8").read()
+
+
+def accepted(name: str) -> set[str]:
+ block = re.search(rf'property var {name}:\s*\[(.*?)\]', health, re.S)
+ if not block:
+ raise SystemExit(f"Health.qml no longer declares {name}")
+ return set(re.findall(r'"([a-z-]+)"', block.group(1)))
+
+
+settings_targets = accepted("settingsTargets")
+instruction_targets = accepted("instructionTargets")
+
+emitted = re.findall(r'Action\(\s*"(open|instructions)"\s*,\s*"[^"]*"\s*,\s*target="([a-z-]+)"', doctor)
+if not emitted:
+ raise SystemExit("no targeted actions were read from the doctor, so this proves nothing")
+
+for kind, target in emitted:
+ allowed = settings_targets if kind == "open" else instruction_targets
+ if target not in allowed:
+ raise SystemExit(
+ f'the doctor emits a {kind} action targeting "{target}", which Health does not accept'
+ )
+
+if "updates" not in settings_targets:
+ raise SystemExit('Health does not accept the "updates" target, so the Software Update '
+ 'actions have nowhere to go')
+PY
+
fixture_dir="$(mktemp -d /tmp/panama-health.XXXXXX)"
config_path="$fixture_dir/quickshell"
cp -a "$repo_dir/config/dot/quickshell" "$config_path"
@@ -130,6 +196,10 @@ printf '%s\n' \
" printf '%s\\n' '$warning_snapshot'" \
' exit 0' \
'fi' \
+ 'if [[ "$1" == "check" ]]; then' \
+ ' printf "%s\n" "$PANAMA_HEALTH_SINGLE_CHECK"' \
+ ' exit 0' \
+ 'fi' \
'if [[ "$1" == "--repair" ]]; then' \
' repair_start_time="$(awk '\''{ print $22 }'\'' "/proc/$$/stat")"' \
' printf "%s|%s\n" "$$" "$repair_start_time" >"$PANAMA_HEALTH_REPAIR_STARTED"' \
@@ -161,7 +231,7 @@ chmod +x "$copy_bin/wl-copy" "$copy_bin/notify-send"
run() {
PATH="$copy_bin:$PATH" PANAMA_HEALTH_HELPER="$helper" PANAMA_HEALTH_COPY_FILE="$copy_file" \
PANAMA_HEALTH_REPAIR_MODE_FILE="$repair_mode_file" PANAMA_HEALTH_REPAIR_LOG="$repair_log" \
- PANAMA_HEALTH_NOTIFICATION_LOG="$notification_log" \
+ PANAMA_HEALTH_NOTIFICATION_LOG="$notification_log" PANAMA_HEALTH_SINGLE_CHECK="$single_check" \
PANAMA_HEALTH_REPAIR_STARTED="$repair_started_file" PANAMA_HEALTH_REPAIR_RELEASE="$repair_release_file" \
qs -p "$harness" "$@"
}
@@ -218,7 +288,7 @@ trap cleanup EXIT
PATH="$copy_bin:$PATH" PANAMA_HEALTH_HELPER="$helper" PANAMA_HEALTH_COPY_FILE="$copy_file" \
PANAMA_HEALTH_REPAIR_MODE_FILE="$repair_mode_file" PANAMA_HEALTH_REPAIR_LOG="$repair_log" \
- PANAMA_HEALTH_NOTIFICATION_LOG="$notification_log" \
+ PANAMA_HEALTH_NOTIFICATION_LOG="$notification_log" PANAMA_HEALTH_SINGLE_CHECK="$single_check" \
PANAMA_HEALTH_REPAIR_STARTED="$repair_started_file" PANAMA_HEALTH_REPAIR_RELEASE="$repair_release_file" \
qs -p "$harness" --daemonize >/dev/null
for _ in $(seq 1 40); do
@@ -237,6 +307,38 @@ state="$(run ipc call health-test status)"
jq -e '.status == "warning" and .acceptedGeneration == 0 and .checks == ["integration.calendar", "panama.caffeine"] and .diagnosticUnavailable == false' \
>/dev/null <<<"$state" || fail "valid warning snapshot was not accepted intact: $state"
+[[ "$(run ipc call health-test accept "$updates_snapshot" 0)" == "true" ]] \
+ || fail 'a check pointing at Software Update was rejected, so the whole report would go blank rather than one button being dead'
+state="$(run ipc call health-test status)"
+jq -e '.status == "warning" and .diagnosticUnavailable == false
+ and .checks == ["integration.calendar", "panama.caffeine"]' \
+ >/dev/null <<<"$state" || fail "the Software Update target did not survive acceptance intact: $state"
+
+[[ "$(run ipc call health-test accept "$unlisted_snapshot" 0)" == "false" ]] \
+ || fail 'a Settings target the service does not list was accepted, so the allow-list is not one'
+
+# ── The command a repair will run, carried through ──────────────────────────
+#
+# Every field of a check is projected onto a known shape on the way in, which
+# is what stops an unknown key from reaching the report -- and which means a
+# NEW known key has to be added to the projection or it is silently dropped.
+# `repairCommand` is the one where that failure is invisible: the repair still
+# works, the row still says "Restart Vicinae", and the only thing missing is
+# the sentence telling somebody what is about to run as them.
+repair_command_snapshot="$(jq -c '
+ .checks[1].repairCommand = "systemd-inhibit --list"
+' <<<"$warning_snapshot")"
+[[ "$(run ipc call health-test accept "$repair_command_snapshot" 0)" == "true" ]] \
+ || fail 'a check carrying its repair command was rejected'
+jq -e '[.checks[] | select(.id == "panama.caffeine") | .repairCommand] == ["systemd-inhibit --list"]' \
+ >/dev/null <<<"$(run ipc call health-test report)" \
+ || fail 'the repair command was projected away, so the row cannot say what it is about to run'
+
+# A check with no repair, or a doctor that has not learned to send one, is
+# still a check. Dropping the field is fine; refusing the report is not.
+[[ "$(run ipc call health-test accept "$warning_snapshot" 0)" == "true" ]] \
+ || fail 'a check without a repair command was rejected once the field existed'
+
[[ "$(run ipc call health-test accept "$projection_snapshot" 0)" == "true" ]] \
|| fail 'snapshot with unknown non-action fields was rejected instead of safely projected'
stored_report="$(run ipc call health-test report)"
@@ -398,6 +500,63 @@ state="$(run ipc call health-test status)"
jq -e '.repairingId == "" and .generation == ($before + 1)' --argjson before "$confirm_generation" \
>/dev/null <<<"$state" || fail "rejected repair altered process state: $state"
+# ── One row, re-checked ─────────────────────────────────────────────────────
+#
+# The whole scan is thirty probes with network and D-Bus work behind several of
+# them. Somebody who has just restarted a service by hand wants to know about
+# that service, and making them wait nine seconds for the other twenty-nine is
+# how a Re-check button stops being pressed and a stale row stops being
+# believed. So `refreshCheck` runs one probe -- and has to leave the rest of
+# the accepted report exactly as it was, since a single-check response says
+# nothing about any other row.
+: >"$repair_log"
+[[ "$(run ipc call health-test recheck panama.caffeine)" == "true" ]] \
+ || fail 'a single-check refresh was refused for a check that is in the report'
+for _ in $(seq 1 60); do
+ state="$(run ipc call health-test status)"
+ jq -e '(.checkStates[] | select(.id == "panama.caffeine") | .status) == "ok"' \
+ >/dev/null <<<"$state" && break
+ sleep 0.1
+done
+jq -e '.checks == ["integration.calendar", "panama.caffeine"]
+ and (.checkStates[] | select(.id == "panama.caffeine") | .status) == "ok"
+ and (.checkStates[] | select(.id == "integration.calendar") | .status) == "warning"
+ and .summary == {status: "warning", healthy: 1, warnings: 1, errors: 0, unconfigured: 0}' \
+ >/dev/null <<<"$state" \
+ || fail "a single-check refresh did not update exactly the one row it probed: $state"
+grep -Fxq 'check panama.caffeine' "$repair_log" \
+ || fail "refreshCheck did not ask the doctor for one check: $(<"$repair_log")"
+! grep -Fxq -- '--json' "$repair_log" \
+ || fail 'a single-check refresh ran the whole thirty-probe scan anyway'
+
+: >"$repair_log"
+[[ "$(run ipc call health-test recheck unknown.check)" == "false" ]] \
+ || fail 'a check id that is not in the report started a probe'
+[[ ! -s "$repair_log" ]] || fail 'a refused single-check refresh started a process'
+
+# ── The report, saved rather than copied ────────────────────────────────────
+#
+# Copy Report puts the diagnostics on the clipboard, which is the right answer
+# when the next step is pasting it into a message and the wrong one when the
+# next step is attaching it, or reading it in an editor, or sending it from a
+# session that is the thing being diagnosed. Same redacted projection, written
+# to a file.
+report_file="$fixture_dir/health-report.txt"
+[[ "$(run ipc call health-test save "$report_file")" == "true" ]] \
+ || fail 'saving the health report was refused'
+for _ in $(seq 1 40); do
+ [[ -s "$report_file" ]] && break
+ sleep 0.1
+done
+[[ -s "$report_file" ]] || fail 'the saved health report is absent or empty'
+grep -Fq 'panama.caffeine' "$report_file" \
+ || fail 'the saved report does not contain the checks it is a report of'
+! grep -Fq 'fixture-secret' "$report_file" \
+ || fail 'the saved report is not the redacted projection that copyReport writes'
+rg -Fq 'panama-health-report.txt' "$service" \
+ || fail 'saveReport has no default destination, so the row has nowhere to write without a file dialog'
+
+
python3 - "$service" <<'PY' || fail 'external repair failure notification is not bounded'
import sys
diff --git a/tests/quickshell/health-ui-contract b/tests/quickshell/health-ui-contract
index 29fd62d..445c225 100755
--- a/tests/quickshell/health-ui-contract
+++ b/tests/quickshell/health-ui-contract
@@ -78,10 +78,46 @@ rg -Fq 'SystemSettings.openGnomePanel("system", "users")' "$settings_dir/HealthP
&& fail 'System Health still hands Users to GNOME, but Panama owns that page'
rg -Fq 'SystemSettings.openGnomePanel("sharing")' "$settings_dir/HealthPage.qml" \
&& fail 'System Health still hands Sharing to GNOME, but Panama owns that page'
+# Colour profiles left the card for the same reason Users and Sharing did:
+# Displays offers a colour profile, a bit depth, an SDR brightness and an SDR
+# saturation per output, which is more than GNOME's panel can say in a session
+# it does not manage. Digital wellbeing stays, because nothing here does it.
rg -Fq 'SystemSettings.openGnomePanel("color")' "$settings_dir/HealthPage.qml" \
- || fail 'Fedora ownership boundary lost the Color profiles handoff'
+ && fail 'System Health still hands Colour profiles to GNOME, but Displays owns them per output'
rg -Fq 'SystemSettings.openGnomePanel("wellbeing")' "$settings_dir/HealthPage.qml" \
|| fail 'Fedora ownership boundary lost the Digital wellbeing handoff'
+
+# ── A repair says what it will run, before it runs it ───────────────────────
+#
+# "Restart Vicinae" and "Release duplicate inhibitors" are what the buttons
+# say. What they do is run a command as the person pressing them, and the row
+# never said which. Somebody who wants to know what a button is about to do to
+# their machine should not have to read panama-doctor to find out -- and after
+# the fact is not the same answer, because by then the decision is made.
+rg -Fq 'repairCommand' "$settings_dir/HealthPage.qml" \
+ || fail 'a repair row never shows the command it is about to run'
+python3 - "$settings_dir/HealthPage.qml" <<'PY' || fail 'the repair command is only shown once the repair has run, which is after the moment it was worth knowing'
+import re
+import sys
+
+text = open(sys.argv[1], encoding="utf-8").read()
+for line in text.splitlines():
+ if "repairCommand" not in line:
+ continue
+ # Gating the command on a repair result, a repairing id, or a "working"
+ # state makes it after-the-fact reassurance rather than a decision aid.
+ if re.search(r'\b(lastRepair|repairingId|repairing|working|repairFailed)\b', line):
+ raise SystemExit(1)
+raise SystemExit(0)
+PY
+
+# ── One row, re-checked; and the report saved rather than copied ────────────
+rg -Fq 'Health.refreshCheck(' "$settings_dir/HealthCheckRow.qml" \
+ || rg -Fq 'Health.refreshCheck(' "$settings_dir/HealthPage.qml" \
+ || fail 'no row offers a re-check, so a row fixed by hand stays wrong until the whole scan runs again'
+rg -Fq 'Health.saveReport(' "$settings_dir/HealthPage.qml" \
+ || rg -Fq 'Health.saveReport(' "$settings_dir/HealthSummary.qml" \
+ || fail 'the report can only be copied, never saved'
# Exact authored handoffs are asserted above. Also prove every panel named by
# this boundary is accepted by SystemSettings, so a typo cannot ship a dead
# button even if its copy still looks correct.
@@ -122,7 +158,9 @@ labels = (
'if (status === "error") return "Action required";',
'return "Not set up";',
)
-assert all(label in page for label in labels)
+# The four status words belong to the row that shows them. They used to be
+# written on the page, one indirection away from the Text that rendered them.
+assert all(label in row for label in labels)
assert 'group: "desktop-foundation"' in page
assert 'group: "input-media"' in page
assert 'group: "integrations"' in page
@@ -140,7 +178,7 @@ assert 'pendingConfirmation' in page
assert 'ddc-permissions' in page
PY
-fixture='{"schemaVersion":1,"generatedAt":"2026-08-18T12:00:00Z","summary":{"status":"error","healthy":2,"warnings":2,"errors":1,"unconfigured":1},"context":{"session":"hyprland","versions":[{"id":"quickshell","version":"0.3.0"}]},"checks":[{"id":"desktop.vicinae","group":"desktop-foundation","title":"Vicinae","status":"warning","detail":"The launcher service is stopped.","action":{"kind":"repair","label":"Restart Vicinae","confirm":false}},{"id":"desktop.quickshell","group":"desktop-foundation","title":"Quickshell","status":"error","detail":"Panama shell needs to restart.","action":{"kind":"repair","label":"Restart Panama","confirm":true}},{"id":"input.pipewire","group":"input-media","title":"PipeWire","status":"ok","detail":"Audio graph is responding."},{"id":"integration.bluebubbles","group":"integrations","title":"BlueBubbles","status":"unconfigured","detail":"Messaging integration has not been enabled."},{"id":"integration.calendar","group":"integrations","title":"Calendar","status":"warning","detail":"Calendar probe timed out.","action":{"kind":"open","label":"Open Date & Time","confirm":false,"target":"datetime"}},{"id":"panama.caffeine","group":"panama-tools","title":"Caffeine","status":"ok","detail":"No duplicate sleep inhibitors."}]}'
+fixture='{"schemaVersion":1,"generatedAt":"2026-08-18T12:00:00Z","summary":{"status":"error","healthy":2,"warnings":3,"errors":1,"unconfigured":1},"context":{"session":"hyprland","versions":[{"id":"quickshell","version":"0.3.0"}]},"checks":[{"id":"desktop.vicinae","group":"desktop-foundation","title":"Vicinae","status":"warning","detail":"The launcher service is stopped.","action":{"kind":"repair","label":"Restart Vicinae","confirm":false},"repairCommand":"systemctl --user restart vicinae.service"},{"id":"desktop.quickshell","group":"desktop-foundation","title":"Quickshell","status":"error","detail":"Panama shell needs to restart.","action":{"kind":"repair","label":"Restart Panama","confirm":true},"repairCommand":"panama-action restart-shell"},{"id":"input.pipewire","group":"input-media","title":"PipeWire","status":"ok","detail":"Audio graph is responding."},{"id":"integration.bluebubbles","group":"integrations","title":"BlueBubbles","status":"unconfigured","detail":"Messaging integration has not been enabled."},{"id":"integration.calendar","group":"integrations","title":"Calendar","status":"warning","detail":"Calendar probe timed out.","action":{"kind":"open","label":"Open Date & Time","confirm":false,"target":"datetime"}},{"id":"panama.caffeine","group":"panama-tools","title":"Caffeine","status":"ok","detail":"No duplicate sleep inhibitors."},{"id":"panama.updates","group":"panama-tools","title":"Software updates","status":"warning","detail":"3 pending updates carry a security advisory.","action":{"kind":"open","label":"Open Software Update","confirm":false,"target":"updates"}}]}'
state_home="$(mktemp -d /tmp/panama-health-ui.XXXXXX)"
config_path="$state_home/quickshell"
@@ -251,6 +289,39 @@ ShellRoot {
});
}
+ // The Software Update action, end to end: the doctor authors the
+ // target, Health accepts it, and the row has to actually go there.
+ function offerUpdatesAction(): bool {
+ ShellState.settingsPage = "services";
+ return Health.consumeSnapshot(JSON.stringify({
+ schemaVersion: 1,
+ generatedAt: "2026-08-24T12:00:00Z",
+ summary: { status: "warning", healthy: 0, warnings: 1, errors: 0, unconfigured: 0 },
+ context: { session: "hyprland", versions: [] },
+ checks: [{
+ id: "panama.updates",
+ group: "panama-tools",
+ title: "Software updates",
+ status: "warning",
+ detail: "3 pending updates carry a security advisory.",
+ action: {
+ kind: "open",
+ label: "Open Software Update",
+ confirm: false,
+ target: "updates"
+ }
+ }]
+ }), Health.acceptedGeneration + 1);
+ }
+
+ function followUpdatesAction(): string {
+ const requested = settingsShell.requestHealthAction("panama.updates");
+ return JSON.stringify({
+ requested: requested,
+ page: ShellState.settingsPage
+ });
+ }
+
function activateIndicator(): string {
ShellState.settingsPage = "home";
ShellState.settingsOpen = false;
@@ -311,19 +382,19 @@ jq -e '
{objectName:"health-check-row:issue:desktop.vicinae", id:"desktop.vicinae", section:"issue", statusText:"Needs attention"},
{objectName:"health-check-row:issue:desktop.quickshell", id:"desktop.quickshell", section:"issue", statusText:"Action required"},
{objectName:"health-check-row:issue:integration.calendar", id:"integration.calendar", section:"issue", statusText:"Needs attention"},
+ {objectName:"health-check-row:issue:panama.updates", id:"panama.updates", section:"issue", statusText:"Needs attention"},
{objectName:"health-check-row:quiet:input.pipewire", id:"input.pipewire", section:"quiet", statusText:"Healthy"},
{objectName:"health-check-row:quiet:integration.bluebubbles", id:"integration.bluebubbles", section:"quiet", statusText:"Not set up"},
{objectName:"health-check-row:quiet:panama.caffeine", id:"panama.caffeine", section:"quiet", statusText:"Healthy"}
]
- and (.renderedRows | map(.id) | length) == 6
- and (.renderedRows | map(.id) | unique | length) == 6
+ and (.renderedRows | map(.id) | length) == 7
+ and (.renderedRows | map(.id) | unique | length) == 7
and .emptyQuietGroups == ["desktop-foundation"]
and .fedoraHandoffs == [
- {id:"color", label:"Color profiles", action:"Open color"},
{id:"wellbeing", label:"Digital wellbeing", action:"Open wellbeing"}
]
and .summaryHeight == 126
- and (.rowHeights | length) == 6
+ and (.rowHeights | length) == 7
and (.rowHeights | all(. >= 62))
and .checking == true
and .checkingText == "Checking…"
@@ -363,7 +434,7 @@ jq -e '.renderedRows[] | select(.id == "desktop.vicinae") | .statusText == "Repa
>/dev/null <<<"$failed_repair_state" || fail "repair failure was not shown inline: $failed_repair_state"
[[ "$(jq -c .rowHeights <<<"$failed_repair_state")" == "$settled_heights" ]] \
|| fail 'repair failure changed row geometry'
-[[ "$(jq -r '.renderedRows | map(.id) | unique | length' <<<"$failed_repair_state")" == 6 ]] \
+[[ "$(jq -r '.renderedRows | map(.id) | unique | length' <<<"$failed_repair_state")" == 7 ]] \
|| fail 'repair state duplicated a health action row'
for _ in $(seq 1 40); do
failed_repair_state="$(run ipc call health-ui-test state)"
@@ -432,6 +503,32 @@ jq -e '
and .refreshRequested == true
' >/dev/null <<<"$activation_state" || fail "indicator activation did not open and refresh System Health: $activation_state"
+# ── The Software Update action goes to Software Update ──────────────────────
+#
+# The two health checks that offered "Open Software Update" carried no target
+# at all, so the button rendered, focused, and did nothing -- the failure this
+# whole target mechanism exists to make impossible, sitting inside it. Fixing
+# it needed a change on both sides at once (panama-doctor authors the target,
+# Health.settingsTargets accepts it), and each half is pinned where it lives.
+# This is the third piece: the row acts on it.
+[[ "$(run ipc call health-ui-test offerUpdatesAction)" == "true" ]] \
+ || fail 'a check offering the Software Update action was rejected by the service'
+# Both conditions: the row rendered, and the scan the page starts when it loads
+# has settled. A row action is disabled while a check is running, which is
+# correct and would otherwise read here as a dead button.
+for _ in $(seq 1 60); do
+ updates_state="$(run ipc call health-ui-test state)"
+ jq -e '.checking == false and (.renderedRows | any(.id == "panama.updates"))' \
+ >/dev/null <<<"$updates_state" && break
+ sleep 0.1
+done
+jq -e '.checking == false and (.renderedRows | any(.id == "panama.updates"))' \
+ >/dev/null <<<"$updates_state" \
+ || fail "the Software Update check did not render on a settled page: $updates_state"
+follow_state="$(run ipc call health-ui-test followUpdatesAction)"
+jq -e '.requested == true and .page == "updates"' >/dev/null <<<"$follow_state" \
+ || fail "the Software Update action did not open Software Update: $follow_state"
+
if rg -i 'QQml|ReferenceError|TypeError|binding loop|failed to load component' "$shell_log"; then
fail 'isolated fixture emitted QML errors or warnings'
fi
diff --git a/tests/quickshell/manual-contract b/tests/quickshell/manual-contract
index 7753c59..159d71e 100755
--- a/tests/quickshell/manual-contract
+++ b/tests/quickshell/manual-contract
@@ -24,6 +24,9 @@ set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
manual_dir="$repo_dir/config/dot/quickshell/manual"
page="$repo_dir/config/dot/quickshell/modules/settings/ManualPage.qml"
+# The chapter list and the titles live in one place that both the reader and
+# About's Manual card instantiate, so neither can drift from the other.
+contents="$repo_dir/config/dot/quickshell/modules/settings/ManualChapters.qml"
routes="$repo_dir/config/dot/quickshell/services/SettingsRoutes.qml"
shell_ui="$repo_dir/config/dot/quickshell/modules/settings/SettingsShell.qml"
qmldir="$repo_dir/config/dot/quickshell/modules/settings/qmldir"
@@ -40,16 +43,18 @@ note() { findings+=("$1"); }
mapfile -t on_disk < <(find "$manual_dir" -maxdepth 1 -name '*.md' -printf '%f\n' | sort)
(( ${#on_disk[@]} > 0 )) || note 'the manual has no chapters'
-mapfile -t listed < <(grep -oE 'file: "[^"]+\.md"' "$page" | sed 's/file: "//; s/"//' | sort)
-(( ${#listed[@]} > 0 )) || note 'the manual page lists no chapters'
+[[ -r "$contents" ]] \
+ || note 'ManualChapters.qml is missing, so neither the reader nor About knows what the chapters are'
+mapfile -t listed < <(grep -oE 'file: "[^"]+\.md"' "$contents" | sed 's/file: "//; s/"//' | sort)
+(( ${#listed[@]} > 0 )) || note 'the chapter list names no chapters'
for file in "${on_disk[@]}"; do
printf '%s\n' "${listed[@]}" | grep -qx "$file" \
- || note "$file exists but the manual page never shows it"
+ || note "$file exists but the chapter list never names it"
done
for file in "${listed[@]}"; do
[[ -r "$manual_dir/$file" ]] \
- || note "the manual page lists $file, which does not exist, so that chapter renders an error"
+ || note "the chapter list names $file, which does not exist, so that chapter renders an error"
done
# A chapter that is only a heading is a chapter somebody forgot to write.
@@ -60,6 +65,43 @@ for file in "${on_disk[@]}"; do
|| note "$file does not begin with a heading, so it has no title of its own"
done
+# ── 1b. The tab titles come from the files ───────────────────────────────────
+#
+# The page carried a comment saying the title shown is the first heading of
+# each chapter, "so a chapter cannot be renamed in one place and not the
+# other". It was not true: the titles were a second copy, written beside the
+# filenames, and the two agreed only because nobody had renamed anything yet.
+# A comment that describes a property the code does not have is worse than no
+# comment, because it is the reason the next person does not check.
+#
+# So: no authored label beside a chapter file, and something that actually
+# reads a leading heading out of one.
+python3 - "$contents" <<'PY' || note 'the chapter list still carries hand-written titles beside the filenames, so a renamed heading and a tab label can disagree'
+import re
+import sys
+
+text = open(sys.argv[1], encoding="utf-8").read()
+block = re.search(r'chapters:\s*\[(.*?)\n \]', text, re.S)
+if not block:
+ raise SystemExit(1)
+raise SystemExit(1 if re.search(r'label:\s*"', block.group(1)) else 0)
+PY
+grep -qE '\^#' "$contents" \
+ || note 'nothing reads a leading heading, so the chapter titles cannot be coming from the files'
+grep -q 'Quickshell.shellDir + "/manual/"' "$contents" \
+ || note 'the chapter titles are not read through the shell directory, so they would break on a clone elsewhere'
+grep -q 'ManualChapters {' "$page" \
+ || note 'the manual reader keeps its own idea of what the chapters are'
+
+# And the titles the files offer have to be usable as tab labels: a first line
+# that is a paragraph would render a tab strip nobody can read.
+for file in "${on_disk[@]}"; do
+ heading="$(head -1 "$manual_dir/$file" | sed 's/^#\+[[:space:]]*//')"
+ [[ -n "$heading" ]] || note "$file has an empty first heading, so its tab would have no name"
+ (( ${#heading} <= 40 )) \
+ || note "$file's first heading is ${#heading} characters; it is the tab label, so it has to be a title"
+done
+
# ── 2 & 3. How it renders ────────────────────────────────────────────────────
grep -q 'textFormat: Text.MarkdownText' "$page" \
@@ -68,6 +110,75 @@ grep -q 'root.chapters\[root.current\]' "$page" \
|| note 'the page does not render one chapter at a time; a single long Text goes blank rather than erroring'
grep -q 'onLinkActivated' "$page" \
|| note 'links in the manual do nothing when clicked'
+
+# ── 3b. A link to a settings page opens the settings page ────────────────────
+#
+# The whole reason the manual is rendered inside Settings rather than in a
+# browser is that a chapter can point at a page and have that mean something.
+# Every link went to Qt.openUrlExternally, so "open Displays" handed a
+# panama:// URL to xdg-open, which has no handler for it: the click did
+# nothing, silently, which is the worst of the three possible outcomes.
+grep -q 'panama://settings/' "$page" \
+ || note 'the manual has no in-app link scheme, so a chapter cannot point at a settings page'
+python3 - "$page" <<'PY' || note 'the manual link handler does not route in-app links through ShellState.openSettings while still sending everything else out of the desktop'
+import re
+import sys
+
+text = open(sys.argv[1], encoding="utf-8").read()
+
+
+def block_at(index: int) -> str:
+ """The braced body starting at the first { on or after index."""
+ start = text.index("{", index)
+ depth = 0
+ for position in range(start, len(text)):
+ if text[position] == "{":
+ depth += 1
+ elif text[position] == "}":
+ depth -= 1
+ if depth == 0:
+ return text[start:position + 1]
+ raise SystemExit(1)
+
+
+# Either shape is fine: the branch written inline, or delegated to a named
+# function on the page. What matters is what is on the path, so the path is
+# followed rather than one line being read and guessed at.
+handler = re.search(r'onLinkActivated:\s*link\s*=>\s*(?P.)', text)
+if not handler:
+ raise SystemExit(1)
+if handler.group("tail") == "{":
+ body = block_at(handler.start("tail"))
+else:
+ called = re.match(r'(?:root\.)?([A-Za-z][A-Za-z0-9]*)\(', text[handler.start("tail"):])
+ if not called:
+ raise SystemExit(1)
+ declaration = re.search(rf'function {called.group(1)}\s*\(', text)
+ if not declaration:
+ raise SystemExit(1)
+ body = block_at(declaration.end())
+
+raise SystemExit(0 if "ShellState.openSettings" in body
+ and "Qt.openUrlExternally" in body
+ and ("panama://settings/" in body or "linkScheme" in body) else 1)
+PY
+
+# Every in-app link a chapter actually writes must name a page that exists.
+# A typo here is invisible: the scheme matches, the handler fires, and
+# openSettings resolves an unknown id to Home -- so the link works, just not
+# the way the sentence around it promised.
+leaves="$( {
+ grep -oE '\{ page: "[a-z-]+", label: "[^"]*", icon: "[^"]*", tabs: \[\] \}' "$routes"
+ grep -oE '\{ page: "[a-z-]+", label: "[^"]*" \}' "$routes"
+ sed -n '/property var hiddenLeaves:/,/\]/p' "$routes" | grep -oE 'page: "[a-z-]+"'
+} | sed -E 's/.*page: "([a-z-]+)".*/\1/' | sort -u)"
+[[ -n "$leaves" ]] || note 'no leaves could be read from SettingsRoutes, so in-app manual links prove nothing'
+while read -r target; do
+ [[ -n "$target" ]] || continue
+ grep -qx "$target" <<<"$leaves" \
+ || note "a chapter links to panama://settings/$target, which is not a page anyone can land on"
+done < <(grep -rhoE 'panama://settings/[a-z-]+' "$manual_dir" \
+ | sed -E 's|panama://settings/||' | sort -u)
grep -q 'onLoadFailed' "$page" \
|| note 'a chapter that cannot be read fails silently instead of saying so'
@@ -80,18 +191,31 @@ grep -q '\.\./\.\./\.\.' "$page" \
&& note 'the manual path walks upward out of the shell directory, which is only correct by accident'
# ── 4. Registered everywhere a settings page has to be ───────────────────────
-# The manual is a tab of the System category rather than a sidebar row of its
-# own -- it is reference material, not a control surface, and it belongs beside
-# About for the same reason. SettingsRoutes is what makes it reachable at all:
-# a leaf missing from the taxonomy cannot be opened, searched, or linked to.
+# The manual is a routable leaf with no tab of its own. It was a tab of System
+# until the strip reached ten and had to come back down to eight, and it is the
+# right one to lose: it is reference material rather than a control surface, so
+# it is opened deliberately -- from About's Manual card, from a deep link, from
+# a search result -- rather than found by scanning a row of tabs.
+#
+# That makes the taxonomy entry the only thing holding it up. A leaf missing
+# from SettingsRoutes cannot be opened, searched, or linked to, and because it
+# draws no tab anywhere, nothing on screen would show it had gone.
-grep -q '{ page: "manual", label: ' "$routes" || note 'the manual is not a leaf in SettingsRoutes, so nothing can navigate to it'
-python3 - "$routes" <<'PY' || note 'the manual is no longer a tab of the System category, so it has drifted out of the group it belongs to'
-import re, sys
+python3 - "$routes" <<'PY' || note 'the manual is not a hidden leaf in SettingsRoutes, so nothing can navigate to it'
+import re
+import sys
text = open(sys.argv[1], encoding="utf-8").read()
-block = re.search(r'\{ page: "system",.*?tabs: \[(.*?)\] \}', text, re.S)
-raise SystemExit(0 if block and '{ page: "manual"' in block.group(1) else 1)
+block = re.search(r'hiddenLeaves:\s*\[(.*?)\]', text, re.S)
+raise SystemExit(0 if block and re.search(r'page: "manual"', block.group(1)) else 1)
+PY
+python3 - "$routes" <<'PY' || note 'the manual is a System tab again, taking back a slot the eight-tab strip does not have'
+import re
+import sys
+
+text = open(sys.argv[1], encoding="utf-8").read()
+block = re.search(r'\{ page: "system",.*?tabs: \[(.*?)\n \] \}', text, re.S)
+raise SystemExit(1 if block and '{ page: "manual"' in block.group(1) else 0)
PY
grep -q 'case "manual": return manualPage;' "$shell_ui" || note 'SettingsShell does not route to the manual'
grep -q 'Component { id: manualPage; ManualPage {} }' "$shell_ui" || note 'SettingsShell never declares the manual component'
@@ -102,6 +226,21 @@ grep -q '^ManualPage 1.0 ManualPage.qml$' "$qmldir" || note 'ManualPage is not r
grep -q 'SettingsRoutes.resolve(' "$state" || note 'ShellState does not resolve pages through SettingsRoutes, so openSettings("manual") has no defined destination'
grep -q 'page: "manual"' "$search" || note 'the manual is not searchable from the settings search box'
+# With no tab of its own, About's Manual card is the only place the manual is
+# offered rather than looked up. If that card stops opening it, the page is
+# still reachable in principle and undiscoverable in practice.
+about="$repo_dir/config/dot/quickshell/modules/settings/AboutPage.qml"
+if [[ -r "$about" ]]; then
+ grep -q 'openSettings("manual"' "$about" \
+ || note 'About does not open the manual, which is now the only place it is offered rather than searched for'
+ grep -q 'ManualChapters {' "$about" \
+ || note 'About keeps its own idea of what the chapters are, so its card and the reader can disagree'
+ grep -qE 'file: "[^"]+\.md"' "$about" \
+ && note 'About names chapter files itself instead of reading the shared list'
+else
+ note 'AboutPage.qml is missing, so the manual has nowhere to be opened from'
+fi
+
if (( ${#findings[@]} > 0 )); then
printf 'manual contract: %d finding(s)\n' "${#findings[@]}" >&2
printf ' - %s\n' "${findings[@]}" >&2
diff --git a/tests/quickshell/panama-commands-contract b/tests/quickshell/panama-commands-contract
index 7b7db09..b570540 100755
--- a/tests/quickshell/panama-commands-contract
+++ b/tests/quickshell/panama-commands-contract
@@ -53,10 +53,16 @@ while read -r page; do
[[ -n "$page" ]] || continue
[[ "$page" == "home" ]] && continue
expected[settings-$page]="settings-page $page"
+#
+# Hidden leaves count too. The manual has no tab of its own -- it is reference
+# material rather than a control surface -- but "manual" is exactly the sort of
+# thing somebody types into a launcher, and a leaf with no tab is the one leaf
+# with nowhere else to be found by scanning.
done < <( {
grep -oE '\{ page: "[a-z-]+", label: "[^"]*", icon: "[^"]*", tabs: \[\] \}' "$routes"
grep -oE '\{ page: "[a-z-]+", label: "[^"]*" \}' "$routes"
-} | sed -E 's/\{ page: "([a-z-]+)".*/\1/' | sort -u)
+ sed -n '/property var hiddenLeaves:/,/\]/p' "$routes" | grep -oE 'page: "[a-z-]+"'
+} | sed -E 's/.*page: "([a-z-]+)".*/\1/' | sort -u)
(( ${#expected[@]} > 18 )) || fail 'no generated per-page commands were found; run scripts/panama-settings-commands'
diff --git a/tests/quickshell/panama-doctor-contract b/tests/quickshell/panama-doctor-contract
index dccb0c7..2802f69 100755
--- a/tests/quickshell/panama-doctor-contract
+++ b/tests/quickshell/panama-doctor-contract
@@ -33,8 +33,13 @@ state_home="$home/.local/state"
runtime_dir="$fixture/runtime"
bin_dir="$fixture/bin"
data_home="$home/.local/share"
+# The updates check reads the Updates page's cache out of XDG_CACHE_HOME rather
+# than through DoctorConfig, so without this the fixture would read the real
+# machine's update state and report whatever happened to be pending today.
+cache_home="$home/.cache"
-mkdir -p "$config_home" "$state_home" "$runtime_dir" "$bin_dir" "$data_home/vicinae/scripts"
+mkdir -p "$config_home" "$state_home" "$runtime_dir" "$bin_dir" "$data_home/vicinae/scripts" \
+ "$cache_home/panama"
# A mount table with the document portal present, which is the healthy state the
# rest of this file assumes. Written rather than read from /proc so the contract
@@ -114,6 +119,7 @@ run_doctor() {
HOME="$home" \
PATH="$bin_dir" \
XDG_CURRENT_DESKTOP=Hyprland \
+ XDG_CACHE_HOME="$cache_home" \
PANAMA_HOME_ASSISTANT_URL='https://fixture.invalid' \
PANAMA_HOME_ASSISTANT_TOKEN='fixture-secret-token' \
PANAMA_DOCTOR_ROOT="$repo_dir" \
@@ -803,4 +809,177 @@ for rejected_id in unknown.check integration.home-assistant input.brightness \
[[ "$(fixture_state)" == "$before_state" ]] || fail "$rejected_id mutated the filesystem"
done
+# ── Every check in the order has a title ────────────────────────────────────
+#
+# `unavailable_check` is the doctor's own containment: a probe that raises
+# becomes a warning row rather than a crashed report. It reads the title out of
+# CHECK_TITLES by key, and `panama.updates` was never added there -- so the one
+# path that exists to keep a failing probe from taking the report down was
+# itself a KeyError, raised inside the `except` handler that was catching the
+# original failure, from where it escaped `collect_checks`, hit `snapshot`'s
+# outer guard, and raised again building the all-unavailable fallback.
+#
+# Nobody saw it, because it needs the updates probe to fail, and the updates
+# probe reads a cache file and almost never does. Both halves are asserted:
+# the invariant, which is cheap and total, and the path, which is the one that
+# actually ran.
+/usr/bin/python3 - "$doctor" <<'PY' || fail 'a check in CHECK_ORDER has no title, so a failing probe raises KeyError from inside the handler that exists to contain it'
+import importlib.machinery
+import importlib.util
+import sys
+
+loader = importlib.machinery.SourceFileLoader("panama_doctor_titles", sys.argv[1])
+spec = importlib.util.spec_from_loader(loader.name, loader)
+module = importlib.util.module_from_spec(spec)
+sys.modules[spec.name] = module
+loader.exec_module(module)
+
+missing = [check for check in module.CHECK_ORDER if check not in module.CHECK_TITLES]
+if missing:
+ raise SystemExit(f"no title for {missing}")
+for check in module.CHECK_ORDER:
+ module.unavailable_check(check)
+PY
+
+/usr/bin/python3 - "$doctor" "$fixture/unavailable-home" <<'PY' || fail 'a probe that raises does not become a warning row; it takes the whole report with it'
+import importlib.machinery
+import importlib.util
+import sys
+from pathlib import Path
+
+doctor_path, home_text = sys.argv[1:]
+loader = importlib.machinery.SourceFileLoader("panama_doctor_unavailable", doctor_path)
+spec = importlib.util.spec_from_loader(loader.name, loader)
+module = importlib.util.module_from_spec(spec)
+sys.modules[spec.name] = module
+loader.exec_module(module)
+
+home = Path(home_text)
+home.mkdir(parents=True)
+config = module.DoctorConfig(home, home, home / "config", home / "state", home / "runtime", "", 0.05)
+
+
+def raising(_config):
+ raise TimeoutError("fixture probe timeout")
+
+
+module.check_updates = raising
+report = module.snapshot(config)
+ids = [check["id"] for check in report["checks"]]
+if ids != list(module.CHECK_ORDER):
+ raise SystemExit("a raising probe changed the shape of the report")
+row = next(check for check in report["checks"] if check["id"] == "panama.updates")
+if row["status"] != "warning" or not row["title"]:
+ raise SystemExit(f"the contained row is not a titled warning: {row}")
+PY
+
+# ── The updates check knows where Software Update is ────────────────────────
+#
+# Two of its four states offered "Open Software Update" with no target, so the
+# button rendered and did nothing. Adding the target is only half the change --
+# Health.settingsTargets has to accept it, or the whole report is rejected --
+# and health-service-contract pins the other half against this same file.
+/usr/bin/python3 - "$cache_home/panama/updates.json" <<'PY'
+import json
+import sys
+import time
+
+json.dump({
+ "checkedAt": int(time.time()),
+ "dnf": {"count": 5, "securityCount": 3},
+ "flatpak": {"count": 0},
+ "firmware": {"count": 0},
+}, open(sys.argv[1], "w"))
+PY
+pending_updates="$(run_doctor --json)"
+check_status "$pending_updates" panama.updates warning
+jq -e '.checks[] | select(.id == "panama.updates")
+ | .action == {kind:"open", label:"Open Software Update", confirm:false, target:"updates"}' \
+ >/dev/null <<<"$pending_updates" || fail 'the updates action carries no Settings target, so the button does nothing'
+assert_schema_and_redaction "$pending_updates"
+
+# ── A repair row says what it will run ──────────────────────────────────────
+#
+# The command is the literal from REPAIR_COMMANDS and nothing else: derived
+# from the table rather than restated here, so a repair whose argv changes
+# cannot leave the row describing the old one, and probe output can never reach
+# this field.
+repairable="$(PANAMA_DOCTOR_MOUNTINFO="$fixture/mountinfo-unmounted" run_doctor --json)"
+check_status "$repairable" desktop.document-portal warning
+printf '%s' "$repairable" >"$fixture/repairable-report.json"
+/usr/bin/python3 - "$doctor" "$fixture/repairable-report.json" <<'PY' || fail 'a repairable check does not carry the exact authored repair command'
+import importlib.machinery
+import importlib.util
+import json
+import sys
+
+loader = importlib.machinery.SourceFileLoader("panama_doctor_repair_command", sys.argv[1])
+spec = importlib.util.spec_from_loader(loader.name, loader)
+module = importlib.util.module_from_spec(spec)
+sys.modules[spec.name] = module
+loader.exec_module(module)
+
+report = json.load(open(sys.argv[2], encoding="utf-8"))
+expected = {check: " ".join(command) for check, command in module.REPAIR_COMMANDS.items()}
+
+seen = 0
+for check in report["checks"]:
+ rendered = check.get("repairCommand")
+ if check["id"] in expected and check.get("action", {}).get("kind") == "repair":
+ if rendered != expected[check["id"]]:
+ raise SystemExit(f'{check["id"]} shows {rendered!r}, expected {expected[check["id"]]!r}')
+ seen += 1
+ elif rendered is not None and rendered != expected.get(check["id"]):
+ raise SystemExit(f'{check["id"]} shows a command that is not its authored one: {rendered!r}')
+if not seen:
+ raise SystemExit("no repairable check carried a command, so this proves nothing")
+PY
+
+# ── One check, asked for on its own ─────────────────────────────────────────
+#
+# The whole scan is thirty probes. Re-checking the one row somebody just fixed
+# should not wait on the other twenty-nine, so there is a verb that runs one --
+# and it answers in the same envelope as a full report, because the consumer is
+# the same parser and a second response shape is a second thing to get wrong.
+single="$(run_doctor check desktop.hyprpaper)" || fail 'a single-check run failed'
+jq -e '.schemaVersion == 1
+ and (.generatedAt | type == "string")
+ and (.summary.status | IN("healthy", "warning", "error"))
+ and (.context.session | IN("hyprland", "other"))
+ and (.context.versions | type == "array")
+ and (.checks | length) == 1
+ and (.checks[0] | has("id") and has("group") and has("title") and has("status") and has("detail"))
+ and .checks[0].id == "desktop.hyprpaper"' \
+ >/dev/null <<<"$single" || fail "a single check is not the full report shape: $single"
+[[ "$(jq -r '.summary | [.healthy, .warnings, .errors, .unconfigured] | add' <<<"$single")" == "1" ]] \
+ || fail 'the single-check summary counts something other than the one check it ran'
+! grep -Fq 'fixture-secret-token' <<<"$single" || fail 'a single check exposed a fixture secret'
+
+# An id nobody authored is refused rather than answered with an empty
+# snapshot, which would look valid and say nothing. The refusal is an exit
+# status and a message, not a report: a caller that asked for a check that does
+# not exist has a bug, and handing it a well-formed reply hides it.
+for rejected_id in unknown.check ../../escape 'desktop.vicinae;touch injected' ''; do
+ before_state="$(fixture_state)"
+ set +e
+ single_rejected="$(run_doctor check "$rejected_id" 2>"$fixture/single-check-error")"
+ single_status=$?
+ set -e
+ [[ "$single_status" != 0 ]] \
+ || fail "the single-check verb accepted \"$rejected_id\""
+ [[ -z "$single_rejected" ]] \
+ || fail "the single-check verb printed a report for \"$rejected_id\": $single_rejected"
+ [[ -s "$fixture/single-check-error" ]] \
+ || fail "the single-check verb refused \"$rejected_id\" without saying why"
+ rm -f "$fixture/single-check-error"
+ [[ "$(fixture_state)" == "$before_state" ]] \
+ || fail "the single-check verb mutated the filesystem for \"$rejected_id\""
+done
+
+# And it is not a second way to run the whole scan, or to run a repair.
+run_doctor check desktop.hyprpaper --summary >/dev/null 2>&1 \
+ && fail 'the single-check verb accepted a second output mode'
+run_doctor check desktop.vicinae --repair desktop.vicinae >/dev/null 2>&1 \
+ && fail 'the single-check verb accepted a repair alongside it'
+
printf 'panama doctor contract: PASS\n'
diff --git a/tests/quickshell/settings-backup-contract b/tests/quickshell/settings-backup-contract
index f6a4ba8..78450a9 100755
--- a/tests/quickshell/settings-backup-contract
+++ b/tests/quickshell/settings-backup-contract
@@ -198,6 +198,102 @@ jq -e 'has("pwned") | not' "$settings" >/dev/null || fail 'a file outside the ba
# ── A snapshot that is not listed is refused ─────────────────────────────────
run restore "settings-20000101-000000000.json" >/dev/null 2>&1 && fail 'a missing snapshot was reported restored'
+# ── A snapshot can be given a name, and the name cannot be a path ────────────
+#
+# Fifteen rows reading "2026-08-24 11:03:07" are fifteen rows nobody can choose
+# between, which is the same as having no backups: the one you want is the one
+# you took before the thing you are undoing, and the timestamp does not say
+# what that was. So `create` takes a name.
+#
+# A name typed by a person then reaches a filename, which is the oldest way a
+# helper gets talked into writing outside its own directory. The charset is the
+# snapshot charset -- letters, digits, dot, dash, underscore -- and everything
+# else is either sanitized out or refused; either is fine, writing outside
+# BACKUP_DIR is not.
+
+rm -f "$backups"/*.json
+printf '{"gapsOut":24}' >"$settings"
+printf '{"initialized":true,"favorites":[]}' >"$home"
+
+run create >/dev/null || fail 'create without a name failed'
+[[ "$(run list | jq 'length')" == "1" ]] || fail 'an unnamed create did not produce one snapshot'
+
+run create "Before the theme experiment" >/dev/null || fail 'create with a name failed'
+named="$(run list | jq -r '.[0]')"
+jq -e '.label == "Before the theme experiment"' <<<"$named" >/dev/null \
+ || fail "a named snapshot did not carry its name back: $named"
+named_file="$(jq -r .name <<<"$named")"
+[[ "$named_file" =~ ^[A-Za-z0-9_.-]+$ ]] \
+ || fail "a named snapshot produced the filename \"$named_file\", which is outside the snapshot charset"
+
+# Every one of these either fails or produces a file inside the backup
+# directory. None of them may produce a file anywhere else, and none may
+# remove or overwrite something that is not a snapshot.
+outside="$work/state/panama/NOT-A-BACKUP"
+printf 'untouched\n' >"$outside"
+for hostile in \
+ '../../../NOT-A-BACKUP' \
+ '/etc/panama-owned' \
+ 'a/b' \
+ '..' \
+ '.' \
+ $'tab\there' \
+ '-rf'; do
+ run create "$hostile" >/dev/null 2>&1 || true
+ [[ "$(<"$outside")" == 'untouched' ]] \
+ || fail "the snapshot name \"$hostile\" wrote outside the backup directory"
+done
+while read -r listed; do
+ [[ -n "$listed" ]] || continue
+ [[ "$listed" =~ ^[A-Za-z0-9_.-]+$ ]] \
+ || fail "a hostile snapshot name produced the listed filename \"$listed\""
+done < <(run list | jq -r '.[].name')
+
+# ── A snapshot can be deleted, and only a snapshot ───────────────────────────
+#
+# Same boundary as restore, in the other direction, and with a worse failure:
+# restore reading the wrong file overwrites settings, delete resolving the
+# wrong name destroys something that is not a backup at all.
+
+before_delete="$(run list | jq 'length')"
+(( before_delete >= 2 )) || fail 'not enough snapshots to exercise delete'
+victim="$(run list | jq -r '.[0].name')"
+run delete "$victim" >/dev/null || fail 'deleting a listed snapshot failed'
+[[ ! -e "$backups/$victim" ]] || fail 'a deleted snapshot is still on disk'
+[[ "$(run list | jq 'length')" == "$((before_delete - 1))" ]] \
+ || fail 'delete removed a different number of snapshots than one'
+
+printf '{"pwned":false}' >"$work/delete-target.json"
+for hostile in \
+ '../../delete-target.json' \
+ '/etc/passwd' \
+ "$victim" \
+ 'settings-20000101-000000000.json' \
+ '' ; do
+ run delete "$hostile" >/dev/null 2>&1 \
+ && fail "delete accepted \"$hostile\", which is not a snapshot in the backup directory"
+done
+[[ -f "$work/delete-target.json" ]] || fail 'delete followed a traversing name out of the backup directory'
+
+escape="settings-20000101-000000009.json"
+ln -s "$work/delete-target.json" "$backups/$escape"
+run delete "$escape" >/dev/null 2>&1 \
+ && fail 'delete accepted a symlink escaping the backup directory'
+[[ -f "$work/delete-target.json" ]] || fail 'delete removed the target of an escaping symlink'
+rm -f "$backups/$escape"
+
+# ── The list says how much room a snapshot takes ─────────────────────────────
+#
+# Fifteen snapshots of a settings file are nothing; fifteen of a settings file
+# somebody grew are not, and the page offers a Delete button now, which is a
+# decision nobody can make without the size.
+run list | jq -e 'all(.[]; (.bytes | type == "number") and .bytes > 0)' >/dev/null \
+ || fail 'the snapshot list does not report each snapshot’s size'
+listed_bytes="$(run list | jq -r '.[0].bytes')"
+actual_bytes="$(stat -c %s "$backups/$(run list | jq -r '.[0].name')")"
+[[ "$listed_bytes" == "$actual_bytes" ]] \
+ || fail "the list reports $listed_bytes bytes for a snapshot that is $actual_bytes on disk"
+
# ── Snapshots are capped ─────────────────────────────────────────────────────
for _ in $(seq 1 20); do
printf '{"n":%s}' "$RANDOM" >"$settings"
diff --git a/tests/quickshell/settings-backup-live-contract b/tests/quickshell/settings-backup-live-contract
index 8e00618..2eb511c 100755
--- a/tests/quickshell/settings-backup-live-contract
+++ b/tests/quickshell/settings-backup-live-contract
@@ -173,6 +173,91 @@ status="$(qs_test ipc call settings-backup-behavior status)"
jq -e '.calls == [] and (.lastError | contains("display change"))' <<<"$status" >/dev/null \
|| fail "display-busy restore refusal was not clean: $status"
+# ── The colour half of a display layout ─────────────────────────────────────
+#
+# Displays persists eleven fields per output. A restore read seven of them.
+#
+# The four it dropped -- VRR mode, colour profile, bit depth, SDR brightness
+# and saturation, plus the mirror source -- are the ones nobody notices going
+# missing, because the picture is still there and it is still the right size.
+# Worse, `layoutsEqual` compared the same seven, so a snapshot whose colour
+# settings differed from the live state compared EQUAL: the restore took the
+# early return, decided there was nothing to apply, and reported success while
+# leaving HDR off. A silent no-op that says it worked is the failure mode this
+# whole codebase keeps relearning, and this is it in the one place where the
+# evidence is a monitor looking slightly wrong.
+#
+# Both halves are asserted, because fixing either alone leaves the bug: reading
+# the fields without comparing them means the restore never runs; comparing
+# them without reading them means it runs and applies nothing.
+
+full='{
+ "DP-2": {"mode":"4500x3000@60","scale":1.5,"transform":0,"x":-2560,"y":0,"primary":false,
+ "vrrMode":2,"colorProfile":"hdr","bitdepth":10,"sdrBrightness":1.2,
+ "sdrSaturation":0.9,"mirrorOf":""},
+ "HDMI-A-1": {"mode":"2560x1440@60","scale":1,"transform":0,"x":0,"y":0,"primary":true,
+ "vrrMode":0,"colorProfile":"srgb","bitdepth":8,"sdrBrightness":1,
+ "sdrSaturation":1,"mirrorOf":"DP-2"}
+}'
+plain='{
+ "DP-2": {"mode":"4500x3000@60","scale":1.5,"transform":0,"x":-2560,"y":0,"primary":false},
+ "HDMI-A-1": {"mode":"2560x1440@60","scale":1,"transform":0,"x":0,"y":0,"primary":true}
+}'
+
+qs_test ipc call settings-backup-behavior reset >/dev/null
+
+restored="$(qs_test ipc call settings-backup-behavior layoutFor "$(jq -c . <<<"$full")")"
+jq -e '
+ (. != null) and (length == 2)
+ and (.[] | select(.name == "DP-2")
+ | .vrrMode == 2 and .colorProfile == "hdr" and .bitdepth == 10
+ and .sdrBrightness == 1.2 and .sdrSaturation == 0.9 and .mirrorOf == "")
+' <<<"$restored" >/dev/null \
+ || fail "a stored layout's colour fields did not survive the trip back into a live layout: $restored"
+jq -e '.[] | select(.name == "HDMI-A-1") | .mirrorOf == "DP-2" and .bitdepth == 8' \
+ <<<"$restored" >/dev/null \
+ || fail "the mirror source did not survive the trip back into a live layout: $restored"
+
+# Back-compat, in the same shape Displays.isPersistedLayoutEntry uses: a record
+# written before these fields existed is still a record. Refusing it would turn
+# every snapshot anybody already has into one that cannot be restored.
+legacy="$(qs_test ipc call settings-backup-behavior layoutFor "$(jq -c . <<<"$plain")")"
+jq -e '(. != null) and (length == 2) and (.[] | select(.name == "DP-2") | .scale == 1.5)' \
+ <<<"$legacy" >/dev/null \
+ || fail "a stored layout without the colour fields was refused, so older snapshots cannot be restored: $legacy"
+
+# And the comparison. Two layouts identical but for a colour profile are not
+# equal -- this is the assertion the shipped behaviour FAILS, and the reason
+# the restore silently did nothing.
+# The two layouts travel as one object: the IPC client turns a top-level JSON
+# array into one argument per element, so passing two layouts as two arguments
+# arrives as four.
+compare() {
+ qs_test ipc call settings-backup-behavior layoutsMatch \
+ "$(jq -c -n --argjson left "$1" --argjson right "$2" '{left: $left, right: $right}')"
+}
+
+drifted="$(jq -c '.[0].colorProfile = "srgb"' <<<"$restored")"
+[[ "$(compare "$restored" "$drifted")" == "false" ]] \
+ || fail 'layoutsEqual calls two layouts equal when their colour profiles differ, so a restore that should change the picture takes the early return and reports success'
+for field in vrrMode bitdepth sdrBrightness sdrSaturation mirrorOf; do
+ case "$field" in
+ mirrorOf) drifted="$(jq -c --arg f "$field" '.[0][$f] = "HDMI-A-1"' <<<"$restored")" ;;
+ *) drifted="$(jq -c --arg f "$field" '.[0][$f] = 7' <<<"$restored")" ;;
+ esac
+ [[ "$(compare "$restored" "$drifted")" == "false" ]] \
+ || fail "layoutsEqual ignores $field, so a snapshot differing only in it restores nothing"
+done
+[[ "$(compare "$restored" "$restored")" == "true" ]] \
+ || fail 'layoutsEqual no longer calls a layout equal to itself, which would make every restore reapply the geometry it already has'
+
+# The tolerance on the two float fields is a tolerance, not an exemption: a
+# value that came back one ulp different is the same value, a value somebody
+# changed is not.
+nudged="$(jq -c '.[0].sdrBrightness = 1.2000001' <<<"$restored")"
+[[ "$(compare "$restored" "$nudged")" == "true" ]] \
+ || fail 'a float that came back with a rounding difference is treated as a change, so every restore would reapply the layout it already has'
+
trap - EXIT
cleanup
printf 'settings backup live contract: PASS\n'
diff --git a/tests/quickshell/settings-nav-contract b/tests/quickshell/settings-nav-contract
index d6e3480..9b35894 100755
--- a/tests/quickshell/settings-nav-contract
+++ b/tests/quickshell/settings-nav-contract
@@ -52,11 +52,33 @@ tabless_ids="$(grep -oE '\{ page: "[a-z-]+", label: "[^"]*", icon: "[^"]*", tabs
tab_ids="$(grep -oE '\{ page: "[a-z-]+", label: "[^"]*" \}' "$routes" \
| sed -E 's/\{ page: "([a-z-]+)".*/\1/')"
+# A third shape: a leaf that is reachable but is not a tab of anything. The
+# manual is the only one -- it is reference material opened from About's Manual
+# card and from deep links, not a control surface worth a permanent slot in the
+# System strip. It still has to resolve, still has to have a SettingsShell case,
+# and still has to be addressable by every caller holding its id, so it is a
+# leaf for every purpose below except appearing in a tab strip.
+hidden_block="$(sed -n '/property var hiddenLeaves:/,/\]/p' "$routes")"
+hidden_ids="$(grep -oE 'page: "[a-z-]+"' <<<"$hidden_block" | sed -E 's/page: "([a-z-]+)"/\1/')"
+
[[ -n "$category_ids" ]] || fail 'no categories found in SettingsRoutes -- this contract is not reading it correctly'
[[ -n "$tab_ids" ]] || fail 'no tabs found in SettingsRoutes -- this contract is not reading it correctly'
+[[ -n "$hidden_ids" ]] \
+ || fail 'no hiddenLeaves found in SettingsRoutes -- the manual is a routable non-tab leaf and this contract is not reading the mechanism that makes it one'
# The leaves: every page a person can actually land on.
-leaves="$(printf '%s\n%s\n' "$tabless_ids" "$tab_ids" | sed '/^$/d')"
+leaves="$(printf '%s\n%s\n%s\n' "$tabless_ids" "$tab_ids" "$hidden_ids" | sed '/^$/d')"
+
+# A hidden leaf that is also a tab, or also a tabless category, puts one page
+# in two places -- and unlike the tab/category overlap below, nothing visible
+# would show it, because the hidden half draws no row anywhere.
+while read -r page; do
+ [[ -n "$page" ]] || continue
+ grep -qx "$page" <<<"$tab_ids" \
+ && fail "\"$page\" is a hidden leaf and also a tab, so the same page id names two different places"
+ grep -qx "$page" <<<"$tabless_ids" \
+ && fail "\"$page\" is a hidden leaf and also a category of its own"
+done <<<"$hidden_ids"
# ── The taxonomy addresses each leaf exactly once ─────────────────────────────
# ShellState.settingsPage holds a leaf id, and the sidebar highlights the
@@ -103,6 +125,50 @@ while read -r pair; do
fi
done <<<"$retired_pairs"
+# ── The System strip is eight tabs, and the two it lost are still reachable ──
+#
+# System had grown to ten tabs, which is more than a strip can show without
+# becoming a second sidebar. Two left: Region & Language merged into Date &
+# Time, because a date format and the clock that shows it are one subject, and
+# the Manual became a hidden leaf.
+#
+# The count is pinned rather than derived because the number is the point: this
+# is the horizontal space one row of tabs has. Anything that needs an eleventh
+# subject needs a decision, not another entry.
+python3 - "$routes" <<'PY' || fail 'the System category is not the approved eight-tab strip'
+import re
+import sys
+
+expected = [
+ "about", "updates", "services", "storage",
+ "snapshots", "containers", "datetime", "sync",
+]
+text = open(sys.argv[1], encoding="utf-8").read()
+block = re.search(r'\{ page: "system",.*?tabs: \[(.*?)\n \] \}', text, re.S)
+if not block:
+ raise SystemExit("the System category could not be read")
+found = re.findall(r'\{ page: "([a-z-]+)", label: "[^"]*" \}', block.group(1))
+if found != expected:
+ raise SystemExit(f"System tabs are {found}, expected {expected}")
+PY
+
+# `region` retiring is only safe because every caller still holding it lands on
+# the tab that absorbed it. The generic retired-map check above proves the
+# target is a leaf; this proves it is the RIGHT leaf, which is the half a
+# rename cannot get wrong quietly.
+grep -qE '"region"[[:space:]]*:[[:space:]]*"datetime"' <<<"$retired_block" \
+ || fail 'the retired "region" id does not resolve to "datetime", so every Vicinae command, deep link, and search result holding it lands on Home'
+[[ ! -e "$settings_dir/RegionPage.qml" ]] \
+ || fail 'RegionPage.qml still exists, so the retired route has a live page behind it after all'
+
+# The manual is a leaf but not a tab. Said both ways: a strip entry would put
+# reference material back in the System strip that the merge just freed, and
+# losing the leaf would break About's Manual card and every deep link.
+grep -qx 'manual' <<<"$hidden_ids" \
+ || fail 'the manual is not a hidden leaf, so opening it from About or a deep link has no destination'
+grep -qx 'manual' <<<"$tab_ids" \
+ && fail 'the manual is a tab again, which is the System strip slot the consolidation just freed'
+
# ── Every leaf resolves everywhere ───────────────────────────────────────────
while read -r page; do
[[ -n "$page" ]] || continue
@@ -153,5 +219,68 @@ while read -r page_file; do
|| fail "$type_name.qml exists but nothing in SettingsShell instantiates it"
done < <(find "$settings_dir" -maxdepth 1 -name '*Page.qml')
-printf 'settings nav contract: PASS (%d categories, %d leaves, %d retired ids)\n' \
- "$(grep -c . <<<"$category_ids")" "$(grep -c . <<<"$leaves")" "$retired_count"
+# ── The search index lands on leaves, not on ids that used to be leaves ──────
+#
+# SettingsSearch is the fifth place a page id is written down, and the only one
+# where being wrong is silent: `resolve()` turns anything it does not recognise
+# into Home, so a result whose page id was retired still opens a window, still
+# looks like it worked, and lands somewhere else. That is exactly what the two
+# Region & Language entries would have done -- and they are the entries most
+# likely to be searched for by somebody who could not find the setting.
+#
+# Checked against the leaves this file already derived, so a page consolidated
+# next time cannot leave a search result pointing at its old name.
+search="$repo_dir/config/dot/quickshell/services/SettingsSearch.qml"
+[[ -r "$search" ]] || fail "cannot read $search"
+while read -r page; do
+ [[ -n "$page" ]] || continue
+ grep -qx "$page" <<<"$leaves" && continue
+ if grep -qE "\"[a-z-]+\"[[:space:]]*:[[:space:]]*\"$page\"" <<<"$retired_block"; then
+ fail "the search index routes to \"$page\", which is a retired id -- resolve() answers with its target, so the result lands somewhere the row never named"
+ fi
+ fail "the search index routes to \"$page\", which is not a leaf, so that result silently opens Home"
+done < <({
+ grep -oE 'page: "[a-z-]+"' "$search"
+ sed -n '/property var groupPages:/,/})/p' "$search" | grep -oE ': "[a-z-]+"'
+} | sed -E 's/.*"([a-z-]+)".*/\1/' | sort -u)
+
+# The subjects the consolidation moved, each findable by its own name and each
+# landing on the tab that now owns it. Region & Language merged into Date, Time
+# & Region, so the words people arrive with for a format have to reach it; and
+# About and Sync & Backup grew rows nobody could search for at all.
+while IFS='|' read -r label page; do
+ [[ -n "$label" ]] || continue
+ python3 - "$search" "$label" "$page" <<'PY' \
+ || fail "the search index does not offer \"$label\" on the $page page"
+import re
+import sys
+
+text, label, page = open(sys.argv[1], encoding="utf-8").read(), sys.argv[2], sys.argv[3]
+pattern = rf'\{{ label: "{re.escape(label)}",[^\n]*page: "([a-z-]+)" \}}'
+match = re.search(pattern, text)
+if not match:
+ raise SystemExit(f'no entry labelled "{label}"')
+if match.group(1) != page:
+ raise SystemExit(f'"{label}" routes to {match.group(1)}, expected {page}')
+PY
+done <<'SEARCHABLE'
+Hostname|about
+Kernel version|about
+Device model|about
+Installed memory|about
+Uptime|about
+Serial number|about
+Export settings|sync
+Import settings|sync
+Language|datetime
+Regional formats|datetime
+Currency|datetime
+Measurement units|datetime
+Paper size|datetime
+First day of the week|datetime
+Manual|manual
+SEARCHABLE
+
+printf 'settings nav contract: PASS (%d categories, %d leaves of which %d hidden, %d retired ids)\n' \
+ "$(grep -c . <<<"$category_ids")" "$(grep -c . <<<"$leaves")" \
+ "$(grep -c . <<<"$hidden_ids")" "$retired_count"
diff --git a/tests/quickshell/settings-pages-contract b/tests/quickshell/settings-pages-contract
index 7464557..16c8a86 100755
--- a/tests/quickshell/settings-pages-contract
+++ b/tests/quickshell/settings-pages-contract
@@ -13,7 +13,13 @@ fail() {
# rebuild touched all four Connections-category pages at once: three of them had
# never been checked for the page scaffold at all, and a rebuild is exactly when
# a hand-rolled Flickable comes back.
-pages=(Home MyHome Phone Displays Connectivity Firewall Printers Sharing Bar Dock ControlCenter Tiling Workspaces Sync Sound Shortcuts Mouse Dictation Notifications Focus ScreenIntelligence Health About)
+# Updates, DateTime and Containers joined when the System rebuild touched all
+# of them at once. Containers is the one this check was waiting for: its root
+# was a bare Item, so it had never had the page scaffold at all, and every
+# convention the scaffold carries -- the scroll behaviour, the header, the
+# padding -- was hand-rolled there and quietly different from the other
+# twenty-five pages.
+pages=(Home MyHome Phone Displays Connectivity Firewall Printers Sharing Bar Dock ControlCenter Tiling Workspaces Sync Sound Shortcuts Mouse Dictation Notifications Focus ScreenIntelligence Health About Updates DateTime Containers Manual)
for page in "${pages[@]}"; do
page_file="$repo_dir/config/dot/quickshell/modules/settings/${page}Page.qml"
[[ -f "$page_file" ]] || fail "${page}Page.qml is missing"
@@ -359,7 +365,13 @@ shell_pid="$harness_pid"
# four different categories, and the page the tab strip was introduced for.
# Routing to a tab must land on that tab, not on whatever its category opens
# first, which is the failure the SettingsRoutes resolution could introduce.
-pages=(home appearance displays connectivity my-home phone bar dock control-center tiling workspaces sync sound dictation notifications focus screen-intelligence shortcuts mouse services manual about)
+#
+# The four System leaves at the end are the ones the consolidation moved.
+# `updates` and `datetime` were rebuilt, `containers` changed its root type,
+# and `manual` stopped being a tab and became a hidden leaf -- which is exactly
+# the kind of change that keeps a page loading fine while making it
+# unreachable, so each one is opened for real here.
+pages=(home appearance displays connectivity my-home phone bar dock control-center tiling workspaces sync sound dictation notifications focus screen-intelligence shortcuts mouse services manual about updates datetime containers)
for page in "${pages[@]}"; do
qs_for_test ipc call settings page "$page" >/dev/null
for _ in $(seq 1 20); do
@@ -386,6 +398,17 @@ done
[[ "$(qs_for_test ipc call settings status | jq -r .page)" == "bar" ]] \
|| fail 'the retired "desktop" id no longer resolves to the Bar tab'
+# Region & Language merged into Date, Time & Region. Its id is held by two
+# Vicinae commands, the deep links in the manual, and anybody who ever typed
+# it, so it has to land on the tab that absorbed it rather than on Home.
+qs_for_test ipc call settings page region >/dev/null
+for _ in $(seq 1 20); do
+ [[ "$(qs_for_test ipc call settings status | jq -r .page)" == "datetime" ]] && break
+ sleep 0.1
+done
+[[ "$(qs_for_test ipc call settings status | jq -r .page)" == "datetime" ]] \
+ || fail 'the retired "region" id does not resolve to the Date, Time & Region tab'
+
/usr/sbin/hyprctl -j binds | jq -e '.[] | select(.description == "Settings" and .key == "I" and .modmask == 64)' >/dev/null \
|| fail 'Super+I is not registered as Panama Settings'
/usr/sbin/hyprctl -j binds | jq -e '.[] | select(.description == "Screen Intelligence" and .key == "S" and .modmask == 65)' >/dev/null \
diff --git a/tests/quickshell/settings-sync-contract b/tests/quickshell/settings-sync-contract
index 1069b66..dcc26aa 100755
--- a/tests/quickshell/settings-sync-contract
+++ b/tests/quickshell/settings-sync-contract
@@ -20,6 +20,14 @@
# including every numeric enum -- were accepted unchecked, and numeric
# enums were then refused outright once that was noticed.
# 5. Import is a merge. Settings the file does not mention are left alone.
+# 6. The preview says what would change, in a shape a diff list can render.
+# "12 settings would change" is a number, not an answer; the page now
+# shows the rows, so `changes` carries {key, from, to} with both sides
+# already turned into text. Doing that stringification in the helper
+# rather than in QML is what makes the cap enforceable: a value long
+# enough to be something other than a setting is truncated once, here,
+# instead of being handed whole to a Text element and to anybody reading
+# over a shoulder.
#
# Runs entirely against a temporary config home. The real settings store is read
# for the export and never written.
@@ -128,6 +136,60 @@ for key in ("gapsIn", "colorScheme", "vrrPolicy", "blurEnabled", "displays", "so
raise SystemExit(f'{key} was refused and queued for application anyway')
PY
+# ── 6. The preview renders as a diff, and cannot render a secret whole ──────
+
+python3 - "$bundle" "$work/oversized.json" <<'PY'
+import json, sys
+bundle = json.load(open(sys.argv[1]))
+# A real, free-text, non-path string setting, so this exercises a value that
+# genuinely travels rather than one the validator would refuse for its own
+# reasons. 4000 characters is not a location; it is somebody's paste buffer.
+bundle["settings"]["weatherLocation"] = "Bearer sk-fixture-secret-" + ("x" * 4000)
+json.dump(bundle, open(sys.argv[2], "w"))
+PY
+
+"$helper" preview "$work/oversized.json" >"$work/oversized-preview.json" \
+ || fail 'preview failed on a bundle carrying an oversized value'
+python3 - "$work/preview.json" "$work/oversized-preview.json" <<'PY' || fail 'the preview does not describe changes in a shape a diff list can render safely'
+import json
+import sys
+
+CAP = 200
+
+for path in sys.argv[1:]:
+ plan = json.load(open(path))
+
+ if "changes" not in plan:
+ raise SystemExit('the preview does not say what would change')
+ if "changeCount" not in plan:
+ raise SystemExit('the preview lists changes without saying how many there are, '
+ 'so a capped list reads as the whole truth')
+
+ count = plan["changeCount"]
+ if not isinstance(count, int) or count < 0:
+ raise SystemExit('changeCount is not a count')
+ if count != len(plan["apply"]):
+ raise SystemExit(f'changeCount says {count} but {len(plan["apply"])} would be applied')
+ if len(plan["changes"]) > count:
+ raise SystemExit('the rendered list is longer than the number of changes')
+
+ for entry in plan["changes"]:
+ if set(entry) != {"key", "from", "to"}:
+ raise SystemExit(f'a change carries {sorted(entry)}, expected key/from/to')
+ for side in ("key", "from", "to"):
+ if not isinstance(entry[side], str):
+ raise SystemExit(f'{entry["key"]}.{side} is {type(entry[side]).__name__}, '
+ 'not text a row can render')
+ if len(entry[side]) > CAP:
+ raise SystemExit(f'{entry["key"]}.{side} is {len(entry[side])} characters; '
+ 'an uncapped value reaches the screen whole')
+
+oversized = json.load(open(sys.argv[2]))
+rendered = json.dumps(oversized["changes"])
+if "x" * (CAP + 1) in rendered:
+ raise SystemExit('an oversized value was reproduced in full in the change list')
+PY
+
# A clean bundle must arrive intact. Refusing valid settings is the failure this
# contract exists to catch as much as accepting invalid ones -- fixing the enum
# check the first time turned every numeric enum into a rejection.
diff --git a/tests/quickshell/updates-contract b/tests/quickshell/updates-contract
index 65c4311..4df2786 100755
--- a/tests/quickshell/updates-contract
+++ b/tests/quickshell/updates-contract
@@ -138,6 +138,151 @@ if not any(e['source'] == 'flatpak' for e in entries) and not any(e['source'] ==
raise SystemExit('neither source produced anything, so nothing was parsed')
" || fail 'the update history is not usable'
+# ── 5. A download size is the whole download, or it is not offered ──────────
+#
+# dnf5 prices packages from repository metadata, flatpak renders "36.2 MB" and
+# has no machine-readable column at all, and either can come back short. A
+# figure that covers some of what is pending, shown as the download, understates
+# it -- and understating it is the direction that costs somebody money on a
+# metered connection. So the total is present only when every item was priced.
+printf '%s' "$state" | python3 -c "
+import json, sys
+
+state = json.load(sys.stdin)
+for name, key in ((\"dnf\", \"packages\"), (\"flatpak\", \"applications\")):
+ source = state.get(name, {})
+ items = source.get(key, [])
+ if \"downloadBytes\" not in source:
+ continue
+ if not items:
+ raise SystemExit(name + \" priced an empty list\")
+ unpriced = [item for item in items if \"bytes\" not in item]
+ if unpriced:
+ raise SystemExit(name + \" reports a total while items have no size\")
+ total = sum(int(item[\"bytes\"]) for item in items)
+ if int(source[\"downloadBytes\"]) != total:
+ raise SystemExit(name + \" totals \" + str(source[\"downloadBytes\"]) + \" for items summing to \" + str(total))
+" || fail 'a reported download size does not add up to the items it is a size for'
+
+# ── 6. A changelog is a read ────────────────────────────────────────────────
+#
+# This verb takes a package name from a settings page and hands it to dnf, which
+# is the one place in this helper where the page names the subject. Two things
+# have to hold and neither is visible from reading the happy path: the name is
+# constrained before it reaches argv, and nothing on this path installs, removes
+# or upgrades anything.
+#
+# Proved by construction rather than by inspection: dnf5, flatpak and pkexec are
+# replaced with stubs that record their argv and produce nothing, so whatever
+# the helper decides to run is written down and checked afterwards. A stub that
+# answers nothing also exercises the honest-absence path, which is the common
+# case on a machine with third-party repositories.
+
+changelog_work="$(mktemp -d /tmp/panama-updates-changelog.XXXXXX)"
+trap 'rm -rf "$changelog_work"' EXIT
+changelog_bin="$changelog_work/bin"
+changelog_log="$changelog_work/argv.log"
+mkdir -p "$changelog_bin" "$changelog_work/cache/panama"
+for tool in dnf5 flatpak fwupdmgr pkexec systemctl; do
+ cat >"$changelog_bin/$tool" <>"$changelog_log"
+exit 1
+EOF
+ chmod +x "$changelog_bin/$tool"
+done
+
+run_changelog() {
+ PATH="$changelog_bin:$PATH" XDG_CACHE_HOME="$changelog_work/cache" "$helper" "$@"
+}
+
+: >"$changelog_log"
+answer="$(run_changelog changelog dnf zsh)" || fail 'the changelog verb failed'
+jq -e '.source == "dnf" and .name == "zsh" and .error == ""
+ and (.kind | IN("advisory", "changelog", "none"))
+ and (.text | type == "string")' <<<"$answer" >/dev/null \
+ || fail "a changelog answered in an unusable shape: $answer"
+[[ "$(jq -r .kind <<<"$answer")" == "none" ]] \
+ || fail "a source that produced nothing was not reported as having no changelog: $answer"
+[[ -n "$(jq -r .text <<<"$answer")" ]] \
+ || fail 'a package with no changelog says nothing at all, which reads as a failure to load'
+
+run_changelog changelog flatpak org.example.Fixture >/dev/null \
+ || fail 'the flatpak changelog verb failed'
+run_changelog changelog firmware FixtureDevice >/dev/null \
+ || fail 'the firmware changelog verb failed'
+
+python3 - "$changelog_log" <<'PY' || fail 'reading a changelog runs something that changes this machine'
+import sys
+
+mutating = {
+ "install", "remove", "erase", "upgrade", "update", "reinstall", "downgrade",
+ "autoremove", "distro-sync", "swap", "-y", "--assumeyes", "--noninteractive",
+}
+lines = [line.rstrip("\n") for line in open(sys.argv[1], encoding="utf-8") if line.strip()]
+if not lines:
+ raise SystemExit("no commands were recorded, so this proves nothing")
+for line in lines:
+ executable, _, arguments = line.partition("\t")
+ if executable == "pkexec":
+ raise SystemExit("a changelog asked for privilege")
+ for token in arguments.split():
+ if token in mutating:
+ raise SystemExit(f"{executable} was run with {token!r} while reading a changelog")
+PY
+
+# A name the machine would not have produced never reaches argv.
+: >"$changelog_log"
+for hostile in '../../etc/passwd' '/etc/passwd' 'zsh; rm -rf /' '' '-rf'; do
+ refusal="$(run_changelog changelog dnf "$hostile" 2>/dev/null)" \
+ || fail "the changelog verb crashed on \"$hostile\" instead of refusing it"
+ [[ -n "$(jq -r '.error // ""' <<<"$refusal")" ]] \
+ || fail "the changelog verb accepted the name \"$hostile\""
+done
+[[ ! -s "$changelog_log" ]] || fail "a refused changelog name still started a process: $(<"$changelog_log")"
+[[ -n "$(run_changelog changelog nonsense zsh | jq -r '.error // ""')" ]] \
+ || fail 'an unknown changelog source was accepted'
+
+# ── 7. One application, updated by name ─────────────────────────────────────
+#
+# `apply flatpak` updated everything, so the only way to take one application's
+# update was to take all of them -- including the 900 MB one nobody asked about.
+# The per-application verb appends exactly one ID to the same command, and the
+# ID is checked against the last scan rather than trusted: the page is not the
+# authority on what is pending, and this is the only verb that takes a name
+# from it.
+printf '%s' '{"flatpak":{"available":true,"count":1,"applications":[{"id":"org.example.Fixture","version":"1.0","origin":"flathub"}]}}' \
+ >"$changelog_work/cache/panama/updates.json"
+: >"$changelog_log"
+run_changelog apply flatpak org.example.Fixture >/dev/null \
+ || fail 'a per-application update failed to answer'
+# Only the flatpak lines: the refusal path re-reads the snapshot afterwards,
+# which asks systemctl about the two unattended-update timers.
+[[ "$(grep -c $'^flatpak\t' "$changelog_log")" == "1" ]] \
+ || fail "a per-application update ran flatpak more than once: $(<"$changelog_log")"
+grep -Fxq "$(printf 'flatpak\tupdate -y --noninteractive org.example.Fixture')" "$changelog_log" \
+ || fail "the per-application argv was not exact: $(<"$changelog_log")"
+
+: >"$changelog_log"
+refusal="$(run_changelog apply flatpak org.example.NotPending)"
+[[ -n "$(jq -r '.error // ""' <<<"$refusal")" ]] \
+ || fail 'an application with no pending update was accepted'
+! grep -q $'^flatpak\t' "$changelog_log" \
+ || fail "a refused per-application update still ran flatpak: $(<"$changelog_log")"
+[[ -n "$(run_changelog apply dnf somepackage | jq -r '.error // ""')" ]] \
+ || fail 'a per-item target was accepted for a source that cannot take one'
+
+# ── 8. The service offers all three, and the stale comment is gone ──────────
+for api in 'function changelogFor(source: string, name: string): var' \
+ 'function applyFlatpakApp(id: string): void' \
+ 'readonly property string downloadSize'; do
+ grep -Fq "$api" "$service" || fail "the Updates service does not expose: $api"
+done
+grep -Fq 'dnf-automatic is not installed' "$helper" \
+ && fail 'panama-updates still says dnf-automatic is not installed, which stopped being true when set-auto-dnf landed'
+grep -q 'def set_auto_dnf' "$helper" \
+ || fail 'automatic package updates are reported but cannot be turned on'
+
printf 'updates contract: PASS (%s dnf, %s flatpak, %s firmware; reboot needed: %s)\n' \
"$(jq -r '.dnf.count // 0' <<<"$state")" \
"$(jq -r '.flatpak.count // 0' <<<"$state")" \