Finish the wonderland: System told truthfully, in eight tabs instead of ten

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-24 23:31:52 -04:00
parent 9ffaf45a4d
commit be0e55214b
57 changed files with 5040 additions and 925 deletions
+4
View File
@@ -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); }
}
}
@@ -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.
@@ -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.
@@ -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")
}
}
}
@@ -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
}
}
}
}
@@ -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.
@@ -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()
}
}
}
@@ -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 {
@@ -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"
@@ -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, "")
}
}
}
@@ -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));
}
}
}
}
@@ -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")
}
}
}
@@ -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)
@@ -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 {} }
@@ -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:<name>", "delete:<name>", 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";
}
}
@@ -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)
@@ -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
+42 -4
View File
@@ -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)"
+61 -11
View File
@@ -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.")
+128 -12
View File
@@ -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 <locale>
# panama-locale categories -> the category names, one per line
# panama-locale overrides -> {LC_TIME: "...", ...}, "" for none
# panama-locale get <category> -> the override, or "" for "match language"
# panama-locale set <category> <locale|"">
#
# 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" =~ ^[[email protected]]+$ ]] || {
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 <locale>]\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] <locale>]\n' >&2
exit 2
;;
esac
@@ -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
# "<name>.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 <name>]")
fail("usage: panama-settings-backup "
"[save|create [name]|list|restore <name>|delete <name>]")
if __name__ == "__main__":
@@ -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:
@@ -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
@@ -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,
}
+262 -23
View File
@@ -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 <app-id>
panama-updates changelog dnf|flatpak|firmware <name>
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"</p>|</li>", "\n", text)
text = re.sub(r"<li>", "• ", 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
# <nevra>" 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=(",", ":")))
@@ -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 ?? []
+102
View File
@@ -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;
+215 -2
View File
@@ -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);
}
@@ -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 {
@@ -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;
}
}
@@ -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" },
@@ -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 === "";
@@ -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
@@ -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;
}
+158
View File
@@ -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();
}
}
}
@@ -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,