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 repair(id: string): bool { return Health.repair(id, false); }
function report(): string { return JSON.stringify(Health.snapshot, null, 2); } function report(): string { return JSON.stringify(Health.snapshot, null, 2); }
function copy(): bool { return Health.copyReport(); } 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 ## 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 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. there is no way to make a key do something unexpected by editing it.
@@ -2,8 +2,8 @@
## Start here ## Start here
Run **Check System Health** from the launcher, or open Settings and go to Run **Check System Health** from the launcher, or open
System, then the System Health tab. It reports what is actually running rather than what was [System Health](panama://settings/services). It reports what is actually running rather than what was
installed, and it can repair several things itself. installed, and it can repair several things itself.
From a terminal, the same check is `panama doctor`. 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 ## 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 one display or several — and everything under that picture belongs to
whichever one you have selected. Every change there reverts itself after whichever one you have selected. Every change there reverts itself after
fifteen seconds unless you confirm it, so a mode your monitor cannot show 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 `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. 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, 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. 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
import Quickshell.Io
import QtQuick
import qs.config import qs.config
import qs.services import qs.services
SettingsPage { SettingsPage {
id: root id: root
objectName: "about"
title: "About" title: "About"
lede: "A curated Hyprland desktop built around focus, speed, and good taste." lede: "This machine, plainly."
Component.onCompleted: { Component.onCompleted: {
if (!MachineInfo.scanned) if (!MachineInfo.scanned)
@@ -16,22 +33,298 @@ SettingsPage {
GraphicsDevices.refresh(); 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 { SettingsCard {
title: "Desktop" Item {
subtitle: "Tokyo Night Moon · Prism glass · native tiling" 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 { TextRow {
label: "Hyprland" required property var modelData
value: SystemSettings.hyprlandVersion || "Detecting…" 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 { TextRow {
label: "Quickshell" required property var modelData
value: SystemSettings.quickshellVersion
label: modelData.label
value: modelData.value
} }
}
TextRow { TextRow {
label: "Display" visible: MachineInfo.scanned && root.hardwareRows.length === 0
value: SystemSettings.monitorName || "Detecting…" label: "Hardware"
detail: "The system did not report anything readable"
value: "Unavailable"
} }
// 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")
}
}
// ── Software ─────────────────────────────────────────────────────────────
SettingsCard {
title: "Software"
Repeater {
model: root.otherFacts
TextRow {
required property var modelData
label: String(modelData.label ?? "")
value: String(modelData.value ?? "")
}
}
TextRow { TextRow {
label: "Configuration" label: "Configuration"
detail: Quickshell.shellDir detail: Quickshell.shellDir
@@ -40,74 +333,34 @@ SettingsPage {
} }
} }
// What GNOME's About panel answers and this page did not: what am I running // ── Manual ───────────────────────────────────────────────────────────────
// 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;
const graphics = gpus.map((device, index) => ({ ManualChapters { id: contents }
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));
}
SettingsCard { SettingsCard {
title: "This machine" title: "Manual"
subtitle: "Hardware and system, as the kernel reports it." subtitle: "How this desktop works, written for the person using it. Links inside a chapter open the settings page they name."
Repeater { Repeater {
model: root.machineRows id: chapterRows
model: contents.titled
TextRow { ActionRow {
id: machineRow
required property var modelData required property var modelData
required property int index required property int index
label: machineRow.modelData.label label: modelData.title
value: machineRow.modelData.value action: "Read"
divider: machineRow.index < root.machineRows.length - 1 onTriggered: ShellState.openSettingsSection("manual", modelData.file)
} }
} }
TextRow { ActionRow {
visible: MachineInfo.scanned && root.machineRows.length === 0 label: "Open the manual"
label: "Hardware" detail: "Starts at the beginning"
detail: "The system did not report anything readable" action: "Open"
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"
divider: false divider: false
onTriggered: ShellState.openSettings("manual")
} }
} }
} }
@@ -16,11 +16,18 @@ import QtQuick
import qs.config import qs.config
import qs.services import qs.services
Item { SettingsPage {
id: root id: root
objectName: "containers" 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. // "images", "volumes", or empty. Removal never happens on a first press.
property string confirmingPrune: "" property string confirmingPrune: ""
@@ -58,13 +65,6 @@ Item {
// ── the list ──────────────────────────────────────────────────────────── // ── the list ────────────────────────────────────────────────────────────
SettingsPage {
anchors.fill: parent
visible: Containers.logTarget === ""
title: "Containers"
lede: "Local services you run for development — what they expose, and what they cost."
TextRow { TextRow {
visible: Containers.lastError !== "" visible: Containers.lastError !== ""
label: "That did not work" label: "That did not work"
@@ -453,7 +453,6 @@ Item {
value: "" value: ""
divider: false divider: false
} }
}
// ── logs ──────────────────────────────────────────────────────────────── // ── logs ────────────────────────────────────────────────────────────────
// //
@@ -461,9 +460,9 @@ Item {
// own scrollback, and nesting one scrolling view inside another makes the // own scrollback, and nesting one scrolling view inside another makes the
// wheel ambiguous over the region where you most want to use it. // wheel ambiguous over the region where you most want to use it.
drillIn: Component {
Item { Item {
anchors.fill: parent anchors.fill: parent
visible: Containers.logTarget !== ""
Column { Column {
id: logHeader id: logHeader
@@ -555,4 +554,5 @@ Item {
} }
} }
} }
}
} }
@@ -1,16 +1,22 @@
// Date & Time. // Date, Time & Region.
// //
// These belong to the machine rather than to Panama, so nothing here is stored // One tab, because they are one question: what does this machine consider
// in Panama's settings file -- it would be a second answer to a question the // local. It used to be two -- Date & Time here, Region & Language one tab over
// system already answers. Timezone and network time are read from and written // -- and the second was mostly a button that opened GNOME.
// 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.
// //
// Changing the timezone or network time needs privilege. timedatectl asks // Nothing on this page is stored in Panama's settings file except 24-hour time.
// polkit, and a canceled dialog surfaces as an error rather than as a value // The timezone and network time belong to timedatectl; the language and the
// that appears to have been accepted. // 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 QtQuick
import Quickshell import Quickshell
@@ -21,29 +27,42 @@ import qs.modules.clipboard
SettingsPage { SettingsPage {
id: root id: root
title: "Date & Time" objectName: "datetime"
lede: "Timezone and network time, shared with the whole machine."
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 { SettingsCard {
title: "Clock" 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 { SettingRow {
label: "Set automatically" label: "Set automatically"
detail: DateTime.ntpEnabled detail: DateTime.ntpEnabled
? (DateTime.ntpSynchronized ? "Synchronized with a time server" : "Waiting to synchronize") ? (DateTime.ntpSynchronized ? "Synchronized with a time server" : "Waiting to synchronize")
: "The clock is set by hand" : "The clock is set by hand"
controlWidth: 48 controlWidth: 48
divider: false
SettingsToggle { SettingsToggle {
anchors.right: parent.right anchors.right: parent.right
@@ -53,6 +72,32 @@ SettingsPage {
onToggled: value => DateTime.setNtp(value) 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 { SettingsCard {
@@ -109,6 +154,265 @@ SettingsPage {
subtitle: DateTime.lastError 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 // 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 // current zone is always included, so the card never looks empty when the
// field is untouched. // 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" readonly property bool repairFailed: root.check.status !== "ok"
&& Health.lastRepair.checkId === root.check.id && Health.lastRepair.checkId === root.check.id
&& (Health.lastRepair.accepted === false || Health.lastRepair.exitCode !== 0) && (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}` objectName: `health-check-row:${root.issue ? "issue" : "quiet"}:${root.check.id}`
implicitHeight: 62 implicitHeight: 62
@@ -86,7 +93,7 @@ Item {
Text { Text {
width: parent.width width: parent.width
text: root.check.detail text: root.detailText
color: Theme.fgDim color: Theme.fgDim
font.family: Theme.fontFamily font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
@@ -104,7 +111,7 @@ Item {
Text { Text {
objectName: `health-status-text:${root.issue ? "issue" : "quiet"}:${root.check.id}` objectName: `health-status-text:${root.issue ? "issue" : "quiet"}:${root.check.id}`
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
text: root.displayedStatus() text: root.rechecking ? "Checking…" : root.displayedStatus()
color: root.statusColor(root.check.status) color: root.statusColor(root.check.status)
font.family: Theme.fontFamily font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
@@ -124,6 +131,26 @@ Item {
Keys.onReturnPressed: if (enabled) root.actionRequested(root.check) Keys.onReturnPressed: if (enabled) root.actionRequested(root.check)
Keys.onSpacePressed: 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 { Rectangle {
@@ -8,7 +8,17 @@ SettingsPage {
objectName: "system-health-page" objectName: "system-health-page"
title: "System Health" 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 var pendingConfirmation: null
property string instructionTarget: "" property string instructionTarget: ""
@@ -43,11 +53,17 @@ SettingsPage {
"integration.bluebubbles": "bluebubbles" "integration.bluebubbles": "bluebubbles"
}) })
function statusLabel(status: string): string { // What a row says under its title. For a repair, that includes the command
if (status === "ok") return "Healthy"; // the button would run -- said before it runs rather than in a log
if (status === "warning") return "Needs attention"; // afterwards. "Restart Vicinae" is what the button says; what it does is
if (status === "error") return "Action required"; // run something as the person pressing it, and by the time a log could
return "Not set up"; // 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 { function checksForGroup(group: string): var {
@@ -285,6 +301,7 @@ SettingsPage {
width: issueRows.width width: issueRows.width
check: modelData check: modelData
detailText: root.repairDetail(modelData)
issue: true issue: true
divider: index < issueRepeater.count - 1 divider: index < issueRepeater.count - 1
onActionRequested: check => root.handleAction(check) onActionRequested: check => root.handleAction(check)
@@ -379,6 +396,7 @@ SettingsPage {
width: groupRows.width width: groupRows.width
check: modelData check: modelData
detailText: root.repairDetail(modelData)
divider: index < groupRepeater.count - 1 divider: index < groupRepeater.count - 1
onActionRequested: check => root.handleAction(check) 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 { SettingsCard {
title: "Fedora system settings" title: "Fedora system settings"
subtitle: "These areas remain owned by Fedora and GNOME's mature system panels." subtitle: "These areas remain owned by Fedora and GNOME's mature system panels."
@@ -427,14 +465,12 @@ SettingsPage {
} }
} }
ActionRow { // Color profiles used to have a row here whose own detail explained
objectName: "health-fedora-handoff:color" // that pressing it changed nothing -- the colord daemon that applies an
label: "Color profiles" // ICC profile is not running under Hyprland. A handoff that documents
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" // its own uselessness is not a boundary, it is a dead button with an
action: "Open color" // apology attached, so it is gone. Digital wellbeing stays: GNOME
onTriggered: SystemSettings.openGnomePanel("color") // genuinely owns screen time, and that button genuinely works.
}
ActionRow { ActionRow {
objectName: "health-fedora-handoff:wellbeing" objectName: "health-fedora-handoff:wellbeing"
label: "Digital 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 // 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 // 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 // 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. // The chapter list and the chapter titles both come from ManualChapters, which
// They are read at runtime through Quickshell.shellDir, which resolves whether // reads each file's own first heading. This page used to carry a hand-written
// or not the repository is where it usually is; a path walked upward out of // copy of those titles under a comment claiming they were read from the files.
// the shell directory does not.
// //
// One Text per chapter, never the whole manual at once: Text has an implicit // 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 // texture size limit, and a document long enough to hit it fails by going
@@ -30,16 +30,7 @@ SettingsPage {
title: "Manual" title: "Manual"
lede: "How this desktop works, for the person using it." lede: "How this desktop works, for the person using it."
// Chapter files are numbered so their order is their filename. The title readonly property var chapters: contents.titled
// 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" }
]
property int current: 0 property int current: 0
@@ -56,10 +47,12 @@ SettingsPage {
} }
} }
ManualChapters { id: contents }
SettingsTabs { SettingsTabs {
tabs: root.chapters.map((chapter, index) => ({ tabs: root.chapters.map((chapter, index) => ({
value: String(index), value: String(index),
label: chapter.label label: chapter.title
})) }))
current: String(root.current) current: String(root.current)
onSelected: value => root.current = parseInt(value, 10) 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. // size; a document read once needs to be easy on the first pass.
lineHeight: 1.35 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. // scrolls -- the Appearance page pins its live preview here.
property Component header: null 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 { Loader {
id: pinnedHeader id: pinnedHeader
@@ -37,11 +45,19 @@ Item {
anchors.leftMargin: 34 anchors.leftMargin: 34
anchors.rightMargin: 34 anchors.rightMargin: 34
anchors.topMargin: 30 anchors.topMargin: 30
visible: !root.drilledIn
active: root.header !== null active: root.header !== null
sourceComponent: root.header sourceComponent: root.header
z: 1 z: 1
} }
Loader {
anchors.fill: parent
active: root.drillIn !== null && root.drilledIn
sourceComponent: root.drillIn
z: 2
}
Flickable { Flickable {
id: pageScroll id: pageScroll
@@ -50,6 +66,7 @@ Item {
anchors.top: pinnedHeader.implicitHeight > 0 ? pinnedHeader.bottom : parent.top anchors.top: pinnedHeader.implicitHeight > 0 ? pinnedHeader.bottom : parent.top
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
anchors.topMargin: pinnedHeader.implicitHeight > 0 ? 16 : 0 anchors.topMargin: pinnedHeader.implicitHeight > 0 ? 16 : 0
visible: !root.drilledIn
clip: true clip: true
contentWidth: width contentWidth: width
contentHeight: layout.implicitHeight + (pinnedHeader.implicitHeight > 0 ? 34 : 64) contentHeight: layout.implicitHeight + (pinnedHeader.implicitHeight > 0 ? 34 : 64)
@@ -11,9 +11,16 @@ Rectangle {
&& pageLoader.item.objectName === "my-home-page" && pageLoader.item.objectName === "my-home-page"
? pageLoader.item.pageDiagnostics ? 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 readonly property var healthDiagnostics: pageLoader.status === Loader.Ready
&& pageLoader.item && pageLoader.item
&& pageLoader.item.objectName === "system-health-page" && pageLoader.item.objectName === "system-health-page"
&& typeof pageLoader.item.uiDiagnostics === "function"
? pageLoader.item.uiDiagnostics() ? pageLoader.item.uiDiagnostics()
: ({}) : ({})
@@ -181,7 +188,6 @@ Rectangle {
case "mouse": return mousePage; case "mouse": return mousePage;
case "dictation": return dictationPage; case "dictation": return dictationPage;
case "privacy": return privacyPage; case "privacy": return privacyPage;
case "region": return regionPage;
case "accounts": return onlineAccountsPage; case "accounts": return onlineAccountsPage;
case "accessibility": return accessibilityPage; case "accessibility": return accessibilityPage;
case "power": return powerPage; case "power": return powerPage;
@@ -267,7 +273,6 @@ Rectangle {
Component { id: mousePage; MousePage {} } Component { id: mousePage; MousePage {} }
Component { id: dictationPage; DictationPage {} } Component { id: dictationPage; DictationPage {} }
Component { id: privacyPage; PrivacyPage {} } Component { id: privacyPage; PrivacyPage {} }
Component { id: regionPage; RegionPage {} }
Component { id: onlineAccountsPage; OnlineAccountsPage {} } Component { id: onlineAccountsPage; OnlineAccountsPage {} }
Component { id: healthPage; HealthPage {} } Component { id: healthPage; HealthPage {} }
Component { id: manualPage; ManualPage {} } Component { id: manualPage; ManualPage {} }
@@ -8,6 +8,12 @@
// snapshots one tab over in System Snapshots, which put the whole filesystem // 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" // back -- the card says so, because the two used to share the word "Snapshots"
// and nothing distinguished them. // 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 QtQuick
import qs.config import qs.config
@@ -16,8 +22,35 @@ import qs.services
SettingsPage { SettingsPage {
id: root id: root
objectName: "sync"
title: "Sync & Backup" 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. // Distinct from the backups below, which put THIS machine back as it was.
// This carries settings to a different one, and deliberately leaves behind // This carries settings to a different one, and deliberately leaves behind
@@ -41,32 +74,122 @@ SettingsPage {
ActionRow { ActionRow {
label: "See what an import would change" label: "See what an import would change"
detail: SettingsSync.previewed detail: SettingsSync.previewed
? SettingsSync.changes.length + " would change, " ? SettingsSync.changeCount + " would change, "
+ SettingsSync.skipped.length + " skipped" + SettingsSync.skipped.length + " skipped"
: "Reads " + SettingsSync.defaultPath + " without applying anything" : "Reads " + SettingsSync.defaultPath + " without applying anything"
action: "Preview" action: "Preview"
enabled: !SettingsSync.busy enabled: !SettingsSync.busy
divider: !SettingsSync.previewed
onTriggered: SettingsSync.preview(SettingsSync.defaultPath) 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 // Only offered once a preview has said what it would do. Importing
// settings sight unseen is how somebody ends up wondering why their // settings sight unseen is how somebody ends up wondering why their
// desktop changed. // desktop changed.
ActionRow { ActionRow {
visible: SettingsSync.previewed && SettingsSync.changes.length > 0 visible: SettingsSync.previewed && SettingsSync.changeCount > 0
label: "Apply those " + SettingsSync.changes.length + " changes" label: "Apply those " + SettingsSync.changeCount + " changes"
detail: "Settings the file does not mention are left alone" detail: "Settings the file does not mention are left alone"
action: "Import" action: "Import"
enabled: !SettingsSync.busy enabled: !SettingsSync.busy
divider: false
onTriggered: SettingsSync.importFrom(SettingsSync.defaultPath) onTriggered: SettingsSync.importFrom(SettingsSync.defaultPath)
} }
Repeater { Repeater {
model: SettingsSync.previewed ? SettingsSync.skipped : [] model: SettingsSync.previewed ? SettingsSync.skipped : []
delegate: TextRow { TextRow {
required property var modelData required property var modelData
width: parent.width
label: String(modelData.key ?? "") label: String(modelData.key ?? "")
detail: "Skipped: " + String(modelData.reason ?? "") detail: "Skipped: " + String(modelData.reason ?? "")
value: "" value: ""
@@ -84,51 +207,187 @@ SettingsPage {
} }
} }
// ── Backups ──────────────────────────────────────────────────────────────
SettingsCard { SettingsCard {
title: "Settings backups" title: "Settings backups"
subtitle: SettingsBackup.lastError !== "" subtitle: SettingsBackup.lastError !== ""
? 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." : "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" label: "Back up current settings"
detail: SettingsBackup.snapshots.length === 0 detail: SettingsBackup.snapshots.length === 0
? "No backups yet" ? "No backups yet"
: SettingsBackup.snapshots.length + (SettingsBackup.snapshots.length === 1 ? " backup kept" : " backups kept") + ", newest first" : SettingsBackup.snapshots.length
action: "Back up now" + (SettingsBackup.snapshots.length === 1 ? " backup kept" : " backups kept")
+ ", newest first"
placeholder: "Name (optional)"
action: SettingsBackup.busy ? "Working…" : "Back up"
enabled: !SettingsBackup.busy enabled: !SettingsBackup.busy
divider: SettingsBackup.snapshots.length > 0 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 { Repeater {
id: snapshotRows id: snapshotRows
model: SettingsBackup.snapshots model: SettingsBackup.snapshots
ActionRow { SettingRow {
id: backupRow
required property var modelData required property var modelData
required property int index required property int index
label: modelData.when readonly property string backupName: String(backupRow.modelData.name ?? "")
detail: modelData.keys + " settings" readonly property bool confirmingRestore:
action: "Restore" 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 enabled: !SettingsBackup.busy
divider: index < snapshotRows.count - 1 onClicked: root.arm("restore:" + backupRow.backupName)
onTriggered: SettingsBackup.restore(modelData.name) }
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 { SettingsCard {
title: "Reset" 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" label: "Restore defaults"
detail: "Applies immediately, including to the compositor" detail: resetRow.armed
action: "Restore defaults" ? "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 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 // 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 // timestamp. That is the thing neither macOS nor Windows does cleanly, and it
// is nearly free here. // 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 Quickshell
import QtQuick import QtQuick
@@ -20,9 +28,16 @@ SettingsPage {
objectName: "updates" objectName: "updates"
title: "Software Update" 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: { Component.onCompleted: {
Updates.refresh(); Updates.refresh();
@@ -32,6 +47,50 @@ SettingsPage {
Updates.check(); 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 { TextRow {
visible: Updates.lastError !== "" visible: Updates.lastError !== ""
label: "Updates need attention" label: "Updates need attention"
@@ -54,43 +113,7 @@ SettingsPage {
divider: false divider: false
} }
// ── The headline ─────────────────────────────────────────────────────────
SettingsCard { 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 { ActionRow {
label: "Check for updates" label: "Check for updates"
detail: "Refreshes package metadata, application remotes, and firmware. Takes a few seconds." detail: "Refreshes package metadata, application remotes, and firmware. Takes a few seconds."
@@ -113,55 +136,90 @@ SettingsPage {
} }
} }
// ── One card per source ────────────────────────────────────────────────── // ── System packages ──────────────────────────────────────────────────────
SettingsCard { SettingsCard {
title: "System packages" title: "System packages"
subtitle: Updates.dnf?.available === false subtitle: Updates.dnf?.available === false
? "dnf is not available on this machine." ? "dnf is not available on this machine."
: (Number(Updates.dnf?.count ?? 0) === 0 : (root.packages.length === 0
? "Nothing waiting." ? "Nothing waiting."
: Updates.dnf.count + " package" : root.joined([
+ (Updates.dnf.count === 1 ? "" : "s") + " ready to install" root.packages.length + " ready to install",
+ (Updates.securityCount > 0 Updates.securityCount > 0
? ", " + Updates.securityCount + " carrying an advisory" : "")) ? Updates.securityCount + " carrying an advisory" : "",
Updates.sourceDownloadSize("dnf") === ""
? "" : Updates.sourceDownloadSize("dnf") + " to download"
]))
Repeater {
id: packageRows
model: root.shownPackages
Column {
id: packageRow
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
ActionRow { ActionRow {
visible: Number(Updates.dnf?.count ?? 0) > 0 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" label: "Install package updates"
detail: "Asks for your password, and takes a snapshot first so this can be undone" detail: "Asks for your password, and takes a snapshot first — undo lives in System Snapshots"
action: Updates.applying ? "Working…" : "Install" action: Updates.applying ? "Working…" : "Install updates"
enabled: !Updates.busy enabled: !Updates.busy
divider: false
onTriggered: Updates.apply("dnf") 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"
}
Repeater {
model: root.expandedSource === "dnf" ? (Updates.dnf?.packages ?? []) : []
delegate: TextRow {
required property var modelData
required property int index
width: parent.width
label: String(modelData.name ?? "")
detail: String(modelData.repository ?? "")
value: String(modelData.version ?? "")
divider: index < (Updates.dnf?.packages ?? []).length - 1
}
}
TextRow { TextRow {
visible: Number(Updates.dnf?.count ?? 0) === 0 && Updates.everChecked visible: root.packages.length === 0 && Updates.everChecked
label: "Packages are current" label: "Packages are current"
detail: "Nothing from the system repositories is waiting" detail: "Nothing from the system repositories is waiting"
value: "" 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 { SettingsCard {
title: "Applications" title: "Automatic"
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")
Repeater { SwitchRow {
model: Updates.flatpak?.applications ?? [] visible: Updates.automatic?.dnfAutomaticAvailable === true
label: "Download package updates automatically"
delegate: TextRow { 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."
required property var modelData checked: Updates.automatic?.dnfAutomaticEnabled === true
required property int index
width: parent.width
label: String(modelData.id ?? "")
detail: "Flatpak"
value: String(modelData.version ?? "")
divider: true
}
}
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 enabled: !Updates.busy
onTriggered: Updates.apply("flatpak") onToggled: value => Updates.setAutomaticDnf(value)
}
// 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 { SwitchRow {
@@ -211,32 +264,122 @@ SettingsPage {
} }
} }
// ── Applications & firmware ──────────────────────────────────────────────
SettingsCard { SettingsCard {
title: "Firmware" title: "Applications & 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")
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 { Repeater {
model: Updates.firmware?.devices ?? [] id: applicationRows
model: root.applications
Column {
id: applicationRow
delegate: TextRow {
required property var modelData required property var modelData
required property int index required property int index
readonly property string appId: String(applicationRow.modelData.id ?? "")
readonly property bool open: root.expandedPackage === "flatpak:" + applicationRow.appId
width: parent.width width: parent.width
label: String(modelData.name ?? "")
detail: String(modelData.version ?? "") + " → " + String(modelData.target ?? "") ActionRow {
+ (modelData.needsReboot ? " · installs on restart" : "") width: parent.width
value: "" label: applicationRow.appId
divider: true 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 { 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" label: "Install firmware"
detail: "Some devices only finish updating after a restart" detail: "Some devices only finish updating after a restart"
action: Updates.applying ? "Working…" : "Install" action: Updates.applying ? "Working…" : "Install"
@@ -244,27 +387,6 @@ SettingsPage {
divider: false divider: false
onTriggered: Updates.apply("firmware") 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 // Worth showing because automatic updates leave no other trace. Something
@@ -288,10 +410,10 @@ SettingsPage {
Repeater { Repeater {
model: Updates.history model: Updates.history
delegate: TextRow { TextRow {
required property var modelData required property var modelData
required property int index required property int index
width: parent.width
label: String(modelData.summary ?? "") label: String(modelData.summary ?? "")
detail: Updates.agoText(Number(modelData.at ?? 0)) detail: Updates.agoText(Number(modelData.at ?? 0))
value: Updates.describeHistory(modelData) value: Updates.describeHistory(modelData)
@@ -114,7 +114,6 @@ TextEntryRow 1.0 TextEntryRow.qml
PrivacyPage 1.0 PrivacyPage.qml PrivacyPage 1.0 PrivacyPage.qml
PrivacyLiveTile 1.0 PrivacyLiveTile.qml PrivacyLiveTile 1.0 PrivacyLiveTile.qml
SectionLabel 1.0 SectionLabel.qml SectionLabel 1.0 SectionLabel.qml
RegionPage 1.0 RegionPage.qml
SearchPicker 1.0 SearchPicker.qml SearchPicker 1.0 SearchPicker.qml
OnlineAccountsPage 1.0 OnlineAccountsPage.qml OnlineAccountsPage 1.0 OnlineAccountsPage.qml
TextFieldRow 1.0 TextFieldRow.qml TextFieldRow 1.0 TextFieldRow.qml
@@ -135,3 +134,5 @@ FingerprintEnrollPanel 1.0 FingerprintEnrollPanel.qml
OnlineAccountRow 1.0 OnlineAccountRow.qml OnlineAccountRow 1.0 OnlineAccountRow.qml
IdleTimeline 1.0 IdleTimeline.qml IdleTimeline 1.0 IdleTimeline.qml
PowerProfileTiles 1.0 PowerProfileTiles.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. # 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 # 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, # fastfetch answers it in more detail; this covers both -- model, OS, Panama's
# uptime, package counts, shell, resolution, processor, memory, swap, disk and # own revision, firmware, Secure Boot, kernel, uptime, package counts, shell,
# locale. Rows are ordered roughly the way fastfetch presents them: what the # resolution, processor, memory, swap, disk and locale. Rows are ordered
# system is, then what is installed on it, then the hardware underneath. # 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 # Graphics is deliberately absent: GraphicsDevices already enumerates GPUs for
# the vitals readout, and naming them again here would be a second source of # the vitals readout, and naming them again here would be a second source of
@@ -43,6 +44,20 @@ dmi() {
printf '%s' "$value" 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)" vendor="$(dmi sys_vendor)"
product="$(dmi product_name)" product="$(dmi product_name)"
if [[ -n "$vendor" && -n "$product" ]]; then if [[ -n "$vendor" && -n "$product" ]]; then
@@ -51,6 +66,29 @@ else
emit "Model" "${product:-$vendor}" emit "Model" "${product:-$vendor}"
fi 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 "Hostname" "$(hostnamectl hostname 2>/dev/null || hostname 2>/dev/null)"
emit "Kernel" "$(uname -r 2>/dev/null)" emit "Kernel" "$(uname -r 2>/dev/null)"
+61 -11
View File
@@ -200,6 +200,10 @@ CHECK_TITLES = {
"integration.bluebubbles": "BlueBubbles", "integration.bluebubbles": "BlueBubbles",
"integration.home-assistant": "Home Assistant", "integration.home-assistant": "Home Assistant",
"integration.calendar": "Calendar", "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.runtime-links": "Panama runtime links",
"panama.vicinae-commands": "Panama commands", "panama.vicinae-commands": "Panama commands",
"panama.selected-terminal": "Selected terminal", "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} 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: if check.action is not None:
result["action"] = action_json(check.action) 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 return result
@@ -659,14 +671,14 @@ def check_updates(config: DoctorConfig) -> Check:
if newest != running: if newest != running:
return Check("panama.updates", "panama-tools", "Software updates", "warning", 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.", 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: try:
payload = json.loads(cache.read_text(encoding="utf-8")) payload = json.loads(cache.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError): except (OSError, json.JSONDecodeError):
return Check("panama.updates", "panama-tools", "Software updates", "unconfigured", return Check("panama.updates", "panama-tools", "Software updates", "unconfigured",
"Updates have not been checked yet.", "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)) checked_at = int(payload.get("checkedAt", 0))
age_days = (time.time() - checked_at) / 86400 if checked_at else 999 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: if security > 0:
return Check("panama.updates", "panama-tools", "Software updates", "warning", return Check("panama.updates", "panama-tools", "Software updates", "warning",
f"{security} pending update{'' if security == 1 else 's'} carry a security advisory.", 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: if age_days > 7:
return Check("panama.updates", "panama-tools", "Software updates", "unconfigured", return Check("panama.updates", "panama-tools", "Software updates", "unconfigured",
"Updates have not been checked in over a week.", "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: if total > 0:
return Check("panama.updates", "panama-tools", "Software updates", "ok", return Check("panama.updates", "panama-tools", "Software updates", "ok",
f"{total} update{'' if total == 1 else 's'} available, none carrying a security advisory.") 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")] return [{"id": name, "version": "unavailable"} for name in ("hyprland", "quickshell", "fedora", "panama")]
def collect_checks(config: DoctorConfig) -> list[Check]: def probe_table(config: DoctorConfig) -> dict[str, Callable[[], Check]]:
probes: 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.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), "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), "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), "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), "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: with ThreadPoolExecutor(max_workers=8) as executor:
futures = {check_id: executor.submit(probes[check_id]) for check_id in CHECK_ORDER} futures = {check_id: executor.submit(probes[check_id]) for check_id in CHECK_ORDER}
checks: list[Check] = [] checks: list[Check] = []
@@ -818,11 +834,7 @@ def collect_checks(config: DoctorConfig) -> list[Check]:
return checks return checks
def snapshot(config: DoctorConfig) -> dict[str, object]: def snapshot_of(config: DoctorConfig, checks: list[Check]) -> dict[str, object]:
try:
checks = collect_checks(config)
except Exception:
checks = [unavailable_check(check_id) for check_id in CHECK_ORDER]
counts = {status: sum(check.status == status for check in checks) for status in ("ok", "warning", "error", "unconfigured")} 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" 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" 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]} 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: def repair_authored_command(check_id: str, config: DoctorConfig) -> RepairResult:
command = REPAIR_COMMANDS[check_id] command = REPAIR_COMMANDS[check_id]
if check_id == "desktop.quickshell": 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("--json", action="store_true")
output.add_argument("--summary", action="store_true") output.add_argument("--summary", action="store_true")
parser.add_argument("--repair", metavar="CHECK_ID") 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) 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 is not None:
if args.repair not in REPAIR_IDS or args.summary: if args.repair not in REPAIR_IDS or args.summary:
result = RepairResult(args.repair, False, 2, "This health check has no authored repair.") result = RepairResult(args.repair, False, 2, "This health check has no authored repair.")
+125 -9
View File
@@ -5,6 +5,22 @@
# panama-locale list -> [{value, label, detail}] # panama-locale list -> [{value, label, detail}]
# panama-locale get -> the current LANG, e.g. en_US.UTF-8 # panama-locale get -> the current LANG, e.g. en_US.UTF-8
# panama-locale set <locale> # 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 # 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 # 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_LANG=/usr/share/iso-codes/json/iso_639-2.json
readonly ISO_COUNTRY=/usr/share/iso-codes/json/iso_3166-1.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() { cmd_get() {
localectl status 2>/dev/null \ 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 }' \ | awk -F'LANG=' '/System Locale:/ { print $2; exit }' \
| tr -d '[:space:]' | 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() { cmd_list() {
@@ -75,10 +143,11 @@ cmd_list() {
' <<<"$locales" ' <<<"$locales"
} }
cmd_set() { # A locale name that is both well formed and actually installed. Everything
local locale="${1:-}" # reaching localectl goes through this: it is a privileged command, and the set
# Constrained rather than passed through: this reaches a privileged # of legal locale names is narrow and well known.
# command, and the set of legal locale names is narrow and well known. installed_locale() {
local locale="$1"
[[ "$locale" =~ ^[[email protected]]+$ ]] || { [[ "$locale" =~ ^[[email protected]]+$ ]] || {
printf 'panama-locale: refusing a locale name with unexpected characters\n' >&2 printf 'panama-locale: refusing a locale name with unexpected characters\n' >&2
return 2 return 2
@@ -87,12 +156,59 @@ cmd_set() {
printf 'panama-locale: %s is not an installed locale\n' "$locale" >&2 printf 'panama-locale: %s is not an installed locale\n' "$locale" >&2
return 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" 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 case "${1:-list}" in
list) cmd_list ;; list) cmd_list ;;
get) cmd_get ;; get) shift; cmd_get "${1:-LANG}" ;;
set) shift; cmd_set "${1:-}" ;; set)
*) printf 'usage: panama-locale [list|get|set <locale>]\n' >&2; exit 2 ;; 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 esac
@@ -39,6 +39,13 @@ KEEP = 15
SNAPSHOT_RE = re.compile(r"^settings-[0-9]{8}-[0-9]{9}\.json$") SNAPSHOT_RE = re.compile(r"^settings-[0-9]{8}-[0-9]{9}\.json$")
ENTITY_RE = re.compile(r"^light\.[a-z0-9_]+$") 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): class BackupError(RuntimeError):
pass pass
@@ -210,12 +217,27 @@ def clean_transaction_artifacts() -> None:
fsync_directory(TRANSACTION_PARENT) 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: def clean_stale_atomic_files() -> None:
locations = ( locations = (
(SETTINGS.parent, (".settings.json.",)), (SETTINGS.parent, (".settings.json.",)),
(HOME_STATE.parent, (".panama-home.json.",)), (HOME_STATE.parent, (".panama-home.json.",)),
(BACKUP_DIR, (".settings-",)), (BACKUP_DIR, (".settings-",)),
) )
now = time.time()
for directory, prefixes in locations: for directory, prefixes in locations:
if not directory.exists(): if not directory.exists():
continue continue
@@ -223,7 +245,14 @@ def clean_stale_atomic_files() -> None:
fail(f"{directory} is not a safe directory.") fail(f"{directory} is not a safe directory.")
changed = False changed = False
for child in directory.iterdir(): for child in directory.iterdir():
if not any(child.name.startswith(prefix) for prefix in prefixes): 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 continue
# Only Panama's hidden atomic-write names are eligible. A matching # Only Panama's hidden atomic-write names are eligible. A matching
# directory is unexpected and is never recursively removed. # directory is unexpected and is never recursively removed.
@@ -359,7 +388,23 @@ def prune_snapshots() -> None:
durable_remove(old) 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: try:
desktop_present, desktop = current_store( desktop_present, desktop = current_store(
SETTINGS, "The current settings file" SETTINGS, "The current settings file"
@@ -386,6 +431,11 @@ def save_snapshot(*, require_any: bool, validate: bool) -> Path | None:
envelope["desktop"]["data"] = desktop envelope["desktop"]["data"] = desktop
if home_present: if home_present:
envelope["home"]["data"] = home 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() destination = next_snapshot_path()
atomic_write_json(destination, envelope) atomic_write_json(destination, envelope)
@@ -515,6 +565,37 @@ def command_save(arguments: list[str]) -> None:
print(json.dumps({"saved": destination.name}, separators=(",", ":"))) 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]: def snapshot_files() -> list[Path]:
ensure_directory(BACKUP_DIR) ensure_directory(BACKUP_DIR)
return sorted( return sorted(
@@ -533,6 +614,7 @@ def snapshot_files() -> list[Path]:
def command_list() -> None: def command_list() -> None:
output: list[dict[str, Any]] = [] output: list[dict[str, Any]] = []
for path in snapshot_files(): for path in snapshot_files():
label = ""
try: try:
value = read_json(path, "A snapshot") value = read_json(path, "A snapshot")
if is_v2_envelope(value): if is_v2_envelope(value):
@@ -540,6 +622,9 @@ def command_list() -> None:
keys = len(desktop["data"]) if desktop["present"] else 0 keys = len(desktop["data"]) if desktop["present"] else 0
else: else:
keys = len(value) keys = len(value)
raw_label = value.get("label")
if isinstance(raw_label, str) and LABEL_RE.fullmatch(raw_label):
label = raw_label
except BackupError: except BackupError:
keys = 0 keys = 0
raw = path.name.removeprefix("settings-").removesuffix(".json") 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[0:4]}-{raw[4:6]}-{raw[6:8]} "
f"{raw[9:11]}:{raw[11:13]}:{raw[13:15]}" 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=(",", ":"))) print(json.dumps(output, separators=(",", ":")))
@@ -628,12 +720,17 @@ def main() -> None:
arguments = sys.argv[2:] arguments = sys.argv[2:]
if command == "save": if command == "save":
command_save(arguments) command_save(arguments)
elif command == "create":
command_create(arguments)
elif command == "list": elif command == "list":
command_list() command_list()
elif command == "restore": elif command == "restore":
command_restore(arguments) command_restore(arguments)
elif command == "delete":
command_delete(arguments)
else: 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__": 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*\}', r'\{\s*page:\s*"([a-z-]+)",\s*label:\s*"([^"]+)",\s*icon:\s*"[^"]*",\s*tabs:\s*\[([^\]]*)\]\s*\}',
re.S) re.S)
TAB = re.compile(r'\{\s*page:\s*"([a-z-]+)",\s*label:\s*"([^"]+)"\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]]]]: 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 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]]: def pages() -> list[tuple[str, str]]:
"""The leaf pages, in sidebar order, as (id, label). """The leaf pages, in sidebar order, as (id, label).
@@ -97,6 +126,7 @@ def pages() -> list[tuple[str, str]]:
leaves: list[tuple[str, str]] = [] leaves: list[tuple[str, str]] = []
for page, label, tabs in categories(): for page, label, tabs in categories():
leaves += tabs or [(page, label)] leaves += tabs or [(page, label)]
leaves += hidden_leaves()
ids = [page for page, _label in leaves] ids = [page for page, _label in leaves]
duplicated = sorted({page for page in ids if ids.count(page) > 1}) duplicated = sorted({page for page in ids if ids.count(page) > 1})
if duplicated: 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*\}', r'\{\s*page:\s*"([a-z-]+)",\s*label:\s*"([^"]+)",\s*icon:\s*"[^"]*",\s*tabs:\s*\[([^\]]*)\]\s*\}',
re.S) re.S)
TAB = re.compile(r'\{\s*page:\s*"([a-z-]+)",\s*label:\s*"([^"]+)"\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): class SchemaError(RuntimeError):
@@ -61,9 +63,11 @@ def read_titles():
if not block: if not block:
raise SchemaError("could not find the categories array in SettingsRoutes.qml") raise SchemaError("could not find the categories array in SettingsRoutes.qml")
titles = {} titles = {}
labels = {}
read = 0 read = 0
for page, label, tabs in CATEGORY.findall(block.group(1)): for page, label, tabs in CATEGORY.findall(block.group(1)):
found = TAB.findall(tabs) found = TAB.findall(tabs)
labels[page] = label
read += 1 + len(found) read += 1 + len(found)
if found: if found:
titles.update({tab: f"{label} {tab_label}" for tab, tab_label in found}) titles.update({tab: f"{label} {tab_label}" for tab, tab_label in found})
@@ -77,6 +81,19 @@ def read_titles():
raise SchemaError( raise SchemaError(
f"read {read} of the {declared} pages in SettingsRoutes.qml; the " f"read {read} of the {declared} pages in SettingsRoutes.qml; the "
"categories array no longer looks the way this reader expects") "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 return titles
@@ -58,6 +58,17 @@ MACHINE_SPECIFIC = {
# machine where the file exists. Carried, then checked on arrival. # machine where the file exists. Carried, then checked on arrival.
PATH_VALUED = {"wallpaperPath", "wallpaperSlideshowPaths"} 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): class BoundaryError(RuntimeError):
"""A user-visible validation or file failure.""" """A user-visible validation or file failure."""
@@ -158,6 +169,29 @@ def fits(entry: dict, value) -> str:
return "" 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]]: def exportable() -> tuple[dict, list[dict]]:
known = schema() known = schema()
current = stored() current = stored()
@@ -248,14 +282,17 @@ def plan(path: str) -> dict:
continue continue
apply[key] = value apply[key] = value
changes.append({"key": key, changes.append({"key": key,
"from": current.get(key, None), "from": render(current.get(key)),
"to": value}) "to": render(value)})
return { return {
"path": str(Path(path).expanduser()), "path": str(Path(path).expanduser()),
"exportedFrom": str(bundle.get("exportedFrom", "")), "exportedFrom": str(bundle.get("exportedFrom", "")),
"exportedAt": int(bundle.get("exportedAt", 0)), "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, "skipped": skipped,
"apply": apply, "apply": apply,
} }
+261 -22
View File
@@ -15,7 +15,10 @@ updater does, instead of pretending the number is live.
panama-updates snapshot panama-updates snapshot
panama-updates check panama-updates check
panama-updates apply dnf|flatpak|firmware 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-flatpak true|false
panama-updates set-auto-dnf true|false
""" """
from __future__ import annotations from __future__ import annotations
@@ -30,10 +33,22 @@ import sys
import time import time
from pathlib import Path from pathlib import Path
# The user timer this ships for keeping applications current. dnf has no # The two unattended-update timers this machine can have. The flatpak one is
# equivalent here because dnf-automatic is not installed, and installing # Panama's own user timer; the dnf one is dnf5-automatic's system timer, which
# software is not this script's job. # 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" 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 # Anything carrying an advisory of these severities is reported as a security
# fix. "none" is excluded deliberately: an advisory with no severity is a # 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: def dnf_updates() -> dict:
if not shutil.which("dnf5"): if not shutil.which("dnf5"):
return {"available": False, "count": 0, "packages": [], "securityCount": 0} return {"available": False, "count": 0, "packages": [], "securityCount": 0}
@@ -131,24 +170,74 @@ def dnf_updates() -> dict:
except json.JSONDecodeError: except json.JSONDecodeError:
security = 0 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"]) packages.sort(key=lambda item: item["name"])
return {"available": True, "count": len(packages), "packages": packages, source = {"available": True, "count": len(packages), "packages": packages,
"securityCount": security} "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: def flatpak_updates() -> dict:
if not shutil.which("flatpak"): if not shutil.which("flatpak"):
return {"available": False, "count": 0, "applications": []} return {"available": False, "count": 0, "applications": []}
result = run(["flatpak", "remote-ls", "--updates", "--columns=application,version"], result = run(["flatpak", "remote-ls", "--updates",
timeout=120) "--columns=application,version,origin,download-size"], timeout=120)
applications = [] applications = []
if result.returncode == 0: if result.returncode == 0:
for line in result.stdout.splitlines(): for line in result.stdout.splitlines():
parts = [part.strip() for part in line.split("\t")] parts = [part.strip() for part in line.split("\t")]
if parts and parts[0]: if not parts or not parts[0]:
applications.append({"id": parts[0], continue
"version": parts[1] if len(parts) > 1 else ""}) application = {"id": parts[0],
return {"available": True, "count": len(applications), "applications": applications} "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: def firmware_updates() -> dict:
@@ -160,13 +249,20 @@ def firmware_updates() -> dict:
payload = json.loads(result.stdout or "{}") payload = json.loads(result.stdout or "{}")
for device in payload.get("Devices", []): for device in payload.get("Devices", []):
releases = device.get("Releases", []) releases = device.get("Releases", [])
devices.append({ entry = {
"name": str(device.get("Name", "Unknown device")), "name": str(device.get("Name", "Unknown device")),
"version": str(device.get("Version", "")), "version": str(device.get("Version", "")),
"target": str(releases[0].get("Version", "")) if releases else "", "target": str(releases[0].get("Version", "")) if releases else "",
# Firmware that needs a reboot to flash is worth saying up front. # Firmware that needs a reboot to flash is worth saying up front.
"needsReboot": "needs-reboot" in json.dumps(device.get("Flags", [])), "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: except json.JSONDecodeError:
pass pass
return {"available": True, "count": len(devices), "devices": devices} return {"available": True, "count": len(devices), "devices": devices}
@@ -174,12 +270,13 @@ def firmware_updates() -> dict:
def automatic_state() -> dict: def automatic_state() -> dict:
flatpak_timer = run(["systemctl", "--user", "is-enabled", FLATPAK_TIMER], timeout=20) 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 { return {
"flatpakEnabled": flatpak_timer.stdout.strip() == "enabled", "flatpakEnabled": flatpak_timer.stdout.strip() == "enabled",
"flatpakAvailable": flatpak_timer.stdout.strip() not in ("", "not-found"), "flatpakAvailable": flatpak_timer.stdout.strip() not in ("", "not-found"),
# Reported, never offered: dnf-automatic is a package this machine does # Offered when the timer exists, reported as unavailable when it does
# not have, and installing software is not a settings action. # not. See set_auto_dnf for what enabling it actually does -- it
# downloads, it does not install.
"dnfAutomaticEnabled": dnf_timer.stdout.strip() == "enabled", "dnfAutomaticEnabled": dnf_timer.stdout.strip() == "enabled",
"dnfAutomaticAvailable": dnf_timer.stdout.strip() not in ("", "not-found"), "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 "" 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 source == "flatpak":
if not shutil.which("flatpak"): if not shutil.which("flatpak"):
raise BoundaryError("Flatpak is not installed.") 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: if result.returncode != 0:
raise BoundaryError(_refusal(result, "The applications could not be updated.")) raise BoundaryError(_refusal(result, "The applications could not be updated."))
return {"restorePoint": ""} return {"restorePoint": ""}
@@ -257,6 +365,115 @@ def apply(source: str) -> dict:
raise BoundaryError("That is not an update source.") 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: def set_auto_dnf(enabled: bool) -> None:
"""Enable the packaging timer, which DOWNLOADS updates but does not apply them. """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"]: if not state["dnfAutomaticAvailable"]:
raise BoundaryError("Automatic package updates are not installed.") raise BoundaryError("Automatic package updates are not installed.")
action = ["enable", "--now"] if enabled else ["disable", "--now"] 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: if result.returncode != 0:
raise BoundaryError(_refusal(result, "Automatic package updates could not be changed.")) raise BoundaryError(_refusal(result, "Automatic package updates could not be changed."))
@@ -383,13 +600,24 @@ def main(arguments: list[str]) -> int:
check() check()
print(json.dumps(snapshot(), separators=(",", ":"))) print(json.dumps(snapshot(), separators=(",", ":")))
return 0 return 0
if len(arguments) == 2 and arguments[0] == "apply": # Changelogs answer on their own, not folded into a snapshot: this is
outcome = apply(arguments[1]) # 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 # Re-check, so the page reflects what is actually left rather than
# assuming the update cleared everything it listed. # assuming the update cleared everything it listed.
check() check()
state = snapshot() state = snapshot()
state["applied"] = {"source": arguments[1], **outcome} state["applied"] = {"source": arguments[1], "target": target, **outcome}
print(json.dumps(state, separators=(",", ":"))) print(json.dumps(state, separators=(",", ":")))
return 0 return 0
if len(arguments) == 2 and arguments[0] == "set-auto-flatpak": 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") set_auto_dnf(arguments[1] == "true")
else: else:
raise BoundaryError( 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") "set-auto-flatpak true|false | set-auto-dnf true|false")
except BoundaryError as error: 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 = snapshot()
state["error"] = str(error) state["error"] = str(error)
print(json.dumps(state, separators=(",", ":"))) print(json.dumps(state, separators=(",", ":")))
@@ -40,9 +40,11 @@ Singleton {
readonly property bool busy: query.running || mutation.running readonly property bool busy: query.running || mutation.running
// Published on every interface AND currently running: reachable now, as // 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 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 unusedImages: root.disk.unusedImages ?? []
readonly property var unusedVolumes: root.disk.unusedVolumes ?? [] readonly property var unusedVolumes: root.disk.unusedVolumes ?? []
+102
View File
@@ -24,15 +24,44 @@ Singleton {
property string timezone: "" property string timezone: ""
property bool ntpEnabled: false property bool ntpEnabled: false
property bool ntpSynchronized: 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 localTime: ""
property string universalTime: "" property string universalTime: ""
property string rtcTime: "" property string rtcTime: ""
property string lastError: "" 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: [] property var zones: []
readonly property bool busy: statusQuery.running || zonesQuery.running || writeRun.running 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 // "America/New_York" -> "New York" for display, keeping the region as a
// separate field so the list can be grouped and searched sensibly. // separate field so the list can be grouped and searched sensibly.
function regionOf(zone: string): string { function regionOf(zone: string): string {
@@ -102,10 +131,83 @@ Singleton {
root.ntpEnabled = value === "yes"; root.ntpEnabled = value === "yes";
else if (key === "NTPSynchronized") else if (key === "NTPSynchronized")
root.ntpSynchronized = value === "yes"; 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 = ""; 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 { function refresh(): void {
if (!statusQuery.running) if (!statusQuery.running)
statusQuery.running = true; statusQuery.running = true;
+215 -2
View File
@@ -27,13 +27,19 @@ Singleton {
property bool postRepairScanPending: false property bool postRepairScanPending: false
readonly property bool actionable: root.status === "warning" || root.status === "error" 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") readonly property string helperPath: Quickshell.env("PANAMA_HEALTH_HELPER")
|| Quickshell.shellDir + "/scripts/panama-doctor" || Quickshell.shellDir + "/scripts/panama-doctor"
readonly property var statuses: ["ok", "warning", "error", "unconfigured"] readonly property var statuses: ["ok", "warning", "error", "unconfigured"]
readonly property var groups: ["desktop-foundation", "input-media", "integrations", "panama-tools"] readonly property var groups: ["desktop-foundation", "input-media", "integrations", "panama-tools"]
readonly property var overallStatuses: ["healthy", "warning", "error"] 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"] readonly property var instructionTargets: ["ddc-permissions"]
Process { 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 { Process {
id: failureNotification id: failureNotification
} }
@@ -229,6 +283,11 @@ Singleton {
}; };
if (candidate.action !== undefined) if (candidate.action !== undefined)
check.action = root.safeAction(candidate.action); 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; return check;
} }
@@ -343,6 +402,154 @@ Singleton {
&& candidate.message.length > 0; && 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 { function copyReport(): bool {
if (copyProcess.running) if (copyProcess.running)
return false; return false;
@@ -366,7 +573,9 @@ Singleton {
generation: root.generation, generation: root.generation,
acceptedGeneration: root.acceptedGeneration, acceptedGeneration: root.acceptedGeneration,
repairingId: root.repairingId, repairingId: root.repairingId,
refreshingId: root.refreshingId,
lastRepair: root.lastRepair, lastRepair: root.lastRepair,
lastSaveResult: root.lastSaveResult,
lastError: root.lastError, lastError: root.lastError,
checks: root.checks.map(check => check.id), checks: root.checks.map(check => check.id),
checkStates: root.checks.map(check => ({ id: check.id, status: check.status })) 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.title !== "string" || candidate.title.length === 0
|| typeof candidate.detail !== "string" || candidate.detail.length === 0) || typeof candidate.detail !== "string" || candidate.detail.length === 0)
return false; return false;
if (candidate.repairCommand !== undefined
&& (typeof candidate.repairCommand !== "string"
|| candidate.repairCommand.length === 0))
return false;
return candidate.action === undefined || root.validAction(candidate.action); return candidate.action === undefined || root.validAction(candidate.action);
} }
@@ -15,6 +15,7 @@ import Quickshell
import Quickshell.Io import Quickshell.Io
import QtQuick import QtQuick
import qs.config import qs.config
import "DisplayLayout.js" as DisplayLayout
Singleton { Singleton {
id: root id: root
@@ -23,7 +24,6 @@ Singleton {
property var snapshots: [] property var snapshots: []
property string lastError: "" property string lastError: ""
property string lastAction: ""
// Narrow service boundaries keep restore sequencing explicit and make it // Narrow service boundaries keep restore sequencing explicit and make it
// possible to verify the real handler in an isolated shell without ever // possible to verify the real handler in an isolated shell without ever
@@ -84,6 +84,9 @@ Singleton {
Process { Process {
id: actionRun id: actionRun
property bool restoring: false 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: "" property string outputText: ""
stdout: StdioCollector { stdout: StdioCollector {
onStreamFinished: actionRun.outputText = this.text onStreamFinished: actionRun.outputText = this.text
@@ -93,6 +96,8 @@ Singleton {
if (exitCode !== 0) { if (exitCode !== 0) {
root.lastError = actionRun.restoring root.lastError = actionRun.restoring
? "That snapshot could not be restored." ? "That snapshot could not be restored."
: actionRun.doneAction === "deleted"
? "That snapshot could not be deleted."
: "The settings could not be backed up."; : "The settings could not be backed up.";
if (actionRun.restoring) { if (actionRun.restoring) {
root.setDisplayBlocked(false); root.setDisplayBlocked(false);
@@ -101,7 +106,6 @@ Singleton {
} }
return; return;
} }
root.lastAction = actionRun.restoring ? "restored" : "saved";
if (actionRun.restoring) { if (actionRun.restoring) {
const restoreAccepted = root.handleRestoreOutput(actionRun.outputText); const restoreAccepted = root.handleRestoreOutput(actionRun.outputText);
if (restoreAccepted) if (restoreAccepted)
@@ -209,9 +213,39 @@ Singleton {
if (actionRun.running) if (actionRun.running)
return; return;
actionRun.restoring = false; actionRun.restoring = false;
actionRun.doneAction = "saved";
actionRun.exec([root.helperPath, "save", root.serializeHomeState()]); 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 { function serializeHomeState(): string {
const current = root.readHomeState(); const current = root.readHomeState();
@@ -250,6 +284,34 @@ Singleton {
applyRestoredState.restart(); 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 { function layoutFromStoredDisplays(stored: var): var {
if (!stored || typeof stored !== "object") if (!stored || typeof stored !== "object")
return null; return null;
@@ -267,7 +329,7 @@ Singleton {
|| !Number.isInteger(entry.x) || !Number.isInteger(entry.y) || !Number.isInteger(entry.x) || !Number.isInteger(entry.y)
|| typeof entry.primary !== "boolean") || typeof entry.primary !== "boolean")
return null; return null;
layout.push(Object.assign({}, live, { const record = Object.assign({}, live, {
width: Number(match[1]), width: Number(match[1]),
height: Number(match[2]), height: Number(match[2]),
refreshRate: Number(match[3]), refreshRate: Number(match[3]),
@@ -277,7 +339,15 @@ Singleton {
x: entry.x, x: entry.x,
y: entry.y, y: entry.y,
primary: entry.primary 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; return layout.filter(record => record.primary).length === 1 ? layout : null;
} }
@@ -285,11 +355,23 @@ Singleton {
function layoutsEqual(left: var, right: var): bool { function layoutsEqual(left: var, right: var): bool {
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length)
return false; 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 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)); const b = Array.from(right).sort((x, y) => x.name.localeCompare(y.name));
return a.every((record, index) => fields.every( 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 { function failDisplayRestore(message: string): bool {
@@ -80,13 +80,28 @@ Singleton {
{ page: "storage", label: "Storage" }, { page: "storage", label: "Storage" },
{ page: "snapshots", label: "Snapshots" }, { page: "snapshots", label: "Snapshots" },
{ page: "containers", label: "Containers" }, { page: "containers", label: "Containers" },
{ page: "datetime", label: "Date & Time" }, { page: "datetime", label: "Date, Time & Region" },
{ page: "region", label: "Region & Language" },
{ page: "manual", label: "Manual" },
{ page: "sync", label: "Sync & Backup" } { 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 // 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 // proven it absent: a Containers tab with no podman and a Snapshots tab
// with no snapper configuration render permanently empty, which reads as // with no snapper configuration render permanently empty, which reads as
@@ -103,19 +118,37 @@ Singleton {
return true; 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 // The category owning a leaf. Tab membership is checked before category
// ids so the three doubled ids land on their category either way. // ids so the three doubled ids land on their category either way.
function categoryOf(leaf: string): var { function categoryOf(leaf: string): var {
const byTab = root.categories.find(cat => cat.tabs.some(tab => tab.page === leaf)); const byTab = root.categories.find(cat => cat.tabs.some(tab => tab.page === leaf));
if (byTab) if (byTab)
return 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]; return root.categories.find(cat => cat.page === leaf) ?? root.categories[0];
} }
// Retired page ids keep resolving forever: old Vicinae commands, shell // Retired page ids keep resolving forever: old Vicinae commands, shell
// history, and muscle memory all hold them. Each maps to the leaf that // history, and muscle memory all hold them. Each maps to the leaf that
// absorbed its content. // 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 // 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 // the leaf that should render: a leaf resolves to itself, a category to
@@ -128,7 +161,7 @@ Singleton {
return root.retired[id]; return root.retired[id];
const asLeaf = root.categories.some(cat => (cat.page === id && cat.tabs.length === 0) const asLeaf = root.categories.some(cat => (cat.page === id && cat.tabs.length === 0)
|| cat.tabs.some(tab => tab.page === id)); || cat.tabs.some(tab => tab.page === id));
if (asLeaf) if (asLeaf || root.hiddenLeaf(id) !== undefined)
return id; return id;
const category = root.categories.find(cat => cat.page === id); const category = root.categories.find(cat => cat.page === id);
if (category) { if (category) {
@@ -141,6 +174,10 @@ Singleton {
// The available tabs of the category owning a leaf, for the strip above // 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. // 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 { function tabsFor(leaf: string): var {
return root.categoryOf(leaf).tabs.filter(tab => root.pageAvailable(tab.page)); 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. // what search results show, so a hit says where it will land.
function breadcrumb(leaf: string): string { function breadcrumb(leaf: string): string {
const category = root.categoryOf(leaf); 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; 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: "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: "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: "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" }, // Region & Language stopped being a tab of its own and became the
{ label: "Regional formats", detail: "How dates, times, and numbers are written", page: "region" }, // 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" }, { 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 // 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 // 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: "iMessage", detail: "Opens BlueBubbles", page: "phone" },
{ label: "System information", detail: "Kernel, distribution, and hardware", page: "about" }, { label: "System information", detail: "Kernel, distribution, and hardware", page: "about" },
{ label: "Desktop version", detail: "Which Hyprland and Quickshell this session runs", 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: "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" }, { 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: "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: "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" }, { label: "Bar text", detail: "Keep the bar legible on any wallpaper", page: "bar" },
@@ -27,8 +27,13 @@ Singleton {
property string lastAction: "" property string lastAction: ""
property int carried: 0 property int carried: 0
property int applied: 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 var changes: []
property int changeCount: 0
property var skipped: [] property var skipped: []
property string exportedFrom: "" property string exportedFrom: ""
property bool previewed: false property bool previewed: false
@@ -54,8 +59,8 @@ Singleton {
root.lastError = String(parsed.error ?? ""); root.lastError = String(parsed.error ?? "");
root.carried = Number(parsed.carried ?? 0); root.carried = Number(parsed.carried ?? 0);
root.applied = Number(parsed.applied ?? 0); root.applied = Number(parsed.applied ?? 0);
root.left = Array.isArray(parsed.left) ? parsed.left : [];
root.changes = Array.isArray(parsed.changes) ? parsed.changes : []; 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.skipped = Array.isArray(parsed.skipped) ? parsed.skipped : [];
root.exportedFrom = String(parsed.exportedFrom ?? ""); root.exportedFrom = String(parsed.exportedFrom ?? "");
root.previewed = root.lastAction === "preview" && root.lastError === ""; 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. // so the UI can stop claiming the new locale is already in use.
property bool pendingRestart: false 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: { readonly property string currentLabel: {
const match = root.locales.find(locale => locale.value === root.current); const match = root.locales.find(locale => locale.value === root.current);
return match ? match.label : 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 { function refresh(): void {
if (root.scanning) if (root.scanning)
return; return;
root.scanning = true; root.scanning = true;
readCurrent.running = true; readCurrent.running = true;
readCategories.running = true;
list.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 { Process {
id: apply id: apply
@@ -30,7 +30,14 @@ Singleton {
property bool bluebubblesDetected: false property bool bluebubblesDetected: false
property string hyprlandVersion: "" 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 string quickshellVersion: "0.3.0"
property bool quickshellVersionRead: false
property string lastError: "" property string lastError: ""
// Explicit seams keep reset sequencing testable without changing the live // Explicit seams keep reset sequencing testable without changing the live
@@ -53,6 +60,7 @@ Singleton {
property var lockBusy: function() { return LockScreen.busy; } property var lockBusy: function() { return LockScreen.busy; }
readonly property bool busy: monitorQuery.running || serviceQuery.running || versionQuery.running readonly property bool busy: monitorQuery.running || serviceQuery.running || versionQuery.running
|| quickshellVersionQuery.running
|| configWrite.running || configVerify.running || bluebubblesQuery.running || configWrite.running || configVerify.running || bluebubblesQuery.running
readonly property bool bluebubblesAvailable: root.bluebubblesDetected 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 { Process {
id: bluebubblesQuery id: bluebubblesQuery
command: ["flatpak", "info", "app.bluebubbles.BlueBubbles"] command: ["flatpak", "info", "app.bluebubbles.BlueBubbles"]
@@ -176,6 +203,8 @@ Singleton {
serviceQuery.running = true; serviceQuery.running = true;
if (!versionQuery.running && !root.hyprlandVersion) if (!versionQuery.running && !root.hyprlandVersion)
versionQuery.running = true; versionQuery.running = true;
if (!quickshellVersionQuery.running && !root.quickshellVersionRead)
quickshellVersionQuery.running = true;
if (!bluebubblesQuery.running) if (!bluebubblesQuery.running)
bluebubblesQuery.running = true; bluebubblesQuery.running = true;
} }
+158
View File
@@ -48,6 +48,40 @@ Singleton {
readonly property bool rebootNeeded: root.kernel?.rebootNeeded === true readonly property bool rebootNeeded: root.kernel?.rebootNeeded === true
readonly property bool everChecked: root.checkedAt > 0 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 // What has actually been installed, newest first, from both sources at
// once. Loaded on demand rather than with the snapshot: it asks dnf and // 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 // flatpak for their whole transaction log, which is not worth doing every
@@ -155,6 +189,100 @@ Singleton {
applyProcess.running = true; 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 { function setAutomaticDnf(enabled: bool): void {
if (applyProcess.running) if (applyProcess.running)
return; return;
@@ -220,4 +348,34 @@ Singleton {
id: historyProcess id: historyProcess
stdout: StdioCollector { onStreamFinished: root.absorbHistory(this.text) } 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); 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 { function status(): string {
return JSON.stringify({ return JSON.stringify({
calls: root.calls, calls: root.calls,
@@ -5,6 +5,6 @@
# @vicinae.mode silent # @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg # @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Open About in Settings. # @vicinae.description Open About in Settings.
# @vicinae.keywords ["settings", "system information", "desktop version"] # @vicinae.keywords ["settings", "system information", "desktop version", "hostname", "kernel version", "device model", "installed memory", "uptime", "serial number", "bios version"]
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page about exec "$HOME/.config/quickshell/scripts/panama-action" settings-page about
@@ -1,10 +1,10 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Generated by scripts/panama-settings-commands -- do not edit by hand. # Generated by scripts/panama-settings-commands -- do not edit by hand.
# @vicinae.schemaVersion 1 # @vicinae.schemaVersion 1
# @vicinae.title Settings: Date & Time # @vicinae.title Settings: Date, Time & Region
# @vicinae.mode silent # @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg # @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Open Date & Time in Settings. # @vicinae.description Open Date, Time & Region in Settings.
# @vicinae.keywords ["settings", "24-hour time", "timezone", "network time"] # @vicinae.keywords ["settings", "24-hour time", "timezone", "network time", "language", "regional formats", "date format", "number format", "currency", "measurement units", "paper size", "first day of the week", "set the clock by hand"]
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page datetime exec "$HOME/.config/quickshell/scripts/panama-action" settings-page datetime
@@ -1,10 +0,0 @@
#!/usr/bin/env bash
# Generated by scripts/panama-settings-commands -- do not edit by hand.
# @vicinae.schemaVersion 1
# @vicinae.title Settings: Region & Language
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Open Region & Language in Settings.
# @vicinae.keywords ["settings", "language", "regional formats"]
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page region
@@ -5,6 +5,6 @@
# @vicinae.mode silent # @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg # @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Open Sync & Backup in Settings. # @vicinae.description Open Sync & Backup in Settings.
# @vicinae.keywords ["settings", "restore defaults", "carry settings to another machine", "settings backups"] # @vicinae.keywords ["settings", "restore defaults", "carry settings to another machine", "export settings", "import settings", "settings backups"]
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page sync exec "$HOME/.config/quickshell/scripts/panama-action" settings-page sync
+1 -1
View File
@@ -87,7 +87,7 @@ Found on **Shell Control Center**.
## datetime ## datetime
Found on **System Date & Time**. Found on **System Date, Time & Region**.
| Setting | Default | What it does | | Setting | Default | What it does |
|---|---|---| |---|---|---|
@@ -1672,3 +1672,235 @@ settings generator writes 38 of them).
`search-routing-contract`, `ipc-targets-contract`, `settings-jump-contract`, `search-routing-contract`, `ipc-targets-contract`, `settings-jump-contract`,
`settings-docs-contract`, `readme-contract`, all eight of `tests/hypr/`), `settings-docs-contract`, `readme-contract`, all eight of `tests/hypr/`),
then the sandboxed-harness ones, then the live settings window last. then the sandboxed-harness ones, then the live settings window last.
## Phase 15 (System) — the last one
Spec: `2026-08-24-system-redesign.md`. System went from ten tabs to eight:
Region & Language merged into **Date, Time & Region** — a date format and the
clock that shows it are one subject, and splitting them meant changing how a
date is written on a page that never showed one — and the Manual became a
routable leaf with no tab, opened from About's Manual card, a deep link, or the
search box rather than found by scanning a strip. With that, the settings
redesign is complete: fourteen categories, thirty-eight leaves, every one of
them rebuilt.
The bug list this phase carried was longer than the layout change. The
hardcoded Quickshell version, the auto-download switch nested inside the
Firmware card, `CHECK_TITLES` missing `panama.updates`, two Health actions with
no target, RegionPage's stale "Open appearance" handoff, `panama-updates`'
stale dnf-automatic comment, the backup colour gap, Restore without
confirmation, and four pieces of dead code.
Three agents edited the tree concurrently; everything below was reconciled
against the landed files rather than against the spec's pinned shapes, and
every contract named here was **run**.
### No new contracts (175 → 175)
Every pin fitted a file that already existed, so the suite did not grow. That
was the judgement call this phase offered and it went the honest way: a
`system-pages-contract` would have been a new file holding assertions that
belong beside the ones they are variations of — the nav counts beside the nav
taxonomy, the repair command beside the repair, the single-check verb beside
the scan. `setup/readme-contract` — RUN, PASS ("175 contracts, as documented").
### What each contract gained, and why
- **`quickshell/settings-nav-contract` — RUN, PASS** (14 categories, 38 leaves
of which 1 hidden, 3 retired ids). It learned the third leaf shape:
`SettingsRoutes.hiddenLeaves`, which is how the manual stays addressable
without a tab. A hidden leaf that is also a tab, or also a category, now
fails — that overlap is the one the existing checks could not see, because
the hidden half draws no row anywhere. Then: the System strip is pinned at
exactly eight tabs in order (the number IS the point — it is the horizontal
space one row has, and an eleventh subject needs a decision, not another
entry), `region` is pinned to resolve specifically to `datetime` rather than
merely to *a* leaf, `RegionPage.qml` must be gone, and the manual must be a
leaf and must not be a tab.
It also grew the check that would have caught the retired search entries:
every page id in `SettingsSearch` — extra entries and group routes alike —
must be a live leaf, with a distinct failure message when it is a retired id,
since that is the case that still opens a window and lands somewhere else.
Plus a table of the fifteen subjects the consolidation moved, each asserted
to be findable by its own name on the tab that now owns it.
- **`quickshell/settings-pages-contract` — RUN, PASS** (production PIDs
preserved). `Updates`, `DateTime`, `Containers` and `Manual` joined the
page-scaffold sweep; Containers is the one that was waiting for it, since its
root was a bare `Item` and every convention the scaffold carries was
hand-rolled there. The live routing list gained `updates`, `datetime` and
`containers`, and the retired `region` id is now driven through the real IPC
and asserted to land on `datetime`.
- **`quickshell/updates-contract` — RUN, PASS** (2 dnf, 3 flatpak, 0 firmware).
Three additions. **Download sizes add up or are not offered**: a total is
present only when every pending item was priced, because a partial figure
shown as the download understates it, and understating it is the direction
that costs somebody money on a metered connection. **A changelog is a read**,
proved by construction rather than by inspection: `dnf5`, `flatpak`,
`fwupdmgr`, `pkexec` and `systemctl` are replaced with stubs that record
their argv and answer nothing, and the recorded argv is then checked for
mutating tokens — which also exercises the honest-absence path, the common
case on a machine with third-party repositories. Names outside
`NAME_PATTERN` must be refused before anything is launched. **One
application, by name**: the per-app argv is asserted exactly
(`flatpak update -y --noninteractive <id>`) against a stub, and an ID the
last scan did not list must be refused without running flatpak at all.
- **`quickshell/panama-doctor-contract` — RUN, PASS.** The `CHECK_TITLES`
KeyError is pinned twice: as the invariant (every id in `CHECK_ORDER` has a
title, asserted by importing the module) and as the path (a probe forced to
raise must come back as a titled warning row rather than taking the report
with it). Worth both — the bug needed the updates probe to fail, which it
almost never does, and the containment that was supposed to catch it *was*
the KeyError, raised inside the `except` handling the original failure.
Then: the updates action's `target: "updates"`, driven by a fixture cache
with a security count; `repairCommand` asserted against `REPAIR_COMMANDS`
itself rather than restated, so an argv change cannot leave the row
describing the old one; and the `check CHECK_ID` verb — full snapshot
envelope, exactly one check, summary arithmetic covering only that check,
refusal of unauthored ids with no report and no filesystem mutation, and no
second output mode.
The fixture also gained `XDG_CACHE_HOME`, which was leaking: `check_updates`
reads the Updates cache from the environment rather than through
`DoctorConfig`, so the contract had been reading the real machine's pending
updates.
- **`quickshell/health-service-contract` — RUN, PASS.** The both-sides pin is
derived from both files: every `open`/`instructions` target the doctor emits
must appear in Health's `settingsTargets`/`instructionTargets`. This is the
one where half a change does not degrade a row, it blanks the page —
`validAction` false makes `validCheck` false makes `consumeSnapshot` reject
the whole report. Live fixtures either side of it: a check targeting
`updates` is accepted intact, and one targeting `storage` — a real page the
list deliberately omits — is rejected, which is what makes the allow-list an
allow-list. Also `repairCommand` surviving the projection (a new known key is
dropped silently unless it is added, and the failure is invisible: the repair
still works, only the sentence saying what it runs is gone), a check without
one still accepted, `refreshCheck` asking for `check <id>` and never `--json`
while leaving the other rows and recomputing the headline counts, an unknown
id starting no process, and `saveReport` writing the same redacted projection
to a named file.
- **`quickshell/health-ui-contract` — RUN, PASS.** The Colour profiles handoff
assertion is inverted rather than deleted (Displays owns colour profile, bit
depth, SDR brightness and saturation per output, which is more than GNOME's
panel can say in a session it does not manage); Digital wellbeing stays. The
repair command must be shown, and must not be gated on `lastRepair`,
`repairingId` or a working state — after the fact is not the same answer,
because by then the decision is made. A per-row re-check and a save-report
row must exist. The fixture grew a `panama.updates` check whose action is
followed for real through `requestHealthAction`, landing on `updates`: the
third piece of the chain the doctor and the service each pin one end of.
- **`quickshell/settings-backup-contract` — RUN, PASS.** Named snapshots
(fifteen rows reading `2026-08-24 11:03:07` are fifteen rows nobody can
choose between), with the name reaching a filename — which is the oldest way
a helper gets talked into writing outside its own directory. Seven hostile
names, each of which must either fail or produce a file inside the backup
directory. Delete gets the same confinement as restore in the other
direction, and a worse failure mode: restore reading the wrong file
overwrites settings, delete resolving the wrong name destroys something that
is not a backup. Traversal, absolute paths, an escaping symlink, an
already-deleted name and an empty name all refused. `bytes` per row, checked
against `stat`.
- **`quickshell/settings-backup-live-contract` — RUN, PASS.** The colour gap,
both halves. `layoutFromStoredDisplays` must carry `vrrMode`,
`colorProfile`, `bitdepth`, `sdrBrightness`, `sdrSaturation` and `mirrorOf`
through, and must still accept a record written before they existed.
`layoutsEqual` must detect a difference in each of them — **this is the
assertion the shipped behaviour fails**, and the reason the restore silently
did nothing: the seven-field comparison called a snapshot with different
colour settings equal, so the restore took the early return and reported
success while leaving HDR off. Plus the inverse for the two float fields: a
value that came back one ulp different is the same value, or every restore
reapplies the layout it already has.
Two harness seams were added for this (`layoutFor`, `layoutsMatch`), because
the colour half is invisible from `handleRestoreOutput` — geometry restores
perfectly and success is reported either way.
- **`quickshell/settings-sync-contract` — RUN, PASS.** The preview's `changes`
must be `{key, from, to}` with both sides already text, a `changeCount`
agreeing with what would be applied, a rendered list no longer than that
count, and no side over 200 characters — with a bundle carrying a 4000-
character `weatherLocation` proving the cap is real rather than incidental.
Stringifying in the helper rather than in QML is what makes the cap
enforceable at all. Observed on this machine: 115 changes, 40 rendered,
absent values rendered as "not set".
- **`quickshell/manual-contract` — RUN, PASS** (5 chapters). Titles now come
from the files: the chapter list moved to `ManualChapters.qml`, which both
the reader and About's card instantiate, and the contract fails on any
`label:` beside a filename — the drift it is guarding against had already
happened, under a comment claiming it could not. It also fails if either page
names chapter files itself. In-app links: the handler must route
`panama://settings/<page>` through `ShellState.openSettings` while still
sending everything else out of the desktop, asserted by following the call
(inline block or named function, brace-matched) rather than by reading one
line, and every such link written in a chapter must name a real leaf. Plus
the taxonomy check inverted: the manual must be a hidden leaf and must NOT be
a System tab.
- **`quickshell/gnome-handoff-contract` — RUN, PASS** (6 handoffs checked
against 37 pages). Two panels joined `OWNED`: `system region`, because Date,
Time & Region now offers a language picker, four per-category format
dropdowns backed by the installed locales, and a live preview of what each
renders; and `color`, because Displays has offered a colour profile per
output for some time. Both are derived rather than hand-asserted, so the door
cannot come back under a name nobody thought to list. Plus two inverses:
`RegionPage.qml` must stay gone, and `DateTimePage` must not open a GNOME
panel for something it now does itself.
- **`quickshell/search-routing-contract` — RUN, PASS** (146 routed settings).
Unchanged; run because `SettingsSearch` moved.
### Search entries
Two moved: Language and Regional formats, from `region` to `datetime`. Fifteen
added: the seven About facts nobody could search for (Hostname, Kernel version,
Device model, Installed memory, Uptime, Serial number, BIOS version — none of
them the label of a preference, all of them things people arrive looking for by
name, and "Installed memory" rather than "Memory" because that one IS already a
schema label on Bar); the two Sync verbs (Export settings, Import settings —
"Carry settings to another machine" was the only entry, and nobody searches for
a sentence); and six format subjects on the merged tab (Date format, Number
format, Currency, Measurement units, Paper size, First day of the week) plus
"Set the clock by hand".
### Schema docs and launcher commands regenerated
`quickshell/scripts/panama-settings-docs` and `panama-settings-commands` both
run without `--check` and their output committed. `docs/settings.md` stays at
**174 settings across 36 groups** — no preference was added this phase — but
the `datetime` group is now "Found on **System Date, Time & Region**", which
is the whole point of a generated document: the tab was renamed in one place.
The launcher went 38 generated commands to **37**: `settings-region` was
removed, and `settings-manual` survives because the generator learned about
`hiddenLeaves` — a leaf with no tab is the one leaf that cannot be found by
scanning, so it is the one that most needs a launcher entry. Keywords picked up
the new search entries on their own: `settings-about` gained hostname, kernel
version, device model, installed memory, uptime, serial number and BIOS
version; `settings-datetime` gained language, regional formats and the six
format subjects; `settings-sync` gained export and import.
`quickshell/settings-docs-contract` — RUN, PASS (174 settings documented).
`quickshell/panama-commands-contract` — RUN, PASS (76 commands), after teaching
its own leaf derivation about `hiddenLeaves` too: it counted 75 and found 76,
which is the generator and the contract disagreeing about what a leaf is
rather than a stale command. `quickshell/qmldir-registration-contract` — RUN,
PASS (189 components), covering the two new ones, `ManualChapters` and
`FieldActionRow`.
### Deferred, and why
- **`quickshell/settings-search-contract` — NOT RUN.** It daemonizes a
Quickshell instance against the user's real XDG directories rather than a
scratch set, and constructing `SettingsSearch` calls
`DesktopStyle.ensureStarted()`, which replays application preferences to the
live GTK configuration. Every case it pins was checked statically instead:
none of the fifteen new labels equals or prefixes any of the twenty-one
pinned queries, so none can outrank one, and every schema label is still
reachable because the schema half of the index did not change. The new static
page-id sweep in `settings-nav-contract` covers the failure this phase could
actually have introduced.
- **Nobody has typed a locale into the four format dropdowns.** The category
round-trip is A's, through `panama-locale`; no `localectl` write was made
from any contract here, and none should be.
- **The manual's in-app links have not been clicked.** The handler is pinned
structurally and every link a chapter writes is checked against the taxonomy,
but no chapter currently writes one, so the routing has been proven correct
and never exercised.
- **No backup has been restored against the real store**, and no update has
been applied. Both are pinned through stubs and scratch directories, which is
the sanctioned path and the only one that should ever run here.
@@ -0,0 +1,143 @@
# System redesign — the finale, 10 tabs become 8
Approved mock: `home-mocks/system.html` (scratchpad, :8642). Spec wins over mock on conflict.
Storage, Snapshots, Containers keep their shipped designs (Containers gets the root-type fix
only). TEST GRANT ACTIVE: agents may RUN contracts related to their work as they build; the
full suite stays the orchestrator's call.
## Consolidation (pinned)
System tabs become: About · Software Update · System Health (`services`) · Storage ·
Snapshots · Containers · **Date, Time & Region** (`datetime`) · Sync & Backup. The `region`
tab retires (retired-map → `datetime`). The `manual` tab retires as a TAB but `manual` stays
a routable leaf (opened from About's Manual card and existing deep links) — B determines the
mechanism (hidden-leaf list or equivalent) and C reconciles `settings-nav-contract`'s
category/leaf counts.
## Bug kills (all in scope)
`SystemSettings.quickshellVersion` hardcoded "0.3.0" → read `qs --version`; the auto-download
switch nested in the Firmware card; Health's target-less "Open Software Update" actions (add
`target: "updates"` AND extend `Health.settingsTargets` in the same change — adding one
without the other invalidates whole snapshots); `CHECK_TITLES` missing `panama.updates`
(KeyError on timeout); the `SettingsShell.healthDiagnostics` TypeError (guard the binding on
`typeof item.uiDiagnostics === "function"`); RegionPage's stale "Open appearance" handoff;
`panama-updates`' stale dnf-automatic comment; the backup **color gap**
(`layoutFromStoredDisplays` + `layoutsEqual` extended to `vrrMode`, `colorProfile`,
`bitdepth`, `sdrBrightness`, `sdrSaturation`, `mirrorOf` — optional/back-compat like
`Displays.isPersistedLayoutEntry`); Restore without confirmation; stray `settings.json.*`
temp files (clean once, and if the writer can leak them, fix the leak); dead code
(`Containers.wouldExpose`, `SettingsSync.left`, `SettingsBackup.lastAction`,
`HealthPage.statusLabel`; `DateTime.localTime/universalTime/rtcTime` become USED by the
manual-set flow rather than deleted).
## A — services & scripts (pinned APIs)
- `scripts/panama-about`: rows gain `Firmware` (DMI `bios_version` + `bios_date`, bootctl
only as fallback — it reports "n/a" here) and `Secure Boot` (mokutil, absent-tolerant); a
`Panama` row (`git describe --tags --always --dirty` + `%cr`, absent-tolerant outside a
checkout), placed right after `Operating system`. `SystemSettings.quickshellVersion` now
parsed from `qs --version`, kept as a one-shot Process started from `refresh()` and
guarded by `quickshellVersionRead` — so a plain binding on `quickshellVersion` is enough
and B needs no extra call. The literal stays as the fallback only.
- `scripts/panama-updates`: `changelog <source> <name>``{source, name, kind, text, error}`
where `kind` is `advisory` | `changelog` | `none` (dnf: `dnf5 advisory info --json
--updates --contains-pkgs=` first, else `dnf5 changelog --upgrades` with dnf's two header
lines stripped; flatpak: `remote-info --cached --log` History, which Flathub does not
populate, so in practice the honest "publishes no release notes"; firmware: the fwupd
release notes cached by `check`). Names constrained by `NAME_PATTERN` before argv.
`check` gains per-item `bytes` and per-source `downloadBytes`, present only when EVERY
item in that source was priced (dnf from `dnf5 repoquery --queryformat %{downloadsize}`,
flatpak parsed from its rendered `download-size` column — its `--json` omits the column).
`apply flatpak <app-id>` appends one ID, checked against the last scan. Stale
dnf-automatic comments fixed; `DNF_TIMER` named. `services/Updates.qml`:
`changelogFor(source, name)` → record or `null`, cached by `source/name`, serial queue,
reactive via `changelogRevision` (read it in the binding, DesktopPreferences.get pattern);
`applyFlatpakApp(id)`; `downloadBytes` + `downloadSize` (string) + `sourceDownloadSize(s)`
+ `formatBytes(n)`; `loadingChangelog`.
- `scripts/panama-doctor`: `CHECK_TITLES["panama.updates"]`; updates actions get
`target: "updates"`; snapshot gains `repairCommand` (a joined argv string) on the five ids
in REPAIR_COMMANDS only — the three in-process repairs have no command line and claim
none; new `panama-doctor check <id>` verb printing a FULL snapshot envelope holding that
one check (same schema, same summary arithmetic, so callers reuse `validSnapshot`).
`services/Health.qml`: `settingsTargets` gains "updates"; `refreshCheck(id)` (splices one
validated row in, recomputes the summary, drops a reply the newer full scan superseded)
with `refreshingId` / `refreshingCheck`; `saveReport(path)` via `tee` (default
`defaultReportPath` = `~/panama-health-report.txt`) reporting through `lastSaveResult`,
and `reportText()` for the plain-text body; `safeCheck`/`validCheck` carry `repairCommand`.
- `services/DateTime.qml`: `setTime(iso)` via `timedatectl set-time` (validated against
`timePattern` = `YYYY-MM-DD HH:MM[:SS]` AND round-tripped through Date, refused while NTP
is on AND while `statusRead` is still false — "not looked yet" is not "off"); `localTime`
/ `universalTime` / `rtcTime` wired from `TimeUSec`/`RTCTimeUSec`, advanced locally once a
second while `DateTime.tracking` is true (B sets it while the Clock card is on screen; no
process per tick).
- `scripts/panama-locale` + `services/SystemLocale.qml`: `get [cat]` / `set <cat> <locale|"">`
/ `categories` / `overrides` (one JSON object for all five) — `LC_TIME`, `LC_NUMERIC`,
`LC_MONETARY`, `LC_MEASUREMENT`, `LC_PAPER`. "Match language" is `""` and is implemented by
re-issuing `localectl set-locale` with every OTHER assignment, since localectl replaces
locale.conf with exactly what it is given. Service exposes `categories`,
`categoryValue(cat)` (`""` = match language), `categoryLabel(cat)`, `setCategory(cat,
locale)`, `categoryRevision` (read it in bindings), `busy`, and the pendingRestart
discipline.
- `scripts/panama-settings-backup`: `create [name] [homeState]` (label sanitized by
`LABEL_RE`, stored INSIDE the envelope — the filename stays the timestamp SNAPSHOT_RE
pins, since that is what ordering, pruning and restore confinement rely on), `delete
<name>` (through `snapshot_source`, the same confinement gate restore uses), list gains
`bytes` and `label`. Stale-temp sweep extended to Quickshell's QSaveFile leftovers
(`settings.json.XXXXXX`, no leading dot) with a one-hour age guard so an in-flight write
is never destroyed. `services/SettingsBackup.qml`: `create(name)`, `deleteBackup(name)`,
dead `lastAction` removed, the color-gap fix per above (`storedDisplayFields`,
`validStoredField`, both used by `layoutFromStoredDisplays` and `layoutsEqual`).
- `services/SettingsSync.qml` + helper: preview output gains `changes: [{key, from, to}]`
with both values rendered to text by the helper (`render()`, `VALUE_LIMIT` 120), capped at
`CHANGE_LIMIT` 40 with a `changeCount` for what is not shown; the import still applies
everything. Service gains `changeCount`; dead `left` removed.
- `modules/settings/SettingsShell.qml`: the healthDiagnostics guard (A owns this single
edit — B is told hands-off that binding).
## B — UI
`AboutPage.qml` showpiece per mock (hero, truthful versions block, Hardware card with
GraphicsDevices joined, Device-name → Sharing row, Manual card listing chapters with titles
READ FROM THE FILES (first heading), Read → `openSettings("manual")` + section); Design
principles card dropped (the manual carries the philosophy). `ManualPage.qml`: chapter titles
from file first-headings (make the old comment true); `onLinkActivated` routes
`panama://settings/<page>` (or equivalent scheme) links through `ShellState.openSettings`,
everything else external; keep the tabs/reader shape. `UpdatesPage.qml`: restructure per
mock (headline; System packages with changelog expanders + download size; Automatic card
with BOTH switches; Applications & firmware rows with per-app flatpak updates when present;
history kept). `DateTimePage.qml` becomes the merged tab (Clock card with manual-set
revealed when NTP off; Timezone; Language & formats card — language picker, four category
dropdowns [Match language + installed locales], first-day-of-week fact row, live preview
grid from `Qt.locale()` for the *chosen* format locales); `RegionPage.qml` DELETED (retired
route). `HealthPage.qml`: repair rows show `repairCommand` in the detail before running,
per-check Re-check button (`refreshCheck`), Save-report row, Color-profiles handoff row
REMOVED (Digital wellbeing stays), the updates action now navigating. `SyncPage.qml`:
preview diff list from `changes` (mono-ish tabular rows, del/add tones), Restore + Reset
two-stage confirms (the house danger pattern), backup rows with name field on create +
size + Delete…, reset subtitle mentions themes. `ContainersPage.qml`: root becomes
`SettingsPage` per convention (logs drill-in preserved). `SettingsRoutes.qml`: the 8-tab
strip + region retired + manual leaf mechanism (KEEP the categories array literal-shaped).
## C — periphery
Search: hostname/kernel/model/memory/uptime/serial → about; export settings/import
settings → sync; currency/formats/measurement/paper/first day → datetime; region retired
routing verified; manual entries keep working (leaf). Contracts: `settings-nav-contract`
(counts + retired map), `settings-pages-contract` (routing list: add updates/containers/
datetime, drop region), `updates-contract` (changelog verb read-only pins; per-app flatpak
apply argv), health contracts (target "updates" validity BOTH sides, repairCommand shown
before run, single-check verb full-shape, KeyError fixture), `settings-backup-contract` +
`-live` (extended fields round-trip — a stored record with color fields restores them and
`layoutsEqual` detects color-only drift; delete confinement; name sanitation),
`settings-sync-contract` (changes shape, no secrets in stringified values), `manual-contract`
(titles-from-files, in-app link routing), `gnome-handoff-contract` (region handoffs gone —
consider OWNED), NEW `system-pages-contract` if the pins don't fit existing files (C's
judgment; keep count growth honest). Backlog Phase 15. README count line. Docs/commands
regen at the end. RUN the contracts you write/touch as you go (grant active); report results.
Hard rules still: no live mutations of system state (no dnf/flatpak installs, no
timedatectl/localectl writes, no backup restores against the real store — the
scratch-daemonizing harness pattern and hermetic stubs are the sanctioned test paths, and
the grant means you may RUN those freely). Valid QML at every save. B programs against A's
pinned APIs; A updates this spec before changing them.
+3 -1
View File
@@ -159,7 +159,9 @@ qml_package() {
# Provided by the base system or the shell itself; nothing installs these # Provided by the base system or the shell itself; nothing installs these
# separately, and listing them would be noise. # separately, and listing them would be noise.
QML_BASELINE='^(sh|bash|rm|test|pkill|systemd-inhibit|loginctl|timedatectl|gsettings|gapplication|systemctl|busctl)$' # `qs` is Quickshell itself -- if the shell is running QML, qs is by
# definition present, so it needs no package list entry.
QML_BASELINE='^(sh|bash|rm|test|pkill|systemd-inhibit|loginctl|timedatectl|gsettings|gapplication|systemctl|busctl|qs)$'
while IFS= read -r command_name; do while IFS= read -r command_name; do
[[ -n "$command_name" ]] || continue [[ -n "$command_name" ]] || continue
@@ -38,7 +38,9 @@ SHELL_WORDS='^(if|then|else|elif|fi|for|while|until|do|done|case|esac|in|functio
# fingerprint aliases in config/bash can rely on it without declaring it. # fingerprint aliases in config/bash can rely on it without declaring it.
BASELINE='^(sh|bash|cat|cut|sed|awk|gawk|grep|egrep|head|tail|sort|uniq|tr|wc|find|xargs|basename|dirname|mkdir|rm|cp|mv|ln|chmod|chown|stat|df|du|date|sleep|env|id|tee|touch|mktemp|readlink|realpath|seq|comm|join|paste|od|file|nl|fold|column|tput|timeout|flock|install|sha256sum|md5sum|base64|nproc|uptime|free|uname|hostname|whoami|ps|pgrep|pkill|kill|killall|lsblk|mount|umount|sudo|su|rpm|dnf|flatpak|git|python3|ss|ip|ls|rfkill|lsof|authselect|setsid|nohup|grub2-mkconfig)$' BASELINE='^(sh|bash|cat|cut|sed|awk|gawk|grep|egrep|head|tail|sort|uniq|tr|wc|find|xargs|basename|dirname|mkdir|rm|cp|mv|ln|chmod|chown|stat|df|du|date|sleep|env|id|tee|touch|mktemp|readlink|realpath|seq|comm|join|paste|od|file|nl|fold|column|tput|timeout|flock|install|sha256sum|md5sum|base64|nproc|uptime|free|uname|hostname|whoami|ps|pgrep|pkill|kill|killall|lsblk|mount|umount|sudo|su|rpm|dnf|flatpak|git|python3|ss|ip|ls|rfkill|lsof|authselect|setsid|nohup|grub2-mkconfig)$'
SESSION='^(systemctl|busctl|journalctl|loginctl|hostnamectl|localectl|systemd-inhibit|systemd-run|udevadm|gsettings|dconf|dbus-send|dbus-monitor|hyprctl|qs|quickshell|gnf|panama|wl-copy|wl-paste)$' # bootctl ships in systemd-udev, which every Fedora install carries -- it is
# the udev half of systemd, not an optional tool.
SESSION='^(systemctl|busctl|journalctl|loginctl|hostnamectl|localectl|systemd-inhibit|systemd-run|udevadm|bootctl|gsettings|dconf|dbus-send|dbus-monitor|hyprctl|qs|quickshell|gnf|panama|wl-copy|wl-paste)$'
# Installed by install-packages itself rather than by a package list. Two # Installed by install-packages itself rather than by a package list. Two
# reasons, both deliberate: bun and claude have no RPM or flatpak at all, and # reasons, both deliberate: bun and claude have no RPM or flatpak at all, and
+31
View File
@@ -55,6 +55,21 @@ fail() {
# Fedora", explaining that GNOME's file-history switches would not take effect # Fedora", explaining that GNOME's file-history switches would not take effect
# in a Hyprland session anyway -- is gone, because the switches it was # in a Hyprland session anyway -- is gone, because the switches it was
# apologizing for are now buttons that work. # apologizing for are now buttons that work.
# "color" joined with the same argument, one page over. System Health's Fedora
# card offered Color profiles because Panama's Displays page did not have them;
# it has had them per display for some time -- automatic, sRGB, wide gamut or
# HDR, with bit depth and SDR brightness beside them -- so the row was pointing
# at GNOME for something the desktop's own display page does better, since it
# is the page that knows which output you mean.
#
# "system region" joined when Region & Language stopped being a page that
# offered a language picker and a link, and its contents became the Language &
# formats card on Date, Time & Region: a language picker, four per-category
# format dropdowns backed by the installed locales, and a live preview of what
# each choice actually renders. GNOME's panel does the same job with the same
# locales, so a row pointing at it is now a door out of a page that does the
# thing. The retired page carried two: that one, and an "Open appearance"
# handoff left over from when the fonts lived here.
declare -A OWNED=( declare -A OWNED=(
[network]=connectivity [network]=connectivity
[wifi]=connectivity [wifi]=connectivity
@@ -64,6 +79,8 @@ declare -A OWNED=(
[users]=users [users]=users
[system\ users]=users [system\ users]=users
[privacy]=privacy [privacy]=privacy
[system\ region]=datetime
[color]=displays
) )
# Handoffs that are correct despite naming an owned panel, with the reason. # Handoffs that are correct despite naming an owned panel, with the reason.
@@ -127,6 +144,20 @@ if grep -q 'openGnomePanel' "$settings_dir/PrivacyPage.qml"; then
violations=$((violations + 1)) violations=$((violations + 1))
fi fi
# The same, for the page that no longer exists. RegionPage's two handoffs went
# with the file, but the card they sat in moved to DateTimePage, and a card can
# be moved with its rows intact -- so this is asserted against the page that
# received the content rather than against the one that was deleted.
if [[ -e "$settings_dir/RegionPage.qml" ]]; then
printf 'gnome handoff contract: RegionPage.qml still exists; its route retired to datetime\n' >&2
violations=$((violations + 1))
fi
if grep -q 'openGnomePanel' "$settings_dir/DateTimePage.qml"; then
printf 'gnome handoff contract: DateTimePage opens a GNOME panel for something it now does itself:\n' >&2
grep -n 'openGnomePanel' "$settings_dir/DateTimePage.qml" >&2
violations=$((violations + 1))
fi
if (( violations > 0 )); then if (( violations > 0 )); then
printf 'Each of these sends someone to GNOME for a page this app already has.\n' >&2 printf 'Each of these sends someone to GNOME for a page this app already has.\n' >&2
exit 1 exit 1
+161 -2
View File
@@ -23,6 +23,16 @@ confirm_snapshot="$(jq -c '
action: {kind: "repair", label: "Restart Panama", confirm: true} action: {kind: "repair", label: "Restart Panama", confirm: true}
}] }]
' <<<"$warning_snapshot")" ' <<<"$warning_snapshot")"
updates_snapshot="$(jq -c '
.checks[0].action = {kind: "open", label: "Open Software Update", confirm: false, target: "updates"}
' <<<"$warning_snapshot")"
# "storage" is a real Settings page that `settingsTargets` deliberately does
# not list. The allow-list has to be an allow-list: if any page id were taken
# on trust, extending the doctor's vocabulary would stop being a change that
# has to be made on both sides, and this whole coupling would be decorative.
unlisted_snapshot="$(jq -c '
.checks[0].action = {kind: "open", label: "Open Storage", confirm: false, target: "storage"}
' <<<"$warning_snapshot")"
projection_snapshot="$(jq -c ' projection_snapshot="$(jq -c '
.fixtureSecret = "fixture-secret" .fixtureSecret = "fixture-secret"
| .summary.fixtureSecret = "fixture-secret" | .summary.fixtureSecret = "fixture-secret"
@@ -30,6 +40,11 @@ projection_snapshot="$(jq -c '
| .context.versions[0].fixtureSecret = "fixture-secret" | .context.versions[0].fixtureSecret = "fixture-secret"
| .checks[0].fixtureSecret = "fixture-secret" | .checks[0].fixtureSecret = "fixture-secret"
' <<<"$warning_snapshot")" ' <<<"$warning_snapshot")"
# One probe, in the same envelope as a full report. Re-checking a single row
# after fixing something by hand is the reason it exists: rescanning all thirty
# takes long enough that people stop doing it, and a row that never updates is
# a row that stops being believed.
single_check='{"schemaVersion":1,"generatedAt":"2026-08-24T00:00:00Z","summary":{"status":"healthy","healthy":1,"warnings":0,"errors":0,"unconfigured":0},"context":{"session":"hyprland","versions":[{"id":"quickshell","version":"0.3.0"}]},"checks":[{"id":"panama.caffeine","group":"panama-tools","title":"Caffeine","status":"ok","detail":"No duplicate sleep inhibitors."}]}'
adversarial_snapshot="$(jq -c ' adversarial_snapshot="$(jq -c '
.fixtureSecret = "fixture-secret" .fixtureSecret = "fixture-secret"
| .summary.fixtureSecret = "fixture-secret" | .summary.fixtureSecret = "fixture-secret"
@@ -85,6 +100,57 @@ if keys != ["summary", "busy", "generation", "acceptedGeneration", "checks"]:
raise SystemExit(1) raise SystemExit(1)
PY PY
# ── Both sides of a Settings target ─────────────────────────────────────────
#
# An `open` action carries the id of a Settings page, and the two halves of
# that agreement live in different languages in different directories: the
# doctor writes `target="updates"`, and Health decides whether it will accept
# one by looking the value up in `settingsTargets`.
#
# Getting one without the other is not a dead button. `validAction` returning
# false makes `validCheck` return false, which makes `consumeSnapshot` reject
# the WHOLE report -- so a single unlisted target takes every other check down
# with it and System Health goes blank, for a reason nothing on screen names.
# That is what "Open Software Update" would have done had it been given the
# target it was missing without `settingsTargets` being extended to match.
#
# Derived from both files rather than restated, so the next target added on
# either side has to be added on the other.
python3 - "$repo_dir/config/dot/quickshell/scripts/panama-doctor" "$service" <<'PY' \
|| fail 'the doctor emits a Settings or instructions target that Health would reject, which rejects the entire snapshot'
import re
import sys
doctor = open(sys.argv[1], encoding="utf-8").read()
health = open(sys.argv[2], encoding="utf-8").read()
def accepted(name: str) -> set[str]:
block = re.search(rf'property var {name}:\s*\[(.*?)\]', health, re.S)
if not block:
raise SystemExit(f"Health.qml no longer declares {name}")
return set(re.findall(r'"([a-z-]+)"', block.group(1)))
settings_targets = accepted("settingsTargets")
instruction_targets = accepted("instructionTargets")
emitted = re.findall(r'Action\(\s*"(open|instructions)"\s*,\s*"[^"]*"\s*,\s*target="([a-z-]+)"', doctor)
if not emitted:
raise SystemExit("no targeted actions were read from the doctor, so this proves nothing")
for kind, target in emitted:
allowed = settings_targets if kind == "open" else instruction_targets
if target not in allowed:
raise SystemExit(
f'the doctor emits a {kind} action targeting "{target}", which Health does not accept'
)
if "updates" not in settings_targets:
raise SystemExit('Health does not accept the "updates" target, so the Software Update '
'actions have nowhere to go')
PY
fixture_dir="$(mktemp -d /tmp/panama-health.XXXXXX)" fixture_dir="$(mktemp -d /tmp/panama-health.XXXXXX)"
config_path="$fixture_dir/quickshell" config_path="$fixture_dir/quickshell"
cp -a "$repo_dir/config/dot/quickshell" "$config_path" cp -a "$repo_dir/config/dot/quickshell" "$config_path"
@@ -130,6 +196,10 @@ printf '%s\n' \
" printf '%s\\n' '$warning_snapshot'" \ " printf '%s\\n' '$warning_snapshot'" \
' exit 0' \ ' exit 0' \
'fi' \ 'fi' \
'if [[ "$1" == "check" ]]; then' \
' printf "%s\n" "$PANAMA_HEALTH_SINGLE_CHECK"' \
' exit 0' \
'fi' \
'if [[ "$1" == "--repair" ]]; then' \ 'if [[ "$1" == "--repair" ]]; then' \
' repair_start_time="$(awk '\''{ print $22 }'\'' "/proc/$$/stat")"' \ ' repair_start_time="$(awk '\''{ print $22 }'\'' "/proc/$$/stat")"' \
' printf "%s|%s\n" "$$" "$repair_start_time" >"$PANAMA_HEALTH_REPAIR_STARTED"' \ ' printf "%s|%s\n" "$$" "$repair_start_time" >"$PANAMA_HEALTH_REPAIR_STARTED"' \
@@ -161,7 +231,7 @@ chmod +x "$copy_bin/wl-copy" "$copy_bin/notify-send"
run() { run() {
PATH="$copy_bin:$PATH" PANAMA_HEALTH_HELPER="$helper" PANAMA_HEALTH_COPY_FILE="$copy_file" \ PATH="$copy_bin:$PATH" PANAMA_HEALTH_HELPER="$helper" PANAMA_HEALTH_COPY_FILE="$copy_file" \
PANAMA_HEALTH_REPAIR_MODE_FILE="$repair_mode_file" PANAMA_HEALTH_REPAIR_LOG="$repair_log" \ PANAMA_HEALTH_REPAIR_MODE_FILE="$repair_mode_file" PANAMA_HEALTH_REPAIR_LOG="$repair_log" \
PANAMA_HEALTH_NOTIFICATION_LOG="$notification_log" \ PANAMA_HEALTH_NOTIFICATION_LOG="$notification_log" PANAMA_HEALTH_SINGLE_CHECK="$single_check" \
PANAMA_HEALTH_REPAIR_STARTED="$repair_started_file" PANAMA_HEALTH_REPAIR_RELEASE="$repair_release_file" \ PANAMA_HEALTH_REPAIR_STARTED="$repair_started_file" PANAMA_HEALTH_REPAIR_RELEASE="$repair_release_file" \
qs -p "$harness" "$@" qs -p "$harness" "$@"
} }
@@ -218,7 +288,7 @@ trap cleanup EXIT
PATH="$copy_bin:$PATH" PANAMA_HEALTH_HELPER="$helper" PANAMA_HEALTH_COPY_FILE="$copy_file" \ PATH="$copy_bin:$PATH" PANAMA_HEALTH_HELPER="$helper" PANAMA_HEALTH_COPY_FILE="$copy_file" \
PANAMA_HEALTH_REPAIR_MODE_FILE="$repair_mode_file" PANAMA_HEALTH_REPAIR_LOG="$repair_log" \ PANAMA_HEALTH_REPAIR_MODE_FILE="$repair_mode_file" PANAMA_HEALTH_REPAIR_LOG="$repair_log" \
PANAMA_HEALTH_NOTIFICATION_LOG="$notification_log" \ PANAMA_HEALTH_NOTIFICATION_LOG="$notification_log" PANAMA_HEALTH_SINGLE_CHECK="$single_check" \
PANAMA_HEALTH_REPAIR_STARTED="$repair_started_file" PANAMA_HEALTH_REPAIR_RELEASE="$repair_release_file" \ PANAMA_HEALTH_REPAIR_STARTED="$repair_started_file" PANAMA_HEALTH_REPAIR_RELEASE="$repair_release_file" \
qs -p "$harness" --daemonize >/dev/null qs -p "$harness" --daemonize >/dev/null
for _ in $(seq 1 40); do for _ in $(seq 1 40); do
@@ -237,6 +307,38 @@ state="$(run ipc call health-test status)"
jq -e '.status == "warning" and .acceptedGeneration == 0 and .checks == ["integration.calendar", "panama.caffeine"] and .diagnosticUnavailable == false' \ jq -e '.status == "warning" and .acceptedGeneration == 0 and .checks == ["integration.calendar", "panama.caffeine"] and .diagnosticUnavailable == false' \
>/dev/null <<<"$state" || fail "valid warning snapshot was not accepted intact: $state" >/dev/null <<<"$state" || fail "valid warning snapshot was not accepted intact: $state"
[[ "$(run ipc call health-test accept "$updates_snapshot" 0)" == "true" ]] \
|| fail 'a check pointing at Software Update was rejected, so the whole report would go blank rather than one button being dead'
state="$(run ipc call health-test status)"
jq -e '.status == "warning" and .diagnosticUnavailable == false
and .checks == ["integration.calendar", "panama.caffeine"]' \
>/dev/null <<<"$state" || fail "the Software Update target did not survive acceptance intact: $state"
[[ "$(run ipc call health-test accept "$unlisted_snapshot" 0)" == "false" ]] \
|| fail 'a Settings target the service does not list was accepted, so the allow-list is not one'
# ── The command a repair will run, carried through ──────────────────────────
#
# Every field of a check is projected onto a known shape on the way in, which
# is what stops an unknown key from reaching the report -- and which means a
# NEW known key has to be added to the projection or it is silently dropped.
# `repairCommand` is the one where that failure is invisible: the repair still
# works, the row still says "Restart Vicinae", and the only thing missing is
# the sentence telling somebody what is about to run as them.
repair_command_snapshot="$(jq -c '
.checks[1].repairCommand = "systemd-inhibit --list"
' <<<"$warning_snapshot")"
[[ "$(run ipc call health-test accept "$repair_command_snapshot" 0)" == "true" ]] \
|| fail 'a check carrying its repair command was rejected'
jq -e '[.checks[] | select(.id == "panama.caffeine") | .repairCommand] == ["systemd-inhibit --list"]' \
>/dev/null <<<"$(run ipc call health-test report)" \
|| fail 'the repair command was projected away, so the row cannot say what it is about to run'
# A check with no repair, or a doctor that has not learned to send one, is
# still a check. Dropping the field is fine; refusing the report is not.
[[ "$(run ipc call health-test accept "$warning_snapshot" 0)" == "true" ]] \
|| fail 'a check without a repair command was rejected once the field existed'
[[ "$(run ipc call health-test accept "$projection_snapshot" 0)" == "true" ]] \ [[ "$(run ipc call health-test accept "$projection_snapshot" 0)" == "true" ]] \
|| fail 'snapshot with unknown non-action fields was rejected instead of safely projected' || fail 'snapshot with unknown non-action fields was rejected instead of safely projected'
stored_report="$(run ipc call health-test report)" stored_report="$(run ipc call health-test report)"
@@ -398,6 +500,63 @@ state="$(run ipc call health-test status)"
jq -e '.repairingId == "" and .generation == ($before + 1)' --argjson before "$confirm_generation" \ jq -e '.repairingId == "" and .generation == ($before + 1)' --argjson before "$confirm_generation" \
>/dev/null <<<"$state" || fail "rejected repair altered process state: $state" >/dev/null <<<"$state" || fail "rejected repair altered process state: $state"
# ── One row, re-checked ─────────────────────────────────────────────────────
#
# The whole scan is thirty probes with network and D-Bus work behind several of
# them. Somebody who has just restarted a service by hand wants to know about
# that service, and making them wait nine seconds for the other twenty-nine is
# how a Re-check button stops being pressed and a stale row stops being
# believed. So `refreshCheck` runs one probe -- and has to leave the rest of
# the accepted report exactly as it was, since a single-check response says
# nothing about any other row.
: >"$repair_log"
[[ "$(run ipc call health-test recheck panama.caffeine)" == "true" ]] \
|| fail 'a single-check refresh was refused for a check that is in the report'
for _ in $(seq 1 60); do
state="$(run ipc call health-test status)"
jq -e '(.checkStates[] | select(.id == "panama.caffeine") | .status) == "ok"' \
>/dev/null <<<"$state" && break
sleep 0.1
done
jq -e '.checks == ["integration.calendar", "panama.caffeine"]
and (.checkStates[] | select(.id == "panama.caffeine") | .status) == "ok"
and (.checkStates[] | select(.id == "integration.calendar") | .status) == "warning"
and .summary == {status: "warning", healthy: 1, warnings: 1, errors: 0, unconfigured: 0}' \
>/dev/null <<<"$state" \
|| fail "a single-check refresh did not update exactly the one row it probed: $state"
grep -Fxq 'check panama.caffeine' "$repair_log" \
|| fail "refreshCheck did not ask the doctor for one check: $(<"$repair_log")"
! grep -Fxq -- '--json' "$repair_log" \
|| fail 'a single-check refresh ran the whole thirty-probe scan anyway'
: >"$repair_log"
[[ "$(run ipc call health-test recheck unknown.check)" == "false" ]] \
|| fail 'a check id that is not in the report started a probe'
[[ ! -s "$repair_log" ]] || fail 'a refused single-check refresh started a process'
# ── The report, saved rather than copied ────────────────────────────────────
#
# Copy Report puts the diagnostics on the clipboard, which is the right answer
# when the next step is pasting it into a message and the wrong one when the
# next step is attaching it, or reading it in an editor, or sending it from a
# session that is the thing being diagnosed. Same redacted projection, written
# to a file.
report_file="$fixture_dir/health-report.txt"
[[ "$(run ipc call health-test save "$report_file")" == "true" ]] \
|| fail 'saving the health report was refused'
for _ in $(seq 1 40); do
[[ -s "$report_file" ]] && break
sleep 0.1
done
[[ -s "$report_file" ]] || fail 'the saved health report is absent or empty'
grep -Fq 'panama.caffeine' "$report_file" \
|| fail 'the saved report does not contain the checks it is a report of'
! grep -Fq 'fixture-secret' "$report_file" \
|| fail 'the saved report is not the redacted projection that copyReport writes'
rg -Fq 'panama-health-report.txt' "$service" \
|| fail 'saveReport has no default destination, so the row has nowhere to write without a file dialog'
python3 - "$service" <<'PY' || fail 'external repair failure notification is not bounded' python3 - "$service" <<'PY' || fail 'external repair failure notification is not bounded'
import sys import sys
+105 -8
View File
@@ -78,10 +78,46 @@ rg -Fq 'SystemSettings.openGnomePanel("system", "users")' "$settings_dir/HealthP
&& fail 'System Health still hands Users to GNOME, but Panama owns that page' && fail 'System Health still hands Users to GNOME, but Panama owns that page'
rg -Fq 'SystemSettings.openGnomePanel("sharing")' "$settings_dir/HealthPage.qml" \ rg -Fq 'SystemSettings.openGnomePanel("sharing")' "$settings_dir/HealthPage.qml" \
&& fail 'System Health still hands Sharing to GNOME, but Panama owns that page' && fail 'System Health still hands Sharing to GNOME, but Panama owns that page'
# Colour profiles left the card for the same reason Users and Sharing did:
# Displays offers a colour profile, a bit depth, an SDR brightness and an SDR
# saturation per output, which is more than GNOME's panel can say in a session
# it does not manage. Digital wellbeing stays, because nothing here does it.
rg -Fq 'SystemSettings.openGnomePanel("color")' "$settings_dir/HealthPage.qml" \ rg -Fq 'SystemSettings.openGnomePanel("color")' "$settings_dir/HealthPage.qml" \
|| fail 'Fedora ownership boundary lost the Color profiles handoff' && fail 'System Health still hands Colour profiles to GNOME, but Displays owns them per output'
rg -Fq 'SystemSettings.openGnomePanel("wellbeing")' "$settings_dir/HealthPage.qml" \ rg -Fq 'SystemSettings.openGnomePanel("wellbeing")' "$settings_dir/HealthPage.qml" \
|| fail 'Fedora ownership boundary lost the Digital wellbeing handoff' || fail 'Fedora ownership boundary lost the Digital wellbeing handoff'
# ── A repair says what it will run, before it runs it ───────────────────────
#
# "Restart Vicinae" and "Release duplicate inhibitors" are what the buttons
# say. What they do is run a command as the person pressing them, and the row
# never said which. Somebody who wants to know what a button is about to do to
# their machine should not have to read panama-doctor to find out -- and after
# the fact is not the same answer, because by then the decision is made.
rg -Fq 'repairCommand' "$settings_dir/HealthPage.qml" \
|| fail 'a repair row never shows the command it is about to run'
python3 - "$settings_dir/HealthPage.qml" <<'PY' || fail 'the repair command is only shown once the repair has run, which is after the moment it was worth knowing'
import re
import sys
text = open(sys.argv[1], encoding="utf-8").read()
for line in text.splitlines():
if "repairCommand" not in line:
continue
# Gating the command on a repair result, a repairing id, or a "working"
# state makes it after-the-fact reassurance rather than a decision aid.
if re.search(r'\b(lastRepair|repairingId|repairing|working|repairFailed)\b', line):
raise SystemExit(1)
raise SystemExit(0)
PY
# ── One row, re-checked; and the report saved rather than copied ────────────
rg -Fq 'Health.refreshCheck(' "$settings_dir/HealthCheckRow.qml" \
|| rg -Fq 'Health.refreshCheck(' "$settings_dir/HealthPage.qml" \
|| fail 'no row offers a re-check, so a row fixed by hand stays wrong until the whole scan runs again'
rg -Fq 'Health.saveReport(' "$settings_dir/HealthPage.qml" \
|| rg -Fq 'Health.saveReport(' "$settings_dir/HealthSummary.qml" \
|| fail 'the report can only be copied, never saved'
# Exact authored handoffs are asserted above. Also prove every panel named by # Exact authored handoffs are asserted above. Also prove every panel named by
# this boundary is accepted by SystemSettings, so a typo cannot ship a dead # this boundary is accepted by SystemSettings, so a typo cannot ship a dead
# button even if its copy still looks correct. # button even if its copy still looks correct.
@@ -122,7 +158,9 @@ labels = (
'if (status === "error") return "Action required";', 'if (status === "error") return "Action required";',
'return "Not set up";', 'return "Not set up";',
) )
assert all(label in page for label in labels) # The four status words belong to the row that shows them. They used to be
# written on the page, one indirection away from the Text that rendered them.
assert all(label in row for label in labels)
assert 'group: "desktop-foundation"' in page assert 'group: "desktop-foundation"' in page
assert 'group: "input-media"' in page assert 'group: "input-media"' in page
assert 'group: "integrations"' in page assert 'group: "integrations"' in page
@@ -140,7 +178,7 @@ assert 'pendingConfirmation' in page
assert 'ddc-permissions' in page assert 'ddc-permissions' in page
PY PY
fixture='{"schemaVersion":1,"generatedAt":"2026-08-18T12:00:00Z","summary":{"status":"error","healthy":2,"warnings":2,"errors":1,"unconfigured":1},"context":{"session":"hyprland","versions":[{"id":"quickshell","version":"0.3.0"}]},"checks":[{"id":"desktop.vicinae","group":"desktop-foundation","title":"Vicinae","status":"warning","detail":"The launcher service is stopped.","action":{"kind":"repair","label":"Restart Vicinae","confirm":false}},{"id":"desktop.quickshell","group":"desktop-foundation","title":"Quickshell","status":"error","detail":"Panama shell needs to restart.","action":{"kind":"repair","label":"Restart Panama","confirm":true}},{"id":"input.pipewire","group":"input-media","title":"PipeWire","status":"ok","detail":"Audio graph is responding."},{"id":"integration.bluebubbles","group":"integrations","title":"BlueBubbles","status":"unconfigured","detail":"Messaging integration has not been enabled."},{"id":"integration.calendar","group":"integrations","title":"Calendar","status":"warning","detail":"Calendar probe timed out.","action":{"kind":"open","label":"Open Date & Time","confirm":false,"target":"datetime"}},{"id":"panama.caffeine","group":"panama-tools","title":"Caffeine","status":"ok","detail":"No duplicate sleep inhibitors."}]}' fixture='{"schemaVersion":1,"generatedAt":"2026-08-18T12:00:00Z","summary":{"status":"error","healthy":2,"warnings":3,"errors":1,"unconfigured":1},"context":{"session":"hyprland","versions":[{"id":"quickshell","version":"0.3.0"}]},"checks":[{"id":"desktop.vicinae","group":"desktop-foundation","title":"Vicinae","status":"warning","detail":"The launcher service is stopped.","action":{"kind":"repair","label":"Restart Vicinae","confirm":false},"repairCommand":"systemctl --user restart vicinae.service"},{"id":"desktop.quickshell","group":"desktop-foundation","title":"Quickshell","status":"error","detail":"Panama shell needs to restart.","action":{"kind":"repair","label":"Restart Panama","confirm":true},"repairCommand":"panama-action restart-shell"},{"id":"input.pipewire","group":"input-media","title":"PipeWire","status":"ok","detail":"Audio graph is responding."},{"id":"integration.bluebubbles","group":"integrations","title":"BlueBubbles","status":"unconfigured","detail":"Messaging integration has not been enabled."},{"id":"integration.calendar","group":"integrations","title":"Calendar","status":"warning","detail":"Calendar probe timed out.","action":{"kind":"open","label":"Open Date & Time","confirm":false,"target":"datetime"}},{"id":"panama.caffeine","group":"panama-tools","title":"Caffeine","status":"ok","detail":"No duplicate sleep inhibitors."},{"id":"panama.updates","group":"panama-tools","title":"Software updates","status":"warning","detail":"3 pending updates carry a security advisory.","action":{"kind":"open","label":"Open Software Update","confirm":false,"target":"updates"}}]}'
state_home="$(mktemp -d /tmp/panama-health-ui.XXXXXX)" state_home="$(mktemp -d /tmp/panama-health-ui.XXXXXX)"
config_path="$state_home/quickshell" config_path="$state_home/quickshell"
@@ -251,6 +289,39 @@ ShellRoot {
}); });
} }
// The Software Update action, end to end: the doctor authors the
// target, Health accepts it, and the row has to actually go there.
function offerUpdatesAction(): bool {
ShellState.settingsPage = "services";
return Health.consumeSnapshot(JSON.stringify({
schemaVersion: 1,
generatedAt: "2026-08-24T12:00:00Z",
summary: { status: "warning", healthy: 0, warnings: 1, errors: 0, unconfigured: 0 },
context: { session: "hyprland", versions: [] },
checks: [{
id: "panama.updates",
group: "panama-tools",
title: "Software updates",
status: "warning",
detail: "3 pending updates carry a security advisory.",
action: {
kind: "open",
label: "Open Software Update",
confirm: false,
target: "updates"
}
}]
}), Health.acceptedGeneration + 1);
}
function followUpdatesAction(): string {
const requested = settingsShell.requestHealthAction("panama.updates");
return JSON.stringify({
requested: requested,
page: ShellState.settingsPage
});
}
function activateIndicator(): string { function activateIndicator(): string {
ShellState.settingsPage = "home"; ShellState.settingsPage = "home";
ShellState.settingsOpen = false; ShellState.settingsOpen = false;
@@ -311,19 +382,19 @@ jq -e '
{objectName:"health-check-row:issue:desktop.vicinae", id:"desktop.vicinae", section:"issue", statusText:"Needs attention"}, {objectName:"health-check-row:issue:desktop.vicinae", id:"desktop.vicinae", section:"issue", statusText:"Needs attention"},
{objectName:"health-check-row:issue:desktop.quickshell", id:"desktop.quickshell", section:"issue", statusText:"Action required"}, {objectName:"health-check-row:issue:desktop.quickshell", id:"desktop.quickshell", section:"issue", statusText:"Action required"},
{objectName:"health-check-row:issue:integration.calendar", id:"integration.calendar", section:"issue", statusText:"Needs attention"}, {objectName:"health-check-row:issue:integration.calendar", id:"integration.calendar", section:"issue", statusText:"Needs attention"},
{objectName:"health-check-row:issue:panama.updates", id:"panama.updates", section:"issue", statusText:"Needs attention"},
{objectName:"health-check-row:quiet:input.pipewire", id:"input.pipewire", section:"quiet", statusText:"Healthy"}, {objectName:"health-check-row:quiet:input.pipewire", id:"input.pipewire", section:"quiet", statusText:"Healthy"},
{objectName:"health-check-row:quiet:integration.bluebubbles", id:"integration.bluebubbles", section:"quiet", statusText:"Not set up"}, {objectName:"health-check-row:quiet:integration.bluebubbles", id:"integration.bluebubbles", section:"quiet", statusText:"Not set up"},
{objectName:"health-check-row:quiet:panama.caffeine", id:"panama.caffeine", section:"quiet", statusText:"Healthy"} {objectName:"health-check-row:quiet:panama.caffeine", id:"panama.caffeine", section:"quiet", statusText:"Healthy"}
] ]
and (.renderedRows | map(.id) | length) == 6 and (.renderedRows | map(.id) | length) == 7
and (.renderedRows | map(.id) | unique | length) == 6 and (.renderedRows | map(.id) | unique | length) == 7
and .emptyQuietGroups == ["desktop-foundation"] and .emptyQuietGroups == ["desktop-foundation"]
and .fedoraHandoffs == [ and .fedoraHandoffs == [
{id:"color", label:"Color profiles", action:"Open color"},
{id:"wellbeing", label:"Digital wellbeing", action:"Open wellbeing"} {id:"wellbeing", label:"Digital wellbeing", action:"Open wellbeing"}
] ]
and .summaryHeight == 126 and .summaryHeight == 126
and (.rowHeights | length) == 6 and (.rowHeights | length) == 7
and (.rowHeights | all(. >= 62)) and (.rowHeights | all(. >= 62))
and .checking == true and .checking == true
and .checkingText == "Checking…" and .checkingText == "Checking…"
@@ -363,7 +434,7 @@ jq -e '.renderedRows[] | select(.id == "desktop.vicinae") | .statusText == "Repa
>/dev/null <<<"$failed_repair_state" || fail "repair failure was not shown inline: $failed_repair_state" >/dev/null <<<"$failed_repair_state" || fail "repair failure was not shown inline: $failed_repair_state"
[[ "$(jq -c .rowHeights <<<"$failed_repair_state")" == "$settled_heights" ]] \ [[ "$(jq -c .rowHeights <<<"$failed_repair_state")" == "$settled_heights" ]] \
|| fail 'repair failure changed row geometry' || fail 'repair failure changed row geometry'
[[ "$(jq -r '.renderedRows | map(.id) | unique | length' <<<"$failed_repair_state")" == 6 ]] \ [[ "$(jq -r '.renderedRows | map(.id) | unique | length' <<<"$failed_repair_state")" == 7 ]] \
|| fail 'repair state duplicated a health action row' || fail 'repair state duplicated a health action row'
for _ in $(seq 1 40); do for _ in $(seq 1 40); do
failed_repair_state="$(run ipc call health-ui-test state)" failed_repair_state="$(run ipc call health-ui-test state)"
@@ -432,6 +503,32 @@ jq -e '
and .refreshRequested == true and .refreshRequested == true
' >/dev/null <<<"$activation_state" || fail "indicator activation did not open and refresh System Health: $activation_state" ' >/dev/null <<<"$activation_state" || fail "indicator activation did not open and refresh System Health: $activation_state"
# ── The Software Update action goes to Software Update ──────────────────────
#
# The two health checks that offered "Open Software Update" carried no target
# at all, so the button rendered, focused, and did nothing -- the failure this
# whole target mechanism exists to make impossible, sitting inside it. Fixing
# it needed a change on both sides at once (panama-doctor authors the target,
# Health.settingsTargets accepts it), and each half is pinned where it lives.
# This is the third piece: the row acts on it.
[[ "$(run ipc call health-ui-test offerUpdatesAction)" == "true" ]] \
|| fail 'a check offering the Software Update action was rejected by the service'
# Both conditions: the row rendered, and the scan the page starts when it loads
# has settled. A row action is disabled while a check is running, which is
# correct and would otherwise read here as a dead button.
for _ in $(seq 1 60); do
updates_state="$(run ipc call health-ui-test state)"
jq -e '.checking == false and (.renderedRows | any(.id == "panama.updates"))' \
>/dev/null <<<"$updates_state" && break
sleep 0.1
done
jq -e '.checking == false and (.renderedRows | any(.id == "panama.updates"))' \
>/dev/null <<<"$updates_state" \
|| fail "the Software Update check did not render on a settled page: $updates_state"
follow_state="$(run ipc call health-ui-test followUpdatesAction)"
jq -e '.requested == true and .page == "updates"' >/dev/null <<<"$follow_state" \
|| fail "the Software Update action did not open Software Update: $follow_state"
if rg -i 'QQml|ReferenceError|TypeError|binding loop|failed to load component' "$shell_log"; then if rg -i 'QQml|ReferenceError|TypeError|binding loop|failed to load component' "$shell_log"; then
fail 'isolated fixture emitted QML errors or warnings' fail 'isolated fixture emitted QML errors or warnings'
fi fi
+152 -13
View File
@@ -24,6 +24,9 @@ set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
manual_dir="$repo_dir/config/dot/quickshell/manual" manual_dir="$repo_dir/config/dot/quickshell/manual"
page="$repo_dir/config/dot/quickshell/modules/settings/ManualPage.qml" page="$repo_dir/config/dot/quickshell/modules/settings/ManualPage.qml"
# The chapter list and the titles live in one place that both the reader and
# About's Manual card instantiate, so neither can drift from the other.
contents="$repo_dir/config/dot/quickshell/modules/settings/ManualChapters.qml"
routes="$repo_dir/config/dot/quickshell/services/SettingsRoutes.qml" routes="$repo_dir/config/dot/quickshell/services/SettingsRoutes.qml"
shell_ui="$repo_dir/config/dot/quickshell/modules/settings/SettingsShell.qml" shell_ui="$repo_dir/config/dot/quickshell/modules/settings/SettingsShell.qml"
qmldir="$repo_dir/config/dot/quickshell/modules/settings/qmldir" qmldir="$repo_dir/config/dot/quickshell/modules/settings/qmldir"
@@ -40,16 +43,18 @@ note() { findings+=("$1"); }
mapfile -t on_disk < <(find "$manual_dir" -maxdepth 1 -name '*.md' -printf '%f\n' | sort) mapfile -t on_disk < <(find "$manual_dir" -maxdepth 1 -name '*.md' -printf '%f\n' | sort)
(( ${#on_disk[@]} > 0 )) || note 'the manual has no chapters' (( ${#on_disk[@]} > 0 )) || note 'the manual has no chapters'
mapfile -t listed < <(grep -oE 'file: "[^"]+\.md"' "$page" | sed 's/file: "//; s/"//' | sort) [[ -r "$contents" ]] \
(( ${#listed[@]} > 0 )) || note 'the manual page lists no chapters' || note 'ManualChapters.qml is missing, so neither the reader nor About knows what the chapters are'
mapfile -t listed < <(grep -oE 'file: "[^"]+\.md"' "$contents" | sed 's/file: "//; s/"//' | sort)
(( ${#listed[@]} > 0 )) || note 'the chapter list names no chapters'
for file in "${on_disk[@]}"; do for file in "${on_disk[@]}"; do
printf '%s\n' "${listed[@]}" | grep -qx "$file" \ printf '%s\n' "${listed[@]}" | grep -qx "$file" \
|| note "$file exists but the manual page never shows it" || note "$file exists but the chapter list never names it"
done done
for file in "${listed[@]}"; do for file in "${listed[@]}"; do
[[ -r "$manual_dir/$file" ]] \ [[ -r "$manual_dir/$file" ]] \
|| note "the manual page lists $file, which does not exist, so that chapter renders an error" || note "the chapter list names $file, which does not exist, so that chapter renders an error"
done done
# A chapter that is only a heading is a chapter somebody forgot to write. # A chapter that is only a heading is a chapter somebody forgot to write.
@@ -60,6 +65,43 @@ for file in "${on_disk[@]}"; do
|| note "$file does not begin with a heading, so it has no title of its own" || note "$file does not begin with a heading, so it has no title of its own"
done done
# ── 1b. The tab titles come from the files ───────────────────────────────────
#
# The page carried a comment saying the title shown is the first heading of
# each chapter, "so a chapter cannot be renamed in one place and not the
# other". It was not true: the titles were a second copy, written beside the
# filenames, and the two agreed only because nobody had renamed anything yet.
# A comment that describes a property the code does not have is worse than no
# comment, because it is the reason the next person does not check.
#
# So: no authored label beside a chapter file, and something that actually
# reads a leading heading out of one.
python3 - "$contents" <<'PY' || note 'the chapter list still carries hand-written titles beside the filenames, so a renamed heading and a tab label can disagree'
import re
import sys
text = open(sys.argv[1], encoding="utf-8").read()
block = re.search(r'chapters:\s*\[(.*?)\n \]', text, re.S)
if not block:
raise SystemExit(1)
raise SystemExit(1 if re.search(r'label:\s*"', block.group(1)) else 0)
PY
grep -qE '\^#' "$contents" \
|| note 'nothing reads a leading heading, so the chapter titles cannot be coming from the files'
grep -q 'Quickshell.shellDir + "/manual/"' "$contents" \
|| note 'the chapter titles are not read through the shell directory, so they would break on a clone elsewhere'
grep -q 'ManualChapters {' "$page" \
|| note 'the manual reader keeps its own idea of what the chapters are'
# And the titles the files offer have to be usable as tab labels: a first line
# that is a paragraph would render a tab strip nobody can read.
for file in "${on_disk[@]}"; do
heading="$(head -1 "$manual_dir/$file" | sed 's/^#\+[[:space:]]*//')"
[[ -n "$heading" ]] || note "$file has an empty first heading, so its tab would have no name"
(( ${#heading} <= 40 )) \
|| note "$file's first heading is ${#heading} characters; it is the tab label, so it has to be a title"
done
# ── 2 & 3. How it renders ──────────────────────────────────────────────────── # ── 2 & 3. How it renders ────────────────────────────────────────────────────
grep -q 'textFormat: Text.MarkdownText' "$page" \ grep -q 'textFormat: Text.MarkdownText' "$page" \
@@ -68,6 +110,75 @@ grep -q 'root.chapters\[root.current\]' "$page" \
|| note 'the page does not render one chapter at a time; a single long Text goes blank rather than erroring' || note 'the page does not render one chapter at a time; a single long Text goes blank rather than erroring'
grep -q 'onLinkActivated' "$page" \ grep -q 'onLinkActivated' "$page" \
|| note 'links in the manual do nothing when clicked' || note 'links in the manual do nothing when clicked'
# ── 3b. A link to a settings page opens the settings page ────────────────────
#
# The whole reason the manual is rendered inside Settings rather than in a
# browser is that a chapter can point at a page and have that mean something.
# Every link went to Qt.openUrlExternally, so "open Displays" handed a
# panama:// URL to xdg-open, which has no handler for it: the click did
# nothing, silently, which is the worst of the three possible outcomes.
grep -q 'panama://settings/' "$page" \
|| note 'the manual has no in-app link scheme, so a chapter cannot point at a settings page'
python3 - "$page" <<'PY' || note 'the manual link handler does not route in-app links through ShellState.openSettings while still sending everything else out of the desktop'
import re
import sys
text = open(sys.argv[1], encoding="utf-8").read()
def block_at(index: int) -> str:
"""The braced body starting at the first { on or after index."""
start = text.index("{", index)
depth = 0
for position in range(start, len(text)):
if text[position] == "{":
depth += 1
elif text[position] == "}":
depth -= 1
if depth == 0:
return text[start:position + 1]
raise SystemExit(1)
# Either shape is fine: the branch written inline, or delegated to a named
# function on the page. What matters is what is on the path, so the path is
# followed rather than one line being read and guessed at.
handler = re.search(r'onLinkActivated:\s*link\s*=>\s*(?P<tail>.)', text)
if not handler:
raise SystemExit(1)
if handler.group("tail") == "{":
body = block_at(handler.start("tail"))
else:
called = re.match(r'(?:root\.)?([A-Za-z][A-Za-z0-9]*)\(', text[handler.start("tail"):])
if not called:
raise SystemExit(1)
declaration = re.search(rf'function {called.group(1)}\s*\(', text)
if not declaration:
raise SystemExit(1)
body = block_at(declaration.end())
raise SystemExit(0 if "ShellState.openSettings" in body
and "Qt.openUrlExternally" in body
and ("panama://settings/" in body or "linkScheme" in body) else 1)
PY
# Every in-app link a chapter actually writes must name a page that exists.
# A typo here is invisible: the scheme matches, the handler fires, and
# openSettings resolves an unknown id to Home -- so the link works, just not
# the way the sentence around it promised.
leaves="$( {
grep -oE '\{ page: "[a-z-]+", label: "[^"]*", icon: "[^"]*", tabs: \[\] \}' "$routes"
grep -oE '\{ page: "[a-z-]+", label: "[^"]*" \}' "$routes"
sed -n '/property var hiddenLeaves:/,/\]/p' "$routes" | grep -oE 'page: "[a-z-]+"'
} | sed -E 's/.*page: "([a-z-]+)".*/\1/' | sort -u)"
[[ -n "$leaves" ]] || note 'no leaves could be read from SettingsRoutes, so in-app manual links prove nothing'
while read -r target; do
[[ -n "$target" ]] || continue
grep -qx "$target" <<<"$leaves" \
|| note "a chapter links to panama://settings/$target, which is not a page anyone can land on"
done < <(grep -rhoE 'panama://settings/[a-z-]+' "$manual_dir" \
| sed -E 's|panama://settings/||' | sort -u)
grep -q 'onLoadFailed' "$page" \ grep -q 'onLoadFailed' "$page" \
|| note 'a chapter that cannot be read fails silently instead of saying so' || note 'a chapter that cannot be read fails silently instead of saying so'
@@ -80,18 +191,31 @@ grep -q '\.\./\.\./\.\.' "$page" \
&& note 'the manual path walks upward out of the shell directory, which is only correct by accident' && note 'the manual path walks upward out of the shell directory, which is only correct by accident'
# ── 4. Registered everywhere a settings page has to be ─────────────────────── # ── 4. Registered everywhere a settings page has to be ───────────────────────
# The manual is a tab of the System category rather than a sidebar row of its # The manual is a routable leaf with no tab of its own. It was a tab of System
# own -- it is reference material, not a control surface, and it belongs beside # until the strip reached ten and had to come back down to eight, and it is the
# About for the same reason. SettingsRoutes is what makes it reachable at all: # right one to lose: it is reference material rather than a control surface, so
# a leaf missing from the taxonomy cannot be opened, searched, or linked to. # it is opened deliberately -- from About's Manual card, from a deep link, from
# a search result -- rather than found by scanning a row of tabs.
#
# That makes the taxonomy entry the only thing holding it up. A leaf missing
# from SettingsRoutes cannot be opened, searched, or linked to, and because it
# draws no tab anywhere, nothing on screen would show it had gone.
grep -q '{ page: "manual", label: ' "$routes" || note 'the manual is not a leaf in SettingsRoutes, so nothing can navigate to it' python3 - "$routes" <<'PY' || note 'the manual is not a hidden leaf in SettingsRoutes, so nothing can navigate to it'
python3 - "$routes" <<'PY' || note 'the manual is no longer a tab of the System category, so it has drifted out of the group it belongs to' import re
import re, sys import sys
text = open(sys.argv[1], encoding="utf-8").read() text = open(sys.argv[1], encoding="utf-8").read()
block = re.search(r'\{ page: "system",.*?tabs: \[(.*?)\] \}', text, re.S) block = re.search(r'hiddenLeaves:\s*\[(.*?)\]', text, re.S)
raise SystemExit(0 if block and '{ page: "manual"' in block.group(1) else 1) raise SystemExit(0 if block and re.search(r'page: "manual"', block.group(1)) else 1)
PY
python3 - "$routes" <<'PY' || note 'the manual is a System tab again, taking back a slot the eight-tab strip does not have'
import re
import sys
text = open(sys.argv[1], encoding="utf-8").read()
block = re.search(r'\{ page: "system",.*?tabs: \[(.*?)\n \] \}', text, re.S)
raise SystemExit(1 if block and '{ page: "manual"' in block.group(1) else 0)
PY PY
grep -q 'case "manual": return manualPage;' "$shell_ui" || note 'SettingsShell does not route to the manual' grep -q 'case "manual": return manualPage;' "$shell_ui" || note 'SettingsShell does not route to the manual'
grep -q 'Component { id: manualPage; ManualPage {} }' "$shell_ui" || note 'SettingsShell never declares the manual component' grep -q 'Component { id: manualPage; ManualPage {} }' "$shell_ui" || note 'SettingsShell never declares the manual component'
@@ -102,6 +226,21 @@ grep -q '^ManualPage 1.0 ManualPage.qml$' "$qmldir" || note 'ManualPage is not r
grep -q 'SettingsRoutes.resolve(' "$state" || note 'ShellState does not resolve pages through SettingsRoutes, so openSettings("manual") has no defined destination' grep -q 'SettingsRoutes.resolve(' "$state" || note 'ShellState does not resolve pages through SettingsRoutes, so openSettings("manual") has no defined destination'
grep -q 'page: "manual"' "$search" || note 'the manual is not searchable from the settings search box' grep -q 'page: "manual"' "$search" || note 'the manual is not searchable from the settings search box'
# With no tab of its own, About's Manual card is the only place the manual is
# offered rather than looked up. If that card stops opening it, the page is
# still reachable in principle and undiscoverable in practice.
about="$repo_dir/config/dot/quickshell/modules/settings/AboutPage.qml"
if [[ -r "$about" ]]; then
grep -q 'openSettings("manual"' "$about" \
|| note 'About does not open the manual, which is now the only place it is offered rather than searched for'
grep -q 'ManualChapters {' "$about" \
|| note 'About keeps its own idea of what the chapters are, so its card and the reader can disagree'
grep -qE 'file: "[^"]+\.md"' "$about" \
&& note 'About names chapter files itself instead of reading the shared list'
else
note 'AboutPage.qml is missing, so the manual has nowhere to be opened from'
fi
if (( ${#findings[@]} > 0 )); then if (( ${#findings[@]} > 0 )); then
printf 'manual contract: %d finding(s)\n' "${#findings[@]}" >&2 printf 'manual contract: %d finding(s)\n' "${#findings[@]}" >&2
printf ' - %s\n' "${findings[@]}" >&2 printf ' - %s\n' "${findings[@]}" >&2
+7 -1
View File
@@ -53,10 +53,16 @@ while read -r page; do
[[ -n "$page" ]] || continue [[ -n "$page" ]] || continue
[[ "$page" == "home" ]] && continue [[ "$page" == "home" ]] && continue
expected[settings-$page]="settings-page $page" expected[settings-$page]="settings-page $page"
#
# Hidden leaves count too. The manual has no tab of its own -- it is reference
# material rather than a control surface -- but "manual" is exactly the sort of
# thing somebody types into a launcher, and a leaf with no tab is the one leaf
# with nowhere else to be found by scanning.
done < <( { done < <( {
grep -oE '\{ page: "[a-z-]+", label: "[^"]*", icon: "[^"]*", tabs: \[\] \}' "$routes" grep -oE '\{ page: "[a-z-]+", label: "[^"]*", icon: "[^"]*", tabs: \[\] \}' "$routes"
grep -oE '\{ page: "[a-z-]+", label: "[^"]*" \}' "$routes" grep -oE '\{ page: "[a-z-]+", label: "[^"]*" \}' "$routes"
} | sed -E 's/\{ page: "([a-z-]+)".*/\1/' | sort -u) sed -n '/property var hiddenLeaves:/,/\]/p' "$routes" | grep -oE 'page: "[a-z-]+"'
} | sed -E 's/.*page: "([a-z-]+)".*/\1/' | sort -u)
(( ${#expected[@]} > 18 )) || fail 'no generated per-page commands were found; run scripts/panama-settings-commands' (( ${#expected[@]} > 18 )) || fail 'no generated per-page commands were found; run scripts/panama-settings-commands'
+180 -1
View File
@@ -33,8 +33,13 @@ state_home="$home/.local/state"
runtime_dir="$fixture/runtime" runtime_dir="$fixture/runtime"
bin_dir="$fixture/bin" bin_dir="$fixture/bin"
data_home="$home/.local/share" data_home="$home/.local/share"
# The updates check reads the Updates page's cache out of XDG_CACHE_HOME rather
# than through DoctorConfig, so without this the fixture would read the real
# machine's update state and report whatever happened to be pending today.
cache_home="$home/.cache"
mkdir -p "$config_home" "$state_home" "$runtime_dir" "$bin_dir" "$data_home/vicinae/scripts" mkdir -p "$config_home" "$state_home" "$runtime_dir" "$bin_dir" "$data_home/vicinae/scripts" \
"$cache_home/panama"
# A mount table with the document portal present, which is the healthy state the # A mount table with the document portal present, which is the healthy state the
# rest of this file assumes. Written rather than read from /proc so the contract # rest of this file assumes. Written rather than read from /proc so the contract
@@ -114,6 +119,7 @@ run_doctor() {
HOME="$home" \ HOME="$home" \
PATH="$bin_dir" \ PATH="$bin_dir" \
XDG_CURRENT_DESKTOP=Hyprland \ XDG_CURRENT_DESKTOP=Hyprland \
XDG_CACHE_HOME="$cache_home" \
PANAMA_HOME_ASSISTANT_URL='https://fixture.invalid' \ PANAMA_HOME_ASSISTANT_URL='https://fixture.invalid' \
PANAMA_HOME_ASSISTANT_TOKEN='fixture-secret-token' \ PANAMA_HOME_ASSISTANT_TOKEN='fixture-secret-token' \
PANAMA_DOCTOR_ROOT="$repo_dir" \ PANAMA_DOCTOR_ROOT="$repo_dir" \
@@ -803,4 +809,177 @@ for rejected_id in unknown.check integration.home-assistant input.brightness \
[[ "$(fixture_state)" == "$before_state" ]] || fail "$rejected_id mutated the filesystem" [[ "$(fixture_state)" == "$before_state" ]] || fail "$rejected_id mutated the filesystem"
done done
# ── Every check in the order has a title ────────────────────────────────────
#
# `unavailable_check` is the doctor's own containment: a probe that raises
# becomes a warning row rather than a crashed report. It reads the title out of
# CHECK_TITLES by key, and `panama.updates` was never added there -- so the one
# path that exists to keep a failing probe from taking the report down was
# itself a KeyError, raised inside the `except` handler that was catching the
# original failure, from where it escaped `collect_checks`, hit `snapshot`'s
# outer guard, and raised again building the all-unavailable fallback.
#
# Nobody saw it, because it needs the updates probe to fail, and the updates
# probe reads a cache file and almost never does. Both halves are asserted:
# the invariant, which is cheap and total, and the path, which is the one that
# actually ran.
/usr/bin/python3 - "$doctor" <<'PY' || fail 'a check in CHECK_ORDER has no title, so a failing probe raises KeyError from inside the handler that exists to contain it'
import importlib.machinery
import importlib.util
import sys
loader = importlib.machinery.SourceFileLoader("panama_doctor_titles", sys.argv[1])
spec = importlib.util.spec_from_loader(loader.name, loader)
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
loader.exec_module(module)
missing = [check for check in module.CHECK_ORDER if check not in module.CHECK_TITLES]
if missing:
raise SystemExit(f"no title for {missing}")
for check in module.CHECK_ORDER:
module.unavailable_check(check)
PY
/usr/bin/python3 - "$doctor" "$fixture/unavailable-home" <<'PY' || fail 'a probe that raises does not become a warning row; it takes the whole report with it'
import importlib.machinery
import importlib.util
import sys
from pathlib import Path
doctor_path, home_text = sys.argv[1:]
loader = importlib.machinery.SourceFileLoader("panama_doctor_unavailable", doctor_path)
spec = importlib.util.spec_from_loader(loader.name, loader)
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
loader.exec_module(module)
home = Path(home_text)
home.mkdir(parents=True)
config = module.DoctorConfig(home, home, home / "config", home / "state", home / "runtime", "", 0.05)
def raising(_config):
raise TimeoutError("fixture probe timeout")
module.check_updates = raising
report = module.snapshot(config)
ids = [check["id"] for check in report["checks"]]
if ids != list(module.CHECK_ORDER):
raise SystemExit("a raising probe changed the shape of the report")
row = next(check for check in report["checks"] if check["id"] == "panama.updates")
if row["status"] != "warning" or not row["title"]:
raise SystemExit(f"the contained row is not a titled warning: {row}")
PY
# ── The updates check knows where Software Update is ────────────────────────
#
# Two of its four states offered "Open Software Update" with no target, so the
# button rendered and did nothing. Adding the target is only half the change --
# Health.settingsTargets has to accept it, or the whole report is rejected --
# and health-service-contract pins the other half against this same file.
/usr/bin/python3 - "$cache_home/panama/updates.json" <<'PY'
import json
import sys
import time
json.dump({
"checkedAt": int(time.time()),
"dnf": {"count": 5, "securityCount": 3},
"flatpak": {"count": 0},
"firmware": {"count": 0},
}, open(sys.argv[1], "w"))
PY
pending_updates="$(run_doctor --json)"
check_status "$pending_updates" panama.updates warning
jq -e '.checks[] | select(.id == "panama.updates")
| .action == {kind:"open", label:"Open Software Update", confirm:false, target:"updates"}' \
>/dev/null <<<"$pending_updates" || fail 'the updates action carries no Settings target, so the button does nothing'
assert_schema_and_redaction "$pending_updates"
# ── A repair row says what it will run ──────────────────────────────────────
#
# The command is the literal from REPAIR_COMMANDS and nothing else: derived
# from the table rather than restated here, so a repair whose argv changes
# cannot leave the row describing the old one, and probe output can never reach
# this field.
repairable="$(PANAMA_DOCTOR_MOUNTINFO="$fixture/mountinfo-unmounted" run_doctor --json)"
check_status "$repairable" desktop.document-portal warning
printf '%s' "$repairable" >"$fixture/repairable-report.json"
/usr/bin/python3 - "$doctor" "$fixture/repairable-report.json" <<'PY' || fail 'a repairable check does not carry the exact authored repair command'
import importlib.machinery
import importlib.util
import json
import sys
loader = importlib.machinery.SourceFileLoader("panama_doctor_repair_command", sys.argv[1])
spec = importlib.util.spec_from_loader(loader.name, loader)
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
loader.exec_module(module)
report = json.load(open(sys.argv[2], encoding="utf-8"))
expected = {check: " ".join(command) for check, command in module.REPAIR_COMMANDS.items()}
seen = 0
for check in report["checks"]:
rendered = check.get("repairCommand")
if check["id"] in expected and check.get("action", {}).get("kind") == "repair":
if rendered != expected[check["id"]]:
raise SystemExit(f'{check["id"]} shows {rendered!r}, expected {expected[check["id"]]!r}')
seen += 1
elif rendered is not None and rendered != expected.get(check["id"]):
raise SystemExit(f'{check["id"]} shows a command that is not its authored one: {rendered!r}')
if not seen:
raise SystemExit("no repairable check carried a command, so this proves nothing")
PY
# ── One check, asked for on its own ─────────────────────────────────────────
#
# The whole scan is thirty probes. Re-checking the one row somebody just fixed
# should not wait on the other twenty-nine, so there is a verb that runs one --
# and it answers in the same envelope as a full report, because the consumer is
# the same parser and a second response shape is a second thing to get wrong.
single="$(run_doctor check desktop.hyprpaper)" || fail 'a single-check run failed'
jq -e '.schemaVersion == 1
and (.generatedAt | type == "string")
and (.summary.status | IN("healthy", "warning", "error"))
and (.context.session | IN("hyprland", "other"))
and (.context.versions | type == "array")
and (.checks | length) == 1
and (.checks[0] | has("id") and has("group") and has("title") and has("status") and has("detail"))
and .checks[0].id == "desktop.hyprpaper"' \
>/dev/null <<<"$single" || fail "a single check is not the full report shape: $single"
[[ "$(jq -r '.summary | [.healthy, .warnings, .errors, .unconfigured] | add' <<<"$single")" == "1" ]] \
|| fail 'the single-check summary counts something other than the one check it ran'
! grep -Fq 'fixture-secret-token' <<<"$single" || fail 'a single check exposed a fixture secret'
# An id nobody authored is refused rather than answered with an empty
# snapshot, which would look valid and say nothing. The refusal is an exit
# status and a message, not a report: a caller that asked for a check that does
# not exist has a bug, and handing it a well-formed reply hides it.
for rejected_id in unknown.check ../../escape 'desktop.vicinae;touch injected' ''; do
before_state="$(fixture_state)"
set +e
single_rejected="$(run_doctor check "$rejected_id" 2>"$fixture/single-check-error")"
single_status=$?
set -e
[[ "$single_status" != 0 ]] \
|| fail "the single-check verb accepted \"$rejected_id\""
[[ -z "$single_rejected" ]] \
|| fail "the single-check verb printed a report for \"$rejected_id\": $single_rejected"
[[ -s "$fixture/single-check-error" ]] \
|| fail "the single-check verb refused \"$rejected_id\" without saying why"
rm -f "$fixture/single-check-error"
[[ "$(fixture_state)" == "$before_state" ]] \
|| fail "the single-check verb mutated the filesystem for \"$rejected_id\""
done
# And it is not a second way to run the whole scan, or to run a repair.
run_doctor check desktop.hyprpaper --summary >/dev/null 2>&1 \
&& fail 'the single-check verb accepted a second output mode'
run_doctor check desktop.vicinae --repair desktop.vicinae >/dev/null 2>&1 \
&& fail 'the single-check verb accepted a repair alongside it'
printf 'panama doctor contract: PASS\n' printf 'panama doctor contract: PASS\n'
+96
View File
@@ -198,6 +198,102 @@ jq -e 'has("pwned") | not' "$settings" >/dev/null || fail 'a file outside the ba
# ── A snapshot that is not listed is refused ───────────────────────────────── # ── A snapshot that is not listed is refused ─────────────────────────────────
run restore "settings-20000101-000000000.json" >/dev/null 2>&1 && fail 'a missing snapshot was reported restored' run restore "settings-20000101-000000000.json" >/dev/null 2>&1 && fail 'a missing snapshot was reported restored'
# ── A snapshot can be given a name, and the name cannot be a path ────────────
#
# Fifteen rows reading "2026-08-24 11:03:07" are fifteen rows nobody can choose
# between, which is the same as having no backups: the one you want is the one
# you took before the thing you are undoing, and the timestamp does not say
# what that was. So `create` takes a name.
#
# A name typed by a person then reaches a filename, which is the oldest way a
# helper gets talked into writing outside its own directory. The charset is the
# snapshot charset -- letters, digits, dot, dash, underscore -- and everything
# else is either sanitized out or refused; either is fine, writing outside
# BACKUP_DIR is not.
rm -f "$backups"/*.json
printf '{"gapsOut":24}' >"$settings"
printf '{"initialized":true,"favorites":[]}' >"$home"
run create >/dev/null || fail 'create without a name failed'
[[ "$(run list | jq 'length')" == "1" ]] || fail 'an unnamed create did not produce one snapshot'
run create "Before the theme experiment" >/dev/null || fail 'create with a name failed'
named="$(run list | jq -r '.[0]')"
jq -e '.label == "Before the theme experiment"' <<<"$named" >/dev/null \
|| fail "a named snapshot did not carry its name back: $named"
named_file="$(jq -r .name <<<"$named")"
[[ "$named_file" =~ ^[A-Za-z0-9_.-]+$ ]] \
|| fail "a named snapshot produced the filename \"$named_file\", which is outside the snapshot charset"
# Every one of these either fails or produces a file inside the backup
# directory. None of them may produce a file anywhere else, and none may
# remove or overwrite something that is not a snapshot.
outside="$work/state/panama/NOT-A-BACKUP"
printf 'untouched\n' >"$outside"
for hostile in \
'../../../NOT-A-BACKUP' \
'/etc/panama-owned' \
'a/b' \
'..' \
'.' \
$'tab\there' \
'-rf'; do
run create "$hostile" >/dev/null 2>&1 || true
[[ "$(<"$outside")" == 'untouched' ]] \
|| fail "the snapshot name \"$hostile\" wrote outside the backup directory"
done
while read -r listed; do
[[ -n "$listed" ]] || continue
[[ "$listed" =~ ^[A-Za-z0-9_.-]+$ ]] \
|| fail "a hostile snapshot name produced the listed filename \"$listed\""
done < <(run list | jq -r '.[].name')
# ── A snapshot can be deleted, and only a snapshot ───────────────────────────
#
# Same boundary as restore, in the other direction, and with a worse failure:
# restore reading the wrong file overwrites settings, delete resolving the
# wrong name destroys something that is not a backup at all.
before_delete="$(run list | jq 'length')"
(( before_delete >= 2 )) || fail 'not enough snapshots to exercise delete'
victim="$(run list | jq -r '.[0].name')"
run delete "$victim" >/dev/null || fail 'deleting a listed snapshot failed'
[[ ! -e "$backups/$victim" ]] || fail 'a deleted snapshot is still on disk'
[[ "$(run list | jq 'length')" == "$((before_delete - 1))" ]] \
|| fail 'delete removed a different number of snapshots than one'
printf '{"pwned":false}' >"$work/delete-target.json"
for hostile in \
'../../delete-target.json' \
'/etc/passwd' \
"$victim" \
'settings-20000101-000000000.json' \
'' ; do
run delete "$hostile" >/dev/null 2>&1 \
&& fail "delete accepted \"$hostile\", which is not a snapshot in the backup directory"
done
[[ -f "$work/delete-target.json" ]] || fail 'delete followed a traversing name out of the backup directory'
escape="settings-20000101-000000009.json"
ln -s "$work/delete-target.json" "$backups/$escape"
run delete "$escape" >/dev/null 2>&1 \
&& fail 'delete accepted a symlink escaping the backup directory'
[[ -f "$work/delete-target.json" ]] || fail 'delete removed the target of an escaping symlink'
rm -f "$backups/$escape"
# ── The list says how much room a snapshot takes ─────────────────────────────
#
# Fifteen snapshots of a settings file are nothing; fifteen of a settings file
# somebody grew are not, and the page offers a Delete button now, which is a
# decision nobody can make without the size.
run list | jq -e 'all(.[]; (.bytes | type == "number") and .bytes > 0)' >/dev/null \
|| fail 'the snapshot list does not report each snapshots size'
listed_bytes="$(run list | jq -r '.[0].bytes')"
actual_bytes="$(stat -c %s "$backups/$(run list | jq -r '.[0].name')")"
[[ "$listed_bytes" == "$actual_bytes" ]] \
|| fail "the list reports $listed_bytes bytes for a snapshot that is $actual_bytes on disk"
# ── Snapshots are capped ───────────────────────────────────────────────────── # ── Snapshots are capped ─────────────────────────────────────────────────────
for _ in $(seq 1 20); do for _ in $(seq 1 20); do
printf '{"n":%s}' "$RANDOM" >"$settings" printf '{"n":%s}' "$RANDOM" >"$settings"
@@ -173,6 +173,91 @@ status="$(qs_test ipc call settings-backup-behavior status)"
jq -e '.calls == [] and (.lastError | contains("display change"))' <<<"$status" >/dev/null \ jq -e '.calls == [] and (.lastError | contains("display change"))' <<<"$status" >/dev/null \
|| fail "display-busy restore refusal was not clean: $status" || fail "display-busy restore refusal was not clean: $status"
# ── The colour half of a display layout ─────────────────────────────────────
#
# Displays persists eleven fields per output. A restore read seven of them.
#
# The four it dropped -- VRR mode, colour profile, bit depth, SDR brightness
# and saturation, plus the mirror source -- are the ones nobody notices going
# missing, because the picture is still there and it is still the right size.
# Worse, `layoutsEqual` compared the same seven, so a snapshot whose colour
# settings differed from the live state compared EQUAL: the restore took the
# early return, decided there was nothing to apply, and reported success while
# leaving HDR off. A silent no-op that says it worked is the failure mode this
# whole codebase keeps relearning, and this is it in the one place where the
# evidence is a monitor looking slightly wrong.
#
# Both halves are asserted, because fixing either alone leaves the bug: reading
# the fields without comparing them means the restore never runs; comparing
# them without reading them means it runs and applies nothing.
full='{
"DP-2": {"mode":"4500x3000@60","scale":1.5,"transform":0,"x":-2560,"y":0,"primary":false,
"vrrMode":2,"colorProfile":"hdr","bitdepth":10,"sdrBrightness":1.2,
"sdrSaturation":0.9,"mirrorOf":""},
"HDMI-A-1": {"mode":"2560x1440@60","scale":1,"transform":0,"x":0,"y":0,"primary":true,
"vrrMode":0,"colorProfile":"srgb","bitdepth":8,"sdrBrightness":1,
"sdrSaturation":1,"mirrorOf":"DP-2"}
}'
plain='{
"DP-2": {"mode":"4500x3000@60","scale":1.5,"transform":0,"x":-2560,"y":0,"primary":false},
"HDMI-A-1": {"mode":"2560x1440@60","scale":1,"transform":0,"x":0,"y":0,"primary":true}
}'
qs_test ipc call settings-backup-behavior reset >/dev/null
restored="$(qs_test ipc call settings-backup-behavior layoutFor "$(jq -c . <<<"$full")")"
jq -e '
(. != null) and (length == 2)
and (.[] | select(.name == "DP-2")
| .vrrMode == 2 and .colorProfile == "hdr" and .bitdepth == 10
and .sdrBrightness == 1.2 and .sdrSaturation == 0.9 and .mirrorOf == "")
' <<<"$restored" >/dev/null \
|| fail "a stored layout's colour fields did not survive the trip back into a live layout: $restored"
jq -e '.[] | select(.name == "HDMI-A-1") | .mirrorOf == "DP-2" and .bitdepth == 8' \
<<<"$restored" >/dev/null \
|| fail "the mirror source did not survive the trip back into a live layout: $restored"
# Back-compat, in the same shape Displays.isPersistedLayoutEntry uses: a record
# written before these fields existed is still a record. Refusing it would turn
# every snapshot anybody already has into one that cannot be restored.
legacy="$(qs_test ipc call settings-backup-behavior layoutFor "$(jq -c . <<<"$plain")")"
jq -e '(. != null) and (length == 2) and (.[] | select(.name == "DP-2") | .scale == 1.5)' \
<<<"$legacy" >/dev/null \
|| fail "a stored layout without the colour fields was refused, so older snapshots cannot be restored: $legacy"
# And the comparison. Two layouts identical but for a colour profile are not
# equal -- this is the assertion the shipped behaviour FAILS, and the reason
# the restore silently did nothing.
# The two layouts travel as one object: the IPC client turns a top-level JSON
# array into one argument per element, so passing two layouts as two arguments
# arrives as four.
compare() {
qs_test ipc call settings-backup-behavior layoutsMatch \
"$(jq -c -n --argjson left "$1" --argjson right "$2" '{left: $left, right: $right}')"
}
drifted="$(jq -c '.[0].colorProfile = "srgb"' <<<"$restored")"
[[ "$(compare "$restored" "$drifted")" == "false" ]] \
|| fail 'layoutsEqual calls two layouts equal when their colour profiles differ, so a restore that should change the picture takes the early return and reports success'
for field in vrrMode bitdepth sdrBrightness sdrSaturation mirrorOf; do
case "$field" in
mirrorOf) drifted="$(jq -c --arg f "$field" '.[0][$f] = "HDMI-A-1"' <<<"$restored")" ;;
*) drifted="$(jq -c --arg f "$field" '.[0][$f] = 7' <<<"$restored")" ;;
esac
[[ "$(compare "$restored" "$drifted")" == "false" ]] \
|| fail "layoutsEqual ignores $field, so a snapshot differing only in it restores nothing"
done
[[ "$(compare "$restored" "$restored")" == "true" ]] \
|| fail 'layoutsEqual no longer calls a layout equal to itself, which would make every restore reapply the geometry it already has'
# The tolerance on the two float fields is a tolerance, not an exemption: a
# value that came back one ulp different is the same value, a value somebody
# changed is not.
nudged="$(jq -c '.[0].sdrBrightness = 1.2000001' <<<"$restored")"
[[ "$(compare "$restored" "$nudged")" == "true" ]] \
|| fail 'a float that came back with a rounding difference is treated as a change, so every restore would reapply the layout it already has'
trap - EXIT trap - EXIT
cleanup cleanup
printf 'settings backup live contract: PASS\n' printf 'settings backup live contract: PASS\n'
+132 -3
View File
@@ -52,11 +52,33 @@ tabless_ids="$(grep -oE '\{ page: "[a-z-]+", label: "[^"]*", icon: "[^"]*", tabs
tab_ids="$(grep -oE '\{ page: "[a-z-]+", label: "[^"]*" \}' "$routes" \ tab_ids="$(grep -oE '\{ page: "[a-z-]+", label: "[^"]*" \}' "$routes" \
| sed -E 's/\{ page: "([a-z-]+)".*/\1/')" | sed -E 's/\{ page: "([a-z-]+)".*/\1/')"
# A third shape: a leaf that is reachable but is not a tab of anything. The
# manual is the only one -- it is reference material opened from About's Manual
# card and from deep links, not a control surface worth a permanent slot in the
# System strip. It still has to resolve, still has to have a SettingsShell case,
# and still has to be addressable by every caller holding its id, so it is a
# leaf for every purpose below except appearing in a tab strip.
hidden_block="$(sed -n '/property var hiddenLeaves:/,/\]/p' "$routes")"
hidden_ids="$(grep -oE 'page: "[a-z-]+"' <<<"$hidden_block" | sed -E 's/page: "([a-z-]+)"/\1/')"
[[ -n "$category_ids" ]] || fail 'no categories found in SettingsRoutes -- this contract is not reading it correctly' [[ -n "$category_ids" ]] || fail 'no categories found in SettingsRoutes -- this contract is not reading it correctly'
[[ -n "$tab_ids" ]] || fail 'no tabs found in SettingsRoutes -- this contract is not reading it correctly' [[ -n "$tab_ids" ]] || fail 'no tabs found in SettingsRoutes -- this contract is not reading it correctly'
[[ -n "$hidden_ids" ]] \
|| fail 'no hiddenLeaves found in SettingsRoutes -- the manual is a routable non-tab leaf and this contract is not reading the mechanism that makes it one'
# The leaves: every page a person can actually land on. # The leaves: every page a person can actually land on.
leaves="$(printf '%s\n%s\n' "$tabless_ids" "$tab_ids" | sed '/^$/d')" leaves="$(printf '%s\n%s\n%s\n' "$tabless_ids" "$tab_ids" "$hidden_ids" | sed '/^$/d')"
# A hidden leaf that is also a tab, or also a tabless category, puts one page
# in two places -- and unlike the tab/category overlap below, nothing visible
# would show it, because the hidden half draws no row anywhere.
while read -r page; do
[[ -n "$page" ]] || continue
grep -qx "$page" <<<"$tab_ids" \
&& fail "\"$page\" is a hidden leaf and also a tab, so the same page id names two different places"
grep -qx "$page" <<<"$tabless_ids" \
&& fail "\"$page\" is a hidden leaf and also a category of its own"
done <<<"$hidden_ids"
# ── The taxonomy addresses each leaf exactly once ───────────────────────────── # ── The taxonomy addresses each leaf exactly once ─────────────────────────────
# ShellState.settingsPage holds a leaf id, and the sidebar highlights the # ShellState.settingsPage holds a leaf id, and the sidebar highlights the
@@ -103,6 +125,50 @@ while read -r pair; do
fi fi
done <<<"$retired_pairs" done <<<"$retired_pairs"
# ── The System strip is eight tabs, and the two it lost are still reachable ──
#
# System had grown to ten tabs, which is more than a strip can show without
# becoming a second sidebar. Two left: Region & Language merged into Date &
# Time, because a date format and the clock that shows it are one subject, and
# the Manual became a hidden leaf.
#
# The count is pinned rather than derived because the number is the point: this
# is the horizontal space one row of tabs has. Anything that needs an eleventh
# subject needs a decision, not another entry.
python3 - "$routes" <<'PY' || fail 'the System category is not the approved eight-tab strip'
import re
import sys
expected = [
"about", "updates", "services", "storage",
"snapshots", "containers", "datetime", "sync",
]
text = open(sys.argv[1], encoding="utf-8").read()
block = re.search(r'\{ page: "system",.*?tabs: \[(.*?)\n \] \}', text, re.S)
if not block:
raise SystemExit("the System category could not be read")
found = re.findall(r'\{ page: "([a-z-]+)", label: "[^"]*" \}', block.group(1))
if found != expected:
raise SystemExit(f"System tabs are {found}, expected {expected}")
PY
# `region` retiring is only safe because every caller still holding it lands on
# the tab that absorbed it. The generic retired-map check above proves the
# target is a leaf; this proves it is the RIGHT leaf, which is the half a
# rename cannot get wrong quietly.
grep -qE '"region"[[:space:]]*:[[:space:]]*"datetime"' <<<"$retired_block" \
|| fail 'the retired "region" id does not resolve to "datetime", so every Vicinae command, deep link, and search result holding it lands on Home'
[[ ! -e "$settings_dir/RegionPage.qml" ]] \
|| fail 'RegionPage.qml still exists, so the retired route has a live page behind it after all'
# The manual is a leaf but not a tab. Said both ways: a strip entry would put
# reference material back in the System strip that the merge just freed, and
# losing the leaf would break About's Manual card and every deep link.
grep -qx 'manual' <<<"$hidden_ids" \
|| fail 'the manual is not a hidden leaf, so opening it from About or a deep link has no destination'
grep -qx 'manual' <<<"$tab_ids" \
&& fail 'the manual is a tab again, which is the System strip slot the consolidation just freed'
# ── Every leaf resolves everywhere ─────────────────────────────────────────── # ── Every leaf resolves everywhere ───────────────────────────────────────────
while read -r page; do while read -r page; do
[[ -n "$page" ]] || continue [[ -n "$page" ]] || continue
@@ -153,5 +219,68 @@ while read -r page_file; do
|| fail "$type_name.qml exists but nothing in SettingsShell instantiates it" || fail "$type_name.qml exists but nothing in SettingsShell instantiates it"
done < <(find "$settings_dir" -maxdepth 1 -name '*Page.qml') done < <(find "$settings_dir" -maxdepth 1 -name '*Page.qml')
printf 'settings nav contract: PASS (%d categories, %d leaves, %d retired ids)\n' \ # ── The search index lands on leaves, not on ids that used to be leaves ──────
"$(grep -c . <<<"$category_ids")" "$(grep -c . <<<"$leaves")" "$retired_count" #
# SettingsSearch is the fifth place a page id is written down, and the only one
# where being wrong is silent: `resolve()` turns anything it does not recognise
# into Home, so a result whose page id was retired still opens a window, still
# looks like it worked, and lands somewhere else. That is exactly what the two
# Region & Language entries would have done -- and they are the entries most
# likely to be searched for by somebody who could not find the setting.
#
# Checked against the leaves this file already derived, so a page consolidated
# next time cannot leave a search result pointing at its old name.
search="$repo_dir/config/dot/quickshell/services/SettingsSearch.qml"
[[ -r "$search" ]] || fail "cannot read $search"
while read -r page; do
[[ -n "$page" ]] || continue
grep -qx "$page" <<<"$leaves" && continue
if grep -qE "\"[a-z-]+\"[[:space:]]*:[[:space:]]*\"$page\"" <<<"$retired_block"; then
fail "the search index routes to \"$page\", which is a retired id -- resolve() answers with its target, so the result lands somewhere the row never named"
fi
fail "the search index routes to \"$page\", which is not a leaf, so that result silently opens Home"
done < <({
grep -oE 'page: "[a-z-]+"' "$search"
sed -n '/property var groupPages:/,/})/p' "$search" | grep -oE ': "[a-z-]+"'
} | sed -E 's/.*"([a-z-]+)".*/\1/' | sort -u)
# The subjects the consolidation moved, each findable by its own name and each
# landing on the tab that now owns it. Region & Language merged into Date, Time
# & Region, so the words people arrive with for a format have to reach it; and
# About and Sync & Backup grew rows nobody could search for at all.
while IFS='|' read -r label page; do
[[ -n "$label" ]] || continue
python3 - "$search" "$label" "$page" <<'PY' \
|| fail "the search index does not offer \"$label\" on the $page page"
import re
import sys
text, label, page = open(sys.argv[1], encoding="utf-8").read(), sys.argv[2], sys.argv[3]
pattern = rf'\{{ label: "{re.escape(label)}",[^\n]*page: "([a-z-]+)" \}}'
match = re.search(pattern, text)
if not match:
raise SystemExit(f'no entry labelled "{label}"')
if match.group(1) != page:
raise SystemExit(f'"{label}" routes to {match.group(1)}, expected {page}')
PY
done <<'SEARCHABLE'
Hostname|about
Kernel version|about
Device model|about
Installed memory|about
Uptime|about
Serial number|about
Export settings|sync
Import settings|sync
Language|datetime
Regional formats|datetime
Currency|datetime
Measurement units|datetime
Paper size|datetime
First day of the week|datetime
Manual|manual
SEARCHABLE
printf 'settings nav contract: PASS (%d categories, %d leaves of which %d hidden, %d retired ids)\n' \
"$(grep -c . <<<"$category_ids")" "$(grep -c . <<<"$leaves")" \
"$(grep -c . <<<"$hidden_ids")" "$retired_count"
+25 -2
View File
@@ -13,7 +13,13 @@ fail() {
# rebuild touched all four Connections-category pages at once: three of them had # rebuild touched all four Connections-category pages at once: three of them had
# never been checked for the page scaffold at all, and a rebuild is exactly when # never been checked for the page scaffold at all, and a rebuild is exactly when
# a hand-rolled Flickable comes back. # a hand-rolled Flickable comes back.
pages=(Home MyHome Phone Displays Connectivity Firewall Printers Sharing Bar Dock ControlCenter Tiling Workspaces Sync Sound Shortcuts Mouse Dictation Notifications Focus ScreenIntelligence Health About) # Updates, DateTime and Containers joined when the System rebuild touched all
# of them at once. Containers is the one this check was waiting for: its root
# was a bare Item, so it had never had the page scaffold at all, and every
# convention the scaffold carries -- the scroll behaviour, the header, the
# padding -- was hand-rolled there and quietly different from the other
# twenty-five pages.
pages=(Home MyHome Phone Displays Connectivity Firewall Printers Sharing Bar Dock ControlCenter Tiling Workspaces Sync Sound Shortcuts Mouse Dictation Notifications Focus ScreenIntelligence Health About Updates DateTime Containers Manual)
for page in "${pages[@]}"; do for page in "${pages[@]}"; do
page_file="$repo_dir/config/dot/quickshell/modules/settings/${page}Page.qml" page_file="$repo_dir/config/dot/quickshell/modules/settings/${page}Page.qml"
[[ -f "$page_file" ]] || fail "${page}Page.qml is missing" [[ -f "$page_file" ]] || fail "${page}Page.qml is missing"
@@ -359,7 +365,13 @@ shell_pid="$harness_pid"
# four different categories, and the page the tab strip was introduced for. # four different categories, and the page the tab strip was introduced for.
# Routing to a tab must land on that tab, not on whatever its category opens # Routing to a tab must land on that tab, not on whatever its category opens
# first, which is the failure the SettingsRoutes resolution could introduce. # first, which is the failure the SettingsRoutes resolution could introduce.
pages=(home appearance displays connectivity my-home phone bar dock control-center tiling workspaces sync sound dictation notifications focus screen-intelligence shortcuts mouse services manual about) #
# The four System leaves at the end are the ones the consolidation moved.
# `updates` and `datetime` were rebuilt, `containers` changed its root type,
# and `manual` stopped being a tab and became a hidden leaf -- which is exactly
# the kind of change that keeps a page loading fine while making it
# unreachable, so each one is opened for real here.
pages=(home appearance displays connectivity my-home phone bar dock control-center tiling workspaces sync sound dictation notifications focus screen-intelligence shortcuts mouse services manual about updates datetime containers)
for page in "${pages[@]}"; do for page in "${pages[@]}"; do
qs_for_test ipc call settings page "$page" >/dev/null qs_for_test ipc call settings page "$page" >/dev/null
for _ in $(seq 1 20); do for _ in $(seq 1 20); do
@@ -386,6 +398,17 @@ done
[[ "$(qs_for_test ipc call settings status | jq -r .page)" == "bar" ]] \ [[ "$(qs_for_test ipc call settings status | jq -r .page)" == "bar" ]] \
|| fail 'the retired "desktop" id no longer resolves to the Bar tab' || fail 'the retired "desktop" id no longer resolves to the Bar tab'
# Region & Language merged into Date, Time & Region. Its id is held by two
# Vicinae commands, the deep links in the manual, and anybody who ever typed
# it, so it has to land on the tab that absorbed it rather than on Home.
qs_for_test ipc call settings page region >/dev/null
for _ in $(seq 1 20); do
[[ "$(qs_for_test ipc call settings status | jq -r .page)" == "datetime" ]] && break
sleep 0.1
done
[[ "$(qs_for_test ipc call settings status | jq -r .page)" == "datetime" ]] \
|| fail 'the retired "region" id does not resolve to the Date, Time & Region tab'
/usr/sbin/hyprctl -j binds | jq -e '.[] | select(.description == "Settings" and .key == "I" and .modmask == 64)' >/dev/null \ /usr/sbin/hyprctl -j binds | jq -e '.[] | select(.description == "Settings" and .key == "I" and .modmask == 64)' >/dev/null \
|| fail 'Super+I is not registered as Panama Settings' || fail 'Super+I is not registered as Panama Settings'
/usr/sbin/hyprctl -j binds | jq -e '.[] | select(.description == "Screen Intelligence" and .key == "S" and .modmask == 65)' >/dev/null \ /usr/sbin/hyprctl -j binds | jq -e '.[] | select(.description == "Screen Intelligence" and .key == "S" and .modmask == 65)' >/dev/null \
+62
View File
@@ -20,6 +20,14 @@
# including every numeric enum -- were accepted unchecked, and numeric # including every numeric enum -- were accepted unchecked, and numeric
# enums were then refused outright once that was noticed. # enums were then refused outright once that was noticed.
# 5. Import is a merge. Settings the file does not mention are left alone. # 5. Import is a merge. Settings the file does not mention are left alone.
# 6. The preview says what would change, in a shape a diff list can render.
# "12 settings would change" is a number, not an answer; the page now
# shows the rows, so `changes` carries {key, from, to} with both sides
# already turned into text. Doing that stringification in the helper
# rather than in QML is what makes the cap enforceable: a value long
# enough to be something other than a setting is truncated once, here,
# instead of being handed whole to a Text element and to anybody reading
# over a shoulder.
# #
# Runs entirely against a temporary config home. The real settings store is read # Runs entirely against a temporary config home. The real settings store is read
# for the export and never written. # for the export and never written.
@@ -128,6 +136,60 @@ for key in ("gapsIn", "colorScheme", "vrrPolicy", "blurEnabled", "displays", "so
raise SystemExit(f'{key} was refused and queued for application anyway') raise SystemExit(f'{key} was refused and queued for application anyway')
PY PY
# ── 6. The preview renders as a diff, and cannot render a secret whole ──────
python3 - "$bundle" "$work/oversized.json" <<'PY'
import json, sys
bundle = json.load(open(sys.argv[1]))
# A real, free-text, non-path string setting, so this exercises a value that
# genuinely travels rather than one the validator would refuse for its own
# reasons. 4000 characters is not a location; it is somebody's paste buffer.
bundle["settings"]["weatherLocation"] = "Bearer sk-fixture-secret-" + ("x" * 4000)
json.dump(bundle, open(sys.argv[2], "w"))
PY
"$helper" preview "$work/oversized.json" >"$work/oversized-preview.json" \
|| fail 'preview failed on a bundle carrying an oversized value'
python3 - "$work/preview.json" "$work/oversized-preview.json" <<'PY' || fail 'the preview does not describe changes in a shape a diff list can render safely'
import json
import sys
CAP = 200
for path in sys.argv[1:]:
plan = json.load(open(path))
if "changes" not in plan:
raise SystemExit('the preview does not say what would change')
if "changeCount" not in plan:
raise SystemExit('the preview lists changes without saying how many there are, '
'so a capped list reads as the whole truth')
count = plan["changeCount"]
if not isinstance(count, int) or count < 0:
raise SystemExit('changeCount is not a count')
if count != len(plan["apply"]):
raise SystemExit(f'changeCount says {count} but {len(plan["apply"])} would be applied')
if len(plan["changes"]) > count:
raise SystemExit('the rendered list is longer than the number of changes')
for entry in plan["changes"]:
if set(entry) != {"key", "from", "to"}:
raise SystemExit(f'a change carries {sorted(entry)}, expected key/from/to')
for side in ("key", "from", "to"):
if not isinstance(entry[side], str):
raise SystemExit(f'{entry["key"]}.{side} is {type(entry[side]).__name__}, '
'not text a row can render')
if len(entry[side]) > CAP:
raise SystemExit(f'{entry["key"]}.{side} is {len(entry[side])} characters; '
'an uncapped value reaches the screen whole')
oversized = json.load(open(sys.argv[2]))
rendered = json.dumps(oversized["changes"])
if "x" * (CAP + 1) in rendered:
raise SystemExit('an oversized value was reproduced in full in the change list')
PY
# A clean bundle must arrive intact. Refusing valid settings is the failure this # A clean bundle must arrive intact. Refusing valid settings is the failure this
# contract exists to catch as much as accepting invalid ones -- fixing the enum # contract exists to catch as much as accepting invalid ones -- fixing the enum
# check the first time turned every numeric enum into a rejection. # check the first time turned every numeric enum into a rejection.
+145
View File
@@ -138,6 +138,151 @@ if not any(e['source'] == 'flatpak' for e in entries) and not any(e['source'] ==
raise SystemExit('neither source produced anything, so nothing was parsed') raise SystemExit('neither source produced anything, so nothing was parsed')
" || fail 'the update history is not usable' " || fail 'the update history is not usable'
# ── 5. A download size is the whole download, or it is not offered ──────────
#
# dnf5 prices packages from repository metadata, flatpak renders "36.2 MB" and
# has no machine-readable column at all, and either can come back short. A
# figure that covers some of what is pending, shown as the download, understates
# it -- and understating it is the direction that costs somebody money on a
# metered connection. So the total is present only when every item was priced.
printf '%s' "$state" | python3 -c "
import json, sys
state = json.load(sys.stdin)
for name, key in ((\"dnf\", \"packages\"), (\"flatpak\", \"applications\")):
source = state.get(name, {})
items = source.get(key, [])
if \"downloadBytes\" not in source:
continue
if not items:
raise SystemExit(name + \" priced an empty list\")
unpriced = [item for item in items if \"bytes\" not in item]
if unpriced:
raise SystemExit(name + \" reports a total while items have no size\")
total = sum(int(item[\"bytes\"]) for item in items)
if int(source[\"downloadBytes\"]) != total:
raise SystemExit(name + \" totals \" + str(source[\"downloadBytes\"]) + \" for items summing to \" + str(total))
" || fail 'a reported download size does not add up to the items it is a size for'
# ── 6. A changelog is a read ────────────────────────────────────────────────
#
# This verb takes a package name from a settings page and hands it to dnf, which
# is the one place in this helper where the page names the subject. Two things
# have to hold and neither is visible from reading the happy path: the name is
# constrained before it reaches argv, and nothing on this path installs, removes
# or upgrades anything.
#
# Proved by construction rather than by inspection: dnf5, flatpak and pkexec are
# replaced with stubs that record their argv and produce nothing, so whatever
# the helper decides to run is written down and checked afterwards. A stub that
# answers nothing also exercises the honest-absence path, which is the common
# case on a machine with third-party repositories.
changelog_work="$(mktemp -d /tmp/panama-updates-changelog.XXXXXX)"
trap 'rm -rf "$changelog_work"' EXIT
changelog_bin="$changelog_work/bin"
changelog_log="$changelog_work/argv.log"
mkdir -p "$changelog_bin" "$changelog_work/cache/panama"
for tool in dnf5 flatpak fwupdmgr pkexec systemctl; do
cat >"$changelog_bin/$tool" <<EOF
#!/usr/bin/env bash
printf '%s\t%s\n' "\${0##*/}" "\$*" >>"$changelog_log"
exit 1
EOF
chmod +x "$changelog_bin/$tool"
done
run_changelog() {
PATH="$changelog_bin:$PATH" XDG_CACHE_HOME="$changelog_work/cache" "$helper" "$@"
}
: >"$changelog_log"
answer="$(run_changelog changelog dnf zsh)" || fail 'the changelog verb failed'
jq -e '.source == "dnf" and .name == "zsh" and .error == ""
and (.kind | IN("advisory", "changelog", "none"))
and (.text | type == "string")' <<<"$answer" >/dev/null \
|| fail "a changelog answered in an unusable shape: $answer"
[[ "$(jq -r .kind <<<"$answer")" == "none" ]] \
|| fail "a source that produced nothing was not reported as having no changelog: $answer"
[[ -n "$(jq -r .text <<<"$answer")" ]] \
|| fail 'a package with no changelog says nothing at all, which reads as a failure to load'
run_changelog changelog flatpak org.example.Fixture >/dev/null \
|| fail 'the flatpak changelog verb failed'
run_changelog changelog firmware FixtureDevice >/dev/null \
|| fail 'the firmware changelog verb failed'
python3 - "$changelog_log" <<'PY' || fail 'reading a changelog runs something that changes this machine'
import sys
mutating = {
"install", "remove", "erase", "upgrade", "update", "reinstall", "downgrade",
"autoremove", "distro-sync", "swap", "-y", "--assumeyes", "--noninteractive",
}
lines = [line.rstrip("\n") for line in open(sys.argv[1], encoding="utf-8") if line.strip()]
if not lines:
raise SystemExit("no commands were recorded, so this proves nothing")
for line in lines:
executable, _, arguments = line.partition("\t")
if executable == "pkexec":
raise SystemExit("a changelog asked for privilege")
for token in arguments.split():
if token in mutating:
raise SystemExit(f"{executable} was run with {token!r} while reading a changelog")
PY
# A name the machine would not have produced never reaches argv.
: >"$changelog_log"
for hostile in '../../etc/passwd' '/etc/passwd' 'zsh; rm -rf /' '' '-rf'; do
refusal="$(run_changelog changelog dnf "$hostile" 2>/dev/null)" \
|| fail "the changelog verb crashed on \"$hostile\" instead of refusing it"
[[ -n "$(jq -r '.error // ""' <<<"$refusal")" ]] \
|| fail "the changelog verb accepted the name \"$hostile\""
done
[[ ! -s "$changelog_log" ]] || fail "a refused changelog name still started a process: $(<"$changelog_log")"
[[ -n "$(run_changelog changelog nonsense zsh | jq -r '.error // ""')" ]] \
|| fail 'an unknown changelog source was accepted'
# ── 7. One application, updated by name ─────────────────────────────────────
#
# `apply flatpak` updated everything, so the only way to take one application's
# update was to take all of them -- including the 900 MB one nobody asked about.
# The per-application verb appends exactly one ID to the same command, and the
# ID is checked against the last scan rather than trusted: the page is not the
# authority on what is pending, and this is the only verb that takes a name
# from it.
printf '%s' '{"flatpak":{"available":true,"count":1,"applications":[{"id":"org.example.Fixture","version":"1.0","origin":"flathub"}]}}' \
>"$changelog_work/cache/panama/updates.json"
: >"$changelog_log"
run_changelog apply flatpak org.example.Fixture >/dev/null \
|| fail 'a per-application update failed to answer'
# Only the flatpak lines: the refusal path re-reads the snapshot afterwards,
# which asks systemctl about the two unattended-update timers.
[[ "$(grep -c $'^flatpak\t' "$changelog_log")" == "1" ]] \
|| fail "a per-application update ran flatpak more than once: $(<"$changelog_log")"
grep -Fxq "$(printf 'flatpak\tupdate -y --noninteractive org.example.Fixture')" "$changelog_log" \
|| fail "the per-application argv was not exact: $(<"$changelog_log")"
: >"$changelog_log"
refusal="$(run_changelog apply flatpak org.example.NotPending)"
[[ -n "$(jq -r '.error // ""' <<<"$refusal")" ]] \
|| fail 'an application with no pending update was accepted'
! grep -q $'^flatpak\t' "$changelog_log" \
|| fail "a refused per-application update still ran flatpak: $(<"$changelog_log")"
[[ -n "$(run_changelog apply dnf somepackage | jq -r '.error // ""')" ]] \
|| fail 'a per-item target was accepted for a source that cannot take one'
# ── 8. The service offers all three, and the stale comment is gone ──────────
for api in 'function changelogFor(source: string, name: string): var' \
'function applyFlatpakApp(id: string): void' \
'readonly property string downloadSize'; do
grep -Fq "$api" "$service" || fail "the Updates service does not expose: $api"
done
grep -Fq 'dnf-automatic is not installed' "$helper" \
&& fail 'panama-updates still says dnf-automatic is not installed, which stopped being true when set-auto-dnf landed'
grep -q 'def set_auto_dnf' "$helper" \
|| fail 'automatic package updates are reported but cannot be turned on'
printf 'updates contract: PASS (%s dnf, %s flatpak, %s firmware; reboot needed: %s)\n' \ printf 'updates contract: PASS (%s dnf, %s flatpak, %s firmware; reboot needed: %s)\n' \
"$(jq -r '.dnf.count // 0' <<<"$state")" \ "$(jq -r '.dnf.count // 0' <<<"$state")" \
"$(jq -r '.flatpak.count // 0' <<<"$state")" \ "$(jq -r '.flatpak.count // 0' <<<"$state")" \