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:
@@ -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 {
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
|
||||||
|
label: modelData.label
|
||||||
|
value: modelData.value
|
||||||
|
divider: index < root.versionRows.length - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hardware ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
readonly property var hardwareRows: [
|
||||||
|
{ label: "Model", value: root.fact("Model") },
|
||||||
|
{ label: "Processor", value: root.fact("Processor") }
|
||||||
|
].concat(root.graphicsRows, [
|
||||||
|
{ label: "Memory", value: root.joined([
|
||||||
|
root.fact("Memory"),
|
||||||
|
root.fact("Swap") === "" ? "" : root.fact("Swap") + " swap"
|
||||||
|
]) },
|
||||||
|
{ label: "Disk", value: root.fact("Disk") },
|
||||||
|
{ label: "Display", value: root.displayValue }
|
||||||
|
]).filter(row => String(row.value ?? "") !== "")
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Hardware"
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.hardwareRows
|
||||||
|
|
||||||
|
TextRow {
|
||||||
|
required property var modelData
|
||||||
|
|
||||||
|
label: modelData.label
|
||||||
|
value: modelData.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
TextRow {
|
TextRow {
|
||||||
label: "Hyprland"
|
visible: MachineInfo.scanned && root.hardwareRows.length === 0
|
||||||
value: SystemSettings.hyprlandVersion || "Detecting…"
|
label: "Hardware"
|
||||||
|
detail: "The system did not report anything readable"
|
||||||
|
value: "Unavailable"
|
||||||
}
|
}
|
||||||
TextRow {
|
|
||||||
label: "Quickshell"
|
// The machine's name is a network-facing setting rather than a fact
|
||||||
value: SystemSettings.quickshellVersion
|
// about the hardware, and Sharing is the page that owns it. Saying so
|
||||||
|
// here is cheaper than a second field that writes the same hostname.
|
||||||
|
ActionRow {
|
||||||
|
label: "Device name"
|
||||||
|
detail: root.hostname === ""
|
||||||
|
? "What this machine calls itself on the network"
|
||||||
|
: root.hostname + " — what this machine calls itself on the network"
|
||||||
|
action: "Open Sharing"
|
||||||
|
divider: false
|
||||||
|
onTriggered: ShellState.openSettings("sharing")
|
||||||
}
|
}
|
||||||
TextRow {
|
}
|
||||||
label: "Display"
|
|
||||||
value: SystemSettings.monitorName || "Detecting…"
|
// ── Software ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Software"
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.otherFacts
|
||||||
|
|
||||||
|
TextRow {
|
||||||
|
required property var modelData
|
||||||
|
|
||||||
|
label: String(modelData.label ?? "")
|
||||||
|
value: String(modelData.value ?? "")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
TextRow {
|
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,371 +65,269 @@ Item {
|
|||||||
|
|
||||||
// ── the list ────────────────────────────────────────────────────────────
|
// ── the list ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
SettingsPage {
|
TextRow {
|
||||||
anchors.fill: parent
|
visible: Containers.lastError !== ""
|
||||||
visible: Containers.logTarget === ""
|
label: "That did not work"
|
||||||
|
detail: Containers.lastError
|
||||||
|
value: ""
|
||||||
|
divider: false
|
||||||
|
}
|
||||||
|
|
||||||
title: "Containers"
|
TextRow {
|
||||||
lede: "Local services you run for development — what they expose, and what they cost."
|
visible: Containers.scanned && !Containers.available
|
||||||
|
label: "podman is not available"
|
||||||
|
detail: "Nothing here can be shown until podman is installed."
|
||||||
|
value: ""
|
||||||
|
divider: false
|
||||||
|
}
|
||||||
|
|
||||||
TextRow {
|
// ── finding: published to the network ───────────────────────────────
|
||||||
visible: Containers.lastError !== ""
|
|
||||||
label: "That did not work"
|
|
||||||
detail: Containers.lastError
|
|
||||||
value: ""
|
|
||||||
divider: false
|
|
||||||
}
|
|
||||||
|
|
||||||
TextRow {
|
SettingsCard {
|
||||||
visible: Containers.scanned && !Containers.available
|
visible: Containers.reachable.length > 0
|
||||||
label: "podman is not available"
|
title: Containers.reachable.length === 1
|
||||||
detail: "Nothing here can be shown until podman is installed."
|
? "A container is published to your whole network"
|
||||||
value: ""
|
: "Containers are published to your whole network"
|
||||||
divider: false
|
subtitle: "These bind every interface, so any machine on your network can connect. "
|
||||||
}
|
+ "For a development database, loopback is almost always what you want. "
|
||||||
|
+ "Binding adds an address to the compose file and changes nothing else."
|
||||||
// ── finding: published to the network ───────────────────────────────
|
|
||||||
|
|
||||||
SettingsCard {
|
|
||||||
visible: Containers.reachable.length > 0
|
|
||||||
title: Containers.reachable.length === 1
|
|
||||||
? "A container is published to your whole network"
|
|
||||||
: "Containers are published to your whole network"
|
|
||||||
subtitle: "These bind every interface, so any machine on your network can connect. "
|
|
||||||
+ "For a development database, loopback is almost always what you want. "
|
|
||||||
+ "Binding adds an address to the compose file and changes nothing else."
|
|
||||||
|
|
||||||
Repeater {
|
|
||||||
model: Containers.reachable
|
|
||||||
|
|
||||||
delegate: SettingRow {
|
|
||||||
required property var modelData
|
|
||||||
width: parent.width
|
|
||||||
label: String(modelData.container ?? "")
|
|
||||||
detail: "Port " + modelData.hostPort + "/" + String(modelData.protocol ?? "tcp")
|
|
||||||
+ " · " + String(modelData.image ?? "")
|
|
||||||
+ (modelData.configFile
|
|
||||||
? "\n" + Containers.shorten(String(modelData.configFile))
|
|
||||||
: "\nNo compose file is recorded for this container, so it cannot be bound from here.")
|
|
||||||
controlWidth: 250
|
|
||||||
|
|
||||||
Row {
|
|
||||||
anchors.right: parent.right
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
spacing: 8
|
|
||||||
|
|
||||||
SettingsButton {
|
|
||||||
text: "Open compose file"
|
|
||||||
visible: String(modelData.configFile ?? "") !== ""
|
|
||||||
enabled: !Containers.busy
|
|
||||||
onClicked: Containers.openFile(String(modelData.configFile))
|
|
||||||
}
|
|
||||||
|
|
||||||
SettingsButton {
|
|
||||||
text: "Bind to localhost"
|
|
||||||
tone: "accent"
|
|
||||||
// Needs both halves of the label to find the entry in
|
|
||||||
// the compose file; the Supabase CLI records neither.
|
|
||||||
visible: String(modelData.configFile ?? "") !== ""
|
|
||||||
&& String(modelData.service ?? "") !== ""
|
|
||||||
enabled: !Containers.busy
|
|
||||||
onClicked: Containers.bindLocal(
|
|
||||||
String(modelData.project), String(modelData.service))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
TextRow {
|
|
||||||
visible: Containers.reachable.length > 0
|
|
||||||
label: "After binding"
|
|
||||||
detail: "The change takes effect the next time the stack comes up, because a "
|
|
||||||
+ "published port is fixed when the container is created."
|
|
||||||
value: ""
|
|
||||||
divider: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── finding: disk nothing references ────────────────────────────────
|
|
||||||
|
|
||||||
SettingsCard {
|
|
||||||
visible: Containers.unusedImages.length > 0 || Containers.unusedVolumes.length > 0
|
|
||||||
title: Containers.formatBytes(Containers.reclaimable) + " nothing is using"
|
|
||||||
subtitle: "Images and volumes no container references. Removing them frees the space; "
|
|
||||||
+ "anything still needed downloads again on next use."
|
|
||||||
|
|
||||||
Repeater {
|
|
||||||
model: Containers.unusedImages.slice(0, 5)
|
|
||||||
|
|
||||||
delegate: TextRow {
|
|
||||||
required property var modelData
|
|
||||||
width: parent.width
|
|
||||||
label: String(modelData.name ?? "")
|
|
||||||
detail: ""
|
|
||||||
value: Containers.formatBytes(Number(modelData.size ?? 0))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
TextRow {
|
|
||||||
visible: Containers.unusedImages.length > 5
|
|
||||||
label: "and " + (Containers.unusedImages.length - 5) + " more"
|
|
||||||
detail: ""
|
|
||||||
value: ""
|
|
||||||
}
|
|
||||||
|
|
||||||
SettingRow {
|
|
||||||
width: parent.width
|
|
||||||
label: root.confirmingPrune === "images"
|
|
||||||
? "Remove " + Containers.unusedImages.length + " images?"
|
|
||||||
: "Unused images"
|
|
||||||
detail: root.confirmingPrune === "images"
|
|
||||||
? "Each is removed by name. Nothing that a container references is touched."
|
|
||||||
: Containers.unusedImages.length + " images, "
|
|
||||||
+ Containers.formatBytes(Number(Containers.disk.imagesReclaimable ?? 0))
|
|
||||||
visible: Containers.unusedImages.length > 0
|
|
||||||
controlWidth: 230
|
|
||||||
|
|
||||||
Row {
|
|
||||||
anchors.right: parent.right
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
spacing: 8
|
|
||||||
|
|
||||||
SettingsButton {
|
|
||||||
text: root.confirmingPrune === "images" ? "Keep them" : "Remove…"
|
|
||||||
enabled: !Containers.busy
|
|
||||||
onClicked: root.confirmingPrune =
|
|
||||||
root.confirmingPrune === "images" ? "" : "images"
|
|
||||||
}
|
|
||||||
|
|
||||||
SettingsButton {
|
|
||||||
visible: root.confirmingPrune === "images"
|
|
||||||
text: "Remove them"
|
|
||||||
tone: "danger"
|
|
||||||
enabled: !Containers.busy
|
|
||||||
onClicked: {
|
|
||||||
root.confirmingPrune = "";
|
|
||||||
Containers.pruneImages();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SettingRow {
|
|
||||||
width: parent.width
|
|
||||||
visible: Containers.unusedVolumes.length > 0
|
|
||||||
label: root.confirmingPrune === "volumes"
|
|
||||||
? "Remove " + Containers.unusedVolumes.length + " volumes?"
|
|
||||||
: "Unused volumes"
|
|
||||||
detail: root.confirmingPrune === "volumes"
|
|
||||||
? "A volume holds data. These are the ones podman reports as referenced by "
|
|
||||||
+ "nothing, but removing them cannot be undone."
|
|
||||||
: Containers.unusedVolumes.length + " volumes, "
|
|
||||||
+ Containers.formatBytes(Number(Containers.disk.volumesReclaimable ?? 0))
|
|
||||||
controlWidth: 230
|
|
||||||
divider: false
|
|
||||||
|
|
||||||
Row {
|
|
||||||
anchors.right: parent.right
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
spacing: 8
|
|
||||||
|
|
||||||
SettingsButton {
|
|
||||||
text: root.confirmingPrune === "volumes" ? "Keep them" : "Remove…"
|
|
||||||
enabled: !Containers.busy
|
|
||||||
onClicked: root.confirmingPrune =
|
|
||||||
root.confirmingPrune === "volumes" ? "" : "volumes"
|
|
||||||
}
|
|
||||||
|
|
||||||
SettingsButton {
|
|
||||||
visible: root.confirmingPrune === "volumes"
|
|
||||||
text: "Remove them"
|
|
||||||
tone: "danger"
|
|
||||||
enabled: !Containers.busy
|
|
||||||
onClicked: {
|
|
||||||
root.confirmingPrune = "";
|
|
||||||
Containers.pruneVolumes();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── the stacks ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
Repeater {
|
Repeater {
|
||||||
model: Containers.projects
|
model: Containers.reachable
|
||||||
|
|
||||||
delegate: SettingsCard {
|
|
||||||
id: projectCard
|
|
||||||
|
|
||||||
|
delegate: SettingRow {
|
||||||
required property var modelData
|
required property var modelData
|
||||||
|
width: parent.width
|
||||||
|
label: String(modelData.container ?? "")
|
||||||
|
detail: "Port " + modelData.hostPort + "/" + String(modelData.protocol ?? "tcp")
|
||||||
|
+ " · " + String(modelData.image ?? "")
|
||||||
|
+ (modelData.configFile
|
||||||
|
? "\n" + Containers.shorten(String(modelData.configFile))
|
||||||
|
: "\nNo compose file is recorded for this container, so it cannot be bound from here.")
|
||||||
|
controlWidth: 250
|
||||||
|
|
||||||
readonly property var runningSet:
|
Row {
|
||||||
(projectCard.modelData.containers ?? []).filter(c => c.state === "running")
|
anchors.right: parent.right
|
||||||
readonly property var stoppedSet:
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
(projectCard.modelData.containers ?? []).filter(c => c.state !== "running")
|
spacing: 8
|
||||||
readonly property string projectName: String(projectCard.modelData.name ?? "")
|
|
||||||
|
|
||||||
title: String(projectCard.modelData.title ?? "")
|
SettingsButton {
|
||||||
subtitle: {
|
text: "Open compose file"
|
||||||
const total = Number(projectCard.modelData.total ?? 0);
|
visible: String(modelData.configFile ?? "") !== ""
|
||||||
const up = Number(projectCard.modelData.running ?? 0);
|
enabled: !Containers.busy
|
||||||
const where = String(projectCard.modelData.configFile ?? "");
|
onClicked: Containers.openFile(String(modelData.configFile))
|
||||||
const counts = up === 0
|
|
||||||
? total + (total === 1 ? " container, stopped" : " containers, all stopped")
|
|
||||||
: up + " of " + total + " running";
|
|
||||||
return where === "" ? counts : counts + " · " + Containers.shorten(where);
|
|
||||||
}
|
|
||||||
|
|
||||||
SettingRow {
|
|
||||||
width: parent.width
|
|
||||||
label: "The whole stack"
|
|
||||||
detail: projectCard.runningSet.length === 0
|
|
||||||
? "Starts every container this project defines."
|
|
||||||
: "Stopping leaves the containers in place; nothing is removed."
|
|
||||||
controlWidth: 250
|
|
||||||
divider: projectCard.runningSet.length > 0
|
|
||||||
|| projectCard.stoppedSet.length > 0
|
|
||||||
|
|
||||||
Row {
|
|
||||||
anchors.right: parent.right
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
spacing: 8
|
|
||||||
|
|
||||||
SettingsButton {
|
|
||||||
text: "Start all"
|
|
||||||
visible: projectCard.stoppedSet.length > 0
|
|
||||||
enabled: !Containers.busy
|
|
||||||
onClicked: Containers.startProject(projectCard.projectName)
|
|
||||||
}
|
|
||||||
|
|
||||||
SettingsButton {
|
|
||||||
text: "Restart all"
|
|
||||||
visible: projectCard.runningSet.length > 0
|
|
||||||
enabled: !Containers.busy
|
|
||||||
onClicked: Containers.restartProject(projectCard.projectName)
|
|
||||||
}
|
|
||||||
|
|
||||||
SettingsButton {
|
|
||||||
text: "Stop all"
|
|
||||||
visible: projectCard.runningSet.length > 0
|
|
||||||
enabled: !Containers.busy
|
|
||||||
onClicked: Containers.stopProject(projectCard.projectName)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Repeater {
|
SettingsButton {
|
||||||
model: projectCard.runningSet
|
text: "Bind to localhost"
|
||||||
|
tone: "accent"
|
||||||
delegate: SettingRow {
|
// Needs both halves of the label to find the entry in
|
||||||
required property var modelData
|
// the compose file; the Supabase CLI records neither.
|
||||||
width: parent.width
|
visible: String(modelData.configFile ?? "") !== ""
|
||||||
label: String(modelData.name ?? "")
|
&& String(modelData.service ?? "") !== ""
|
||||||
detail: String(modelData.image ?? "") + " · " + root.portSummary(modelData)
|
enabled: !Containers.busy
|
||||||
value: {
|
onClicked: Containers.bindLocal(
|
||||||
const health = String(modelData.health ?? "");
|
String(modelData.project), String(modelData.service))
|
||||||
const up = root.uptimeOf(modelData);
|
|
||||||
if (health !== "" && up !== "")
|
|
||||||
return health + " · " + up;
|
|
||||||
return health !== "" ? health : up;
|
|
||||||
}
|
|
||||||
controlWidth: 250
|
|
||||||
|
|
||||||
Row {
|
|
||||||
anchors.right: parent.right
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
spacing: 8
|
|
||||||
|
|
||||||
SettingsButton {
|
|
||||||
text: "Logs"
|
|
||||||
onClicked: Containers.openLogs(String(modelData.name))
|
|
||||||
}
|
|
||||||
|
|
||||||
SettingsButton {
|
|
||||||
text: "Restart"
|
|
||||||
enabled: !Containers.busy
|
|
||||||
onClicked: Containers.restart(String(modelData.name))
|
|
||||||
}
|
|
||||||
|
|
||||||
SettingsButton {
|
|
||||||
text: "Stop"
|
|
||||||
enabled: !Containers.busy
|
|
||||||
onClicked: Containers.stop(String(modelData.name))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SettingRow {
|
|
||||||
width: parent.width
|
|
||||||
visible: projectCard.stoppedSet.length > 0
|
|
||||||
activatable: true
|
|
||||||
divider: root.isExpanded(projectCard.projectName)
|
|
||||||
label: (root.isExpanded(projectCard.projectName) ? "▾ " : "▸ ")
|
|
||||||
+ projectCard.stoppedSet.length
|
|
||||||
+ (projectCard.stoppedSet.length === 1
|
|
||||||
? " stopped container" : " stopped containers")
|
|
||||||
detail: {
|
|
||||||
const bad = projectCard.stoppedSet.filter(c => Number(c.exitCode ?? 0) !== 0);
|
|
||||||
return bad.length === 0
|
|
||||||
? ""
|
|
||||||
: bad.length + (bad.length === 1 ? " exited" : " exited") + " badly.";
|
|
||||||
}
|
|
||||||
onActivated: root.toggleExpanded(projectCard.projectName)
|
|
||||||
}
|
|
||||||
|
|
||||||
Repeater {
|
|
||||||
model: root.isExpanded(projectCard.projectName) ? projectCard.stoppedSet : []
|
|
||||||
|
|
||||||
delegate: SettingRow {
|
|
||||||
required property var modelData
|
|
||||||
required property int index
|
|
||||||
width: parent.width
|
|
||||||
label: String(modelData.name ?? "")
|
|
||||||
detail: String(modelData.image ?? "") + " · " + String(modelData.status ?? "")
|
|
||||||
controlWidth: 180
|
|
||||||
divider: index < projectCard.stoppedSet.length - 1
|
|
||||||
|
|
||||||
Row {
|
|
||||||
anchors.right: parent.right
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
spacing: 8
|
|
||||||
|
|
||||||
SettingsButton {
|
|
||||||
text: "Logs"
|
|
||||||
onClicked: Containers.openLogs(String(modelData.name))
|
|
||||||
}
|
|
||||||
|
|
||||||
SettingsButton {
|
|
||||||
text: "Start"
|
|
||||||
enabled: !Containers.busy
|
|
||||||
onClicked: Containers.start(String(modelData.name))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── containers no compose project claims ────────────────────────────
|
TextRow {
|
||||||
|
visible: Containers.reachable.length > 0
|
||||||
|
label: "After binding"
|
||||||
|
detail: "The change takes effect the next time the stack comes up, because a "
|
||||||
|
+ "published port is fixed when the container is created."
|
||||||
|
value: ""
|
||||||
|
divider: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
SettingsCard {
|
// ── finding: disk nothing references ────────────────────────────────
|
||||||
visible: Containers.loose.length > 0
|
|
||||||
title: "Not part of a project"
|
SettingsCard {
|
||||||
subtitle: "Started directly rather than by a compose file."
|
visible: Containers.unusedImages.length > 0 || Containers.unusedVolumes.length > 0
|
||||||
|
title: Containers.formatBytes(Containers.reclaimable) + " nothing is using"
|
||||||
|
subtitle: "Images and volumes no container references. Removing them frees the space; "
|
||||||
|
+ "anything still needed downloads again on next use."
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: Containers.unusedImages.slice(0, 5)
|
||||||
|
|
||||||
|
delegate: TextRow {
|
||||||
|
required property var modelData
|
||||||
|
width: parent.width
|
||||||
|
label: String(modelData.name ?? "")
|
||||||
|
detail: ""
|
||||||
|
value: Containers.formatBytes(Number(modelData.size ?? 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TextRow {
|
||||||
|
visible: Containers.unusedImages.length > 5
|
||||||
|
label: "and " + (Containers.unusedImages.length - 5) + " more"
|
||||||
|
detail: ""
|
||||||
|
value: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
width: parent.width
|
||||||
|
label: root.confirmingPrune === "images"
|
||||||
|
? "Remove " + Containers.unusedImages.length + " images?"
|
||||||
|
: "Unused images"
|
||||||
|
detail: root.confirmingPrune === "images"
|
||||||
|
? "Each is removed by name. Nothing that a container references is touched."
|
||||||
|
: Containers.unusedImages.length + " images, "
|
||||||
|
+ Containers.formatBytes(Number(Containers.disk.imagesReclaimable ?? 0))
|
||||||
|
visible: Containers.unusedImages.length > 0
|
||||||
|
controlWidth: 230
|
||||||
|
|
||||||
|
Row {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
text: root.confirmingPrune === "images" ? "Keep them" : "Remove…"
|
||||||
|
enabled: !Containers.busy
|
||||||
|
onClicked: root.confirmingPrune =
|
||||||
|
root.confirmingPrune === "images" ? "" : "images"
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
visible: root.confirmingPrune === "images"
|
||||||
|
text: "Remove them"
|
||||||
|
tone: "danger"
|
||||||
|
enabled: !Containers.busy
|
||||||
|
onClicked: {
|
||||||
|
root.confirmingPrune = "";
|
||||||
|
Containers.pruneImages();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
width: parent.width
|
||||||
|
visible: Containers.unusedVolumes.length > 0
|
||||||
|
label: root.confirmingPrune === "volumes"
|
||||||
|
? "Remove " + Containers.unusedVolumes.length + " volumes?"
|
||||||
|
: "Unused volumes"
|
||||||
|
detail: root.confirmingPrune === "volumes"
|
||||||
|
? "A volume holds data. These are the ones podman reports as referenced by "
|
||||||
|
+ "nothing, but removing them cannot be undone."
|
||||||
|
: Containers.unusedVolumes.length + " volumes, "
|
||||||
|
+ Containers.formatBytes(Number(Containers.disk.volumesReclaimable ?? 0))
|
||||||
|
controlWidth: 230
|
||||||
|
divider: false
|
||||||
|
|
||||||
|
Row {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
text: root.confirmingPrune === "volumes" ? "Keep them" : "Remove…"
|
||||||
|
enabled: !Containers.busy
|
||||||
|
onClicked: root.confirmingPrune =
|
||||||
|
root.confirmingPrune === "volumes" ? "" : "volumes"
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
visible: root.confirmingPrune === "volumes"
|
||||||
|
text: "Remove them"
|
||||||
|
tone: "danger"
|
||||||
|
enabled: !Containers.busy
|
||||||
|
onClicked: {
|
||||||
|
root.confirmingPrune = "";
|
||||||
|
Containers.pruneVolumes();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the stacks ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: Containers.projects
|
||||||
|
|
||||||
|
delegate: SettingsCard {
|
||||||
|
id: projectCard
|
||||||
|
|
||||||
|
required property var modelData
|
||||||
|
|
||||||
|
readonly property var runningSet:
|
||||||
|
(projectCard.modelData.containers ?? []).filter(c => c.state === "running")
|
||||||
|
readonly property var stoppedSet:
|
||||||
|
(projectCard.modelData.containers ?? []).filter(c => c.state !== "running")
|
||||||
|
readonly property string projectName: String(projectCard.modelData.name ?? "")
|
||||||
|
|
||||||
|
title: String(projectCard.modelData.title ?? "")
|
||||||
|
subtitle: {
|
||||||
|
const total = Number(projectCard.modelData.total ?? 0);
|
||||||
|
const up = Number(projectCard.modelData.running ?? 0);
|
||||||
|
const where = String(projectCard.modelData.configFile ?? "");
|
||||||
|
const counts = up === 0
|
||||||
|
? total + (total === 1 ? " container, stopped" : " containers, all stopped")
|
||||||
|
: up + " of " + total + " running";
|
||||||
|
return where === "" ? counts : counts + " · " + Containers.shorten(where);
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
width: parent.width
|
||||||
|
label: "The whole stack"
|
||||||
|
detail: projectCard.runningSet.length === 0
|
||||||
|
? "Starts every container this project defines."
|
||||||
|
: "Stopping leaves the containers in place; nothing is removed."
|
||||||
|
controlWidth: 250
|
||||||
|
divider: projectCard.runningSet.length > 0
|
||||||
|
|| projectCard.stoppedSet.length > 0
|
||||||
|
|
||||||
|
Row {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
text: "Start all"
|
||||||
|
visible: projectCard.stoppedSet.length > 0
|
||||||
|
enabled: !Containers.busy
|
||||||
|
onClicked: Containers.startProject(projectCard.projectName)
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
text: "Restart all"
|
||||||
|
visible: projectCard.runningSet.length > 0
|
||||||
|
enabled: !Containers.busy
|
||||||
|
onClicked: Containers.restartProject(projectCard.projectName)
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
text: "Stop all"
|
||||||
|
visible: projectCard.runningSet.length > 0
|
||||||
|
enabled: !Containers.busy
|
||||||
|
onClicked: Containers.stopProject(projectCard.projectName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Repeater {
|
Repeater {
|
||||||
model: Containers.loose
|
model: projectCard.runningSet
|
||||||
|
|
||||||
delegate: SettingRow {
|
delegate: SettingRow {
|
||||||
required property var modelData
|
required property var modelData
|
||||||
required property int index
|
|
||||||
width: parent.width
|
width: parent.width
|
||||||
label: String(modelData.name ?? "")
|
label: String(modelData.name ?? "")
|
||||||
detail: String(modelData.image ?? "") + " · " + String(modelData.status ?? "")
|
detail: String(modelData.image ?? "") + " · " + root.portSummary(modelData)
|
||||||
controlWidth: 180
|
value: {
|
||||||
divider: index < Containers.loose.length - 1
|
const health = String(modelData.health ?? "");
|
||||||
|
const up = root.uptimeOf(modelData);
|
||||||
|
if (health !== "" && up !== "")
|
||||||
|
return health + " · " + up;
|
||||||
|
return health !== "" ? health : up;
|
||||||
|
}
|
||||||
|
controlWidth: 250
|
||||||
|
|
||||||
Row {
|
Row {
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
@@ -435,122 +340,217 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
SettingsButton {
|
SettingsButton {
|
||||||
text: modelData.state === "running" ? "Stop" : "Start"
|
text: "Restart"
|
||||||
enabled: !Containers.busy
|
enabled: !Containers.busy
|
||||||
onClicked: modelData.state === "running"
|
onClicked: Containers.restart(String(modelData.name))
|
||||||
? Containers.stop(String(modelData.name))
|
}
|
||||||
: Containers.start(String(modelData.name))
|
|
||||||
|
SettingsButton {
|
||||||
|
text: "Stop"
|
||||||
|
enabled: !Containers.busy
|
||||||
|
onClicked: Containers.stop(String(modelData.name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
width: parent.width
|
||||||
|
visible: projectCard.stoppedSet.length > 0
|
||||||
|
activatable: true
|
||||||
|
divider: root.isExpanded(projectCard.projectName)
|
||||||
|
label: (root.isExpanded(projectCard.projectName) ? "▾ " : "▸ ")
|
||||||
|
+ projectCard.stoppedSet.length
|
||||||
|
+ (projectCard.stoppedSet.length === 1
|
||||||
|
? " stopped container" : " stopped containers")
|
||||||
|
detail: {
|
||||||
|
const bad = projectCard.stoppedSet.filter(c => Number(c.exitCode ?? 0) !== 0);
|
||||||
|
return bad.length === 0
|
||||||
|
? ""
|
||||||
|
: bad.length + (bad.length === 1 ? " exited" : " exited") + " badly.";
|
||||||
|
}
|
||||||
|
onActivated: root.toggleExpanded(projectCard.projectName)
|
||||||
|
}
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.isExpanded(projectCard.projectName) ? projectCard.stoppedSet : []
|
||||||
|
|
||||||
|
delegate: SettingRow {
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
width: parent.width
|
||||||
|
label: String(modelData.name ?? "")
|
||||||
|
detail: String(modelData.image ?? "") + " · " + String(modelData.status ?? "")
|
||||||
|
controlWidth: 180
|
||||||
|
divider: index < projectCard.stoppedSet.length - 1
|
||||||
|
|
||||||
|
Row {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
text: "Logs"
|
||||||
|
onClicked: Containers.openLogs(String(modelData.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
text: "Start"
|
||||||
|
enabled: !Containers.busy
|
||||||
|
onClicked: Containers.start(String(modelData.name))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
TextRow {
|
// ── containers no compose project claims ────────────────────────────
|
||||||
visible: Containers.scanned && Containers.available && Containers.total === 0
|
|
||||||
label: "No containers"
|
SettingsCard {
|
||||||
detail: "Nothing has been created on this machine yet."
|
visible: Containers.loose.length > 0
|
||||||
value: ""
|
title: "Not part of a project"
|
||||||
divider: false
|
subtitle: "Started directly rather than by a compose file."
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: Containers.loose
|
||||||
|
|
||||||
|
delegate: SettingRow {
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
width: parent.width
|
||||||
|
label: String(modelData.name ?? "")
|
||||||
|
detail: String(modelData.image ?? "") + " · " + String(modelData.status ?? "")
|
||||||
|
controlWidth: 180
|
||||||
|
divider: index < Containers.loose.length - 1
|
||||||
|
|
||||||
|
Row {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
text: "Logs"
|
||||||
|
onClicked: Containers.openLogs(String(modelData.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
text: modelData.state === "running" ? "Stop" : "Start"
|
||||||
|
enabled: !Containers.busy
|
||||||
|
onClicked: modelData.state === "running"
|
||||||
|
? Containers.stop(String(modelData.name))
|
||||||
|
: Containers.start(String(modelData.name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TextRow {
|
||||||
|
visible: Containers.scanned && Containers.available && Containers.total === 0
|
||||||
|
label: "No containers"
|
||||||
|
detail: "Nothing has been created on this machine yet."
|
||||||
|
value: ""
|
||||||
|
divider: false
|
||||||
|
}
|
||||||
|
|
||||||
// ── logs ────────────────────────────────────────────────────────────────
|
// ── logs ────────────────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// A drill-in rather than a panel inside the scrolling page: logs need their
|
// A drill-in rather than a panel inside the scrolling page: logs need their
|
||||||
// own scrollback, and nesting one scrolling view inside another makes the
|
// 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.
|
||||||
|
|
||||||
Item {
|
drillIn: Component {
|
||||||
anchors.fill: parent
|
Item {
|
||||||
visible: Containers.logTarget !== ""
|
anchors.fill: parent
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
id: logHeader
|
id: logHeader
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
anchors.top: parent.top
|
anchors.top: parent.top
|
||||||
anchors.leftMargin: 34
|
anchors.leftMargin: 34
|
||||||
anchors.rightMargin: 34
|
anchors.rightMargin: 34
|
||||||
anchors.topMargin: 30
|
anchors.topMargin: 30
|
||||||
spacing: 6
|
spacing: 6
|
||||||
|
|
||||||
Row {
|
Row {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
spacing: 12
|
spacing: 12
|
||||||
|
|
||||||
SettingsButton {
|
SettingsButton {
|
||||||
text: "‹ Back"
|
text: "‹ Back"
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
onClicked: Containers.closeLogs()
|
onClicked: Containers.closeLogs()
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: Containers.logTarget
|
||||||
|
color: Theme.fg
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: 22
|
||||||
|
font.weight: Font.DemiBold
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
width: parent.width
|
||||||
text: Containers.logTarget
|
text: Containers.logError !== ""
|
||||||
color: Theme.fg
|
? Containers.logError
|
||||||
|
: (Containers.logFollowing
|
||||||
|
? "Following. The last " + Containers.logLines.count + " lines are shown."
|
||||||
|
: "The stream has ended.")
|
||||||
|
color: Containers.logError !== "" ? Theme.danger : Theme.fgDim
|
||||||
font.family: Theme.fontFamily
|
font.family: Theme.fontFamily
|
||||||
font.pixelSize: 22
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
font.weight: Font.DemiBold
|
wrapMode: Text.WordWrap
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Text {
|
Rectangle {
|
||||||
width: parent.width
|
anchors.left: parent.left
|
||||||
text: Containers.logError !== ""
|
anchors.right: parent.right
|
||||||
? Containers.logError
|
anchors.top: logHeader.bottom
|
||||||
: (Containers.logFollowing
|
anchors.bottom: parent.bottom
|
||||||
? "Following. The last " + Containers.logLines.count + " lines are shown."
|
anchors.leftMargin: 34
|
||||||
: "The stream has ended.")
|
anchors.rightMargin: 34
|
||||||
color: Containers.logError !== "" ? Theme.danger : Theme.fgDim
|
anchors.topMargin: 14
|
||||||
font.family: Theme.fontFamily
|
anchors.bottomMargin: 30
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
radius: Theme.cardRadius + 2
|
||||||
wrapMode: Text.WordWrap
|
color: Theme.alpha(Theme.bgDark, 0.75)
|
||||||
}
|
border.width: 1
|
||||||
}
|
border.color: Theme.alpha(Theme.fg, 0.07)
|
||||||
|
|
||||||
Rectangle {
|
ListView {
|
||||||
anchors.left: parent.left
|
id: logList
|
||||||
anchors.right: parent.right
|
|
||||||
anchors.top: logHeader.bottom
|
|
||||||
anchors.bottom: parent.bottom
|
|
||||||
anchors.leftMargin: 34
|
|
||||||
anchors.rightMargin: 34
|
|
||||||
anchors.topMargin: 14
|
|
||||||
anchors.bottomMargin: 30
|
|
||||||
radius: Theme.cardRadius + 2
|
|
||||||
color: Theme.alpha(Theme.bgDark, 0.75)
|
|
||||||
border.width: 1
|
|
||||||
border.color: Theme.alpha(Theme.fg, 0.07)
|
|
||||||
|
|
||||||
ListView {
|
anchors.fill: parent
|
||||||
id: logList
|
anchors.margins: 12
|
||||||
|
clip: true
|
||||||
|
model: Containers.logLines
|
||||||
|
spacing: 1
|
||||||
|
boundsBehavior: Flickable.StopAtBounds
|
||||||
|
cacheBuffer: 400
|
||||||
|
|
||||||
anchors.fill: parent
|
// Stay pinned to the newest line while the reader is already at
|
||||||
anchors.margins: 12
|
// the bottom, and leave the view alone the moment they scroll up
|
||||||
clip: true
|
// to read something.
|
||||||
model: Containers.logLines
|
property bool pinned: true
|
||||||
spacing: 1
|
onContentYChanged: logList.pinned =
|
||||||
boundsBehavior: Flickable.StopAtBounds
|
logList.contentY >= logList.contentHeight - logList.height - 24
|
||||||
cacheBuffer: 400
|
onCountChanged: if (logList.pinned) logList.positionViewAtEnd()
|
||||||
|
|
||||||
// Stay pinned to the newest line while the reader is already at
|
delegate: Text {
|
||||||
// the bottom, and leave the view alone the moment they scroll up
|
required property string line
|
||||||
// to read something.
|
width: logList.width - 24
|
||||||
property bool pinned: true
|
text: line
|
||||||
onContentYChanged: logList.pinned =
|
color: Theme.fgDim
|
||||||
logList.contentY >= logList.contentHeight - logList.height - 24
|
font.family: Theme.fontMono
|
||||||
onCountChanged: if (logList.pinned) logList.positionViewAtEnd()
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
wrapMode: Text.WrapAnywhere
|
||||||
delegate: Text {
|
textFormat: Text.PlainText
|
||||||
required property string line
|
}
|
||||||
width: logList.width - 24
|
|
||||||
text: line
|
|
||||||
color: Theme.fgDim
|
|
||||||
font.family: Theme.fontMono
|
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
|
||||||
wrapMode: Text.WrapAnywhere
|
|
||||||
textFormat: Text.PlainText
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
// Date & Time.
|
// Date, Time & Region.
|
||||||
//
|
//
|
||||||
// These belong to the machine rather than to Panama, so nothing here is stored
|
// 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
|
||||||
enabled: !SettingsBackup.busy
|
readonly property bool confirmingDelete:
|
||||||
divider: index < snapshotRows.count - 1
|
root.confirming === "delete:" + backupRow.backupName
|
||||||
onTriggered: SettingsBackup.restore(modelData.name)
|
|
||||||
|
// The helper names a file by its timestamp with whatever the
|
||||||
|
// person called it appended, so what is left after the
|
||||||
|
// timestamp is the name they gave it. An unnamed backup falls
|
||||||
|
// back to when it was taken rather than showing a filename.
|
||||||
|
readonly property string displayName: {
|
||||||
|
const given = String(backupRow.modelData.label ?? "");
|
||||||
|
if (given !== "")
|
||||||
|
return given;
|
||||||
|
const stem = backupRow.backupName.replace(/\.json$/, "");
|
||||||
|
const named = stem
|
||||||
|
.replace(/^[0-9][0-9T:_.\-]*/, "")
|
||||||
|
.replace(/^[-_]+/, "")
|
||||||
|
.replace(/[-_]+/g, " ")
|
||||||
|
.trim();
|
||||||
|
return named === "" ? String(backupRow.modelData.when ?? stem) : named;
|
||||||
|
}
|
||||||
|
|
||||||
|
width: parent.width
|
||||||
|
label: backupRow.displayName
|
||||||
|
detail: {
|
||||||
|
const when = String(backupRow.modelData.when ?? "");
|
||||||
|
const parts = when === backupRow.displayName ? [] : [when];
|
||||||
|
if (backupRow.modelData.keys)
|
||||||
|
parts.push(backupRow.modelData.keys + " settings");
|
||||||
|
const bytes = Number(backupRow.modelData.bytes ?? 0);
|
||||||
|
if (bytes > 0)
|
||||||
|
parts.push(root.formatBytes(bytes));
|
||||||
|
if (backupRow.confirmingRestore)
|
||||||
|
parts.push("Restoring replaces every setting on this machine, and reloads the desktop");
|
||||||
|
if (backupRow.confirmingDelete)
|
||||||
|
parts.push("Deleting a backup cannot be undone");
|
||||||
|
return parts.filter(part => part !== "").join(" · ");
|
||||||
|
}
|
||||||
|
controlWidth: 250
|
||||||
|
divider: backupRow.index < snapshotRows.count - 1
|
||||||
|
|
||||||
|
Row {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
visible: !backupRow.confirmingDelete
|
||||||
|
text: backupRow.confirmingRestore ? "Cancel" : "Restore…"
|
||||||
|
enabled: !SettingsBackup.busy
|
||||||
|
onClicked: root.arm("restore:" + backupRow.backupName)
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
visible: backupRow.confirmingRestore
|
||||||
|
text: "Restore it"
|
||||||
|
tone: "danger"
|
||||||
|
enabled: !SettingsBackup.busy
|
||||||
|
onClicked: {
|
||||||
|
root.confirming = "";
|
||||||
|
SettingsBackup.restore(backupRow.backupName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
visible: !backupRow.confirmingRestore
|
||||||
|
text: backupRow.confirmingDelete ? "Cancel" : "Delete…"
|
||||||
|
enabled: !SettingsBackup.busy
|
||||||
|
onClicked: root.arm("delete:" + backupRow.backupName)
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
visible: backupRow.confirmingDelete
|
||||||
|
text: "Delete it"
|
||||||
|
tone: "danger"
|
||||||
|
enabled: !SettingsBackup.busy
|
||||||
|
onClicked: {
|
||||||
|
root.confirming = "";
|
||||||
|
SettingsBackup.deleteBackup(backupRow.backupName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Reset ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
SettingsCard {
|
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") === ""
|
||||||
ActionRow {
|
? "" : Updates.sourceDownloadSize("dnf") + " to download"
|
||||||
visible: Number(Updates.dnf?.count ?? 0) > 0
|
]))
|
||||||
label: "Install package updates"
|
|
||||||
detail: "Asks for your password, and takes a snapshot first so this can be undone"
|
|
||||||
action: Updates.applying ? "Working…" : "Install"
|
|
||||||
enabled: !Updates.busy
|
|
||||||
onTriggered: Updates.apply("dnf")
|
|
||||||
}
|
|
||||||
|
|
||||||
ActionRow {
|
|
||||||
visible: Number(Updates.dnf?.count ?? 0) > 0
|
|
||||||
label: "What would change"
|
|
||||||
detail: root.expandedSource === "dnf"
|
|
||||||
? "Every package that would be replaced"
|
|
||||||
: Updates.dnf.count + " packages"
|
|
||||||
action: root.expandedSource === "dnf" ? "Hide" : "Show"
|
|
||||||
divider: root.expandedSource === "dnf"
|
|
||||||
onTriggered: root.expandedSource = root.expandedSource === "dnf" ? "" : "dnf"
|
|
||||||
}
|
|
||||||
|
|
||||||
Repeater {
|
Repeater {
|
||||||
model: root.expandedSource === "dnf" ? (Updates.dnf?.packages ?? []) : []
|
id: packageRows
|
||||||
|
model: root.shownPackages
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: packageRow
|
||||||
|
|
||||||
delegate: TextRow {
|
|
||||||
required property var modelData
|
required property var modelData
|
||||||
required property int index
|
required property int index
|
||||||
|
|
||||||
|
readonly property string name: String(packageRow.modelData.name ?? "")
|
||||||
|
readonly property bool open: root.expandedPackage === "dnf:" + packageRow.name
|
||||||
|
|
||||||
width: parent.width
|
width: parent.width
|
||||||
label: String(modelData.name ?? "")
|
|
||||||
detail: String(modelData.repository ?? "")
|
ActionRow {
|
||||||
value: String(modelData.version ?? "")
|
width: parent.width
|
||||||
divider: index < (Updates.dnf?.packages ?? []).length - 1
|
label: packageRow.name
|
||||||
|
detail: root.joined([
|
||||||
|
String(packageRow.modelData.version ?? ""),
|
||||||
|
String(packageRow.modelData.repository ?? "") === ""
|
||||||
|
? "" : "from " + packageRow.modelData.repository
|
||||||
|
])
|
||||||
|
action: packageRow.open ? "Hide" : "Changelog"
|
||||||
|
divider: !packageRow.open && packageRow.index < packageRows.count - 1
|
||||||
|
onTriggered: root.toggleChangelog("dnf", packageRow.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
visible: packageRow.open
|
||||||
|
text: root.changelogText("dnf", packageRow.name)
|
||||||
|
color: Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
lineHeight: 1.3
|
||||||
|
topPadding: 2
|
||||||
|
bottomPadding: 14
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
visible: root.packages.length > root.previewCount
|
||||||
|
label: root.showAllPackages
|
||||||
|
? "Showing all " + root.packages.length + " packages"
|
||||||
|
: (root.packages.length - root.previewCount) + " more waiting"
|
||||||
|
detail: root.showAllPackages ? "" : "Each one can say what it changes"
|
||||||
|
action: root.showAllPackages ? "Show fewer" : "Show all"
|
||||||
|
onTriggered: {
|
||||||
|
root.expandedPackage = "";
|
||||||
|
root.showAllPackages = !root.showAllPackages;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
visible: root.packages.length > 0
|
||||||
|
label: "Install package updates"
|
||||||
|
detail: "Asks for your password, and takes a snapshot first — undo lives in System › Snapshots"
|
||||||
|
action: Updates.applying ? "Working…" : "Install updates"
|
||||||
|
enabled: !Updates.busy
|
||||||
|
divider: false
|
||||||
|
onTriggered: Updates.apply("dnf")
|
||||||
|
}
|
||||||
|
|
||||||
TextRow {
|
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
|
enabled: !Updates.busy
|
||||||
width: parent.width
|
onToggled: value => Updates.setAutomaticDnf(value)
|
||||||
label: String(modelData.id ?? "")
|
|
||||||
detail: "Flatpak"
|
|
||||||
value: String(modelData.version ?? "")
|
|
||||||
divider: true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ActionRow {
|
// Offered only when the machinery exists. When it does not, this says
|
||||||
visible: Number(Updates.flatpak?.count ?? 0) > 0
|
// so rather than showing a switch that could not do anything --
|
||||||
label: "Update applications"
|
// installing software is not a settings action.
|
||||||
detail: "Needs no password: these are installed for your account"
|
TextRow {
|
||||||
action: Updates.applying ? "Working…" : "Update"
|
visible: Updates.automatic?.dnfAutomaticAvailable === false
|
||||||
enabled: !Updates.busy
|
label: "Download package updates automatically"
|
||||||
onTriggered: Updates.apply("flatpak")
|
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
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
|||||||
@@ -2,9 +2,25 @@
|
|||||||
|
|
||||||
# System locale, via localectl.
|
# System locale, via localectl.
|
||||||
#
|
#
|
||||||
# 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}"
|
||||||
| awk -F'LANG=' '/System Locale:/ { print $2; exit }' \
|
if [[ "$name" != "LANG" ]] && ! is_category "$name"; then
|
||||||
| tr -d '[:space:]'
|
printf 'panama-locale: %s is not a category this manages\n' "$name" >&2
|
||||||
|
return 2
|
||||||
|
fi
|
||||||
|
local value
|
||||||
|
value="$(current_assignments | awk -F= -v want="$name" '$1 == want { print $2; exit }')"
|
||||||
|
# A machine with no /etc/locale.conf still has a LANG, and localectl is the
|
||||||
|
# one that knows it. Categories have no such fallback: absent there means
|
||||||
|
# absent, which is exactly "match language".
|
||||||
|
if [[ -z "$value" && "$name" == "LANG" ]]; then
|
||||||
|
value="$(localectl status 2>/dev/null \
|
||||||
|
| awk -F'LANG=' '/System Locale:/ { print $2; exit }' \
|
||||||
|
| tr -d '[:space:]')"
|
||||||
|
fi
|
||||||
|
printf '%s\n' "$value"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Every category override in one answer: {"LC_TIME": "de_DE.UTF-8", ...} with
|
||||||
|
# "" for the ones that match the language. Five separate `get` calls would be
|
||||||
|
# five processes for one screenful of state.
|
||||||
|
cmd_overrides() {
|
||||||
|
local assignments name
|
||||||
|
assignments="$(current_assignments)"
|
||||||
|
{ for name in "${CATEGORIES[@]}"; do
|
||||||
|
printf '%s\t%s\n' "$name" \
|
||||||
|
"$(awk -F= -v want="$name" '$1 == want { print $2; exit }' <<<"$assignments")"
|
||||||
|
done; } | jq -Rn '[inputs | split("\t") | {key: .[0], value: (.[1] // "")}] | from_entries'
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd_list() {
|
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,8 +245,15 @@ 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)
|
||||||
continue
|
if not owned:
|
||||||
|
if QSAVEFILE_RE.fullmatch(child.name) is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if now - child.stat().st_mtime < STALE_AFTER_SECONDS:
|
||||||
|
continue
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
# Only Panama's hidden atomic-write names are eligible. A matching
|
# 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.
|
||||||
if child.is_dir() and not child.is_symlink():
|
if child.is_dir() and not child.is_symlink():
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ?? []
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,7 +96,9 @@ 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."
|
||||||
: "The settings could not be backed up.";
|
: actionRun.doneAction === "deleted"
|
||||||
|
? "That snapshot could not be deleted."
|
||||||
|
: "The settings could not be backed up.";
|
||||||
if (actionRun.restoring) {
|
if (actionRun.restoring) {
|
||||||
root.setDisplayBlocked(false);
|
root.setDisplayBlocked(false);
|
||||||
root.protectedDisplays = ({});
|
root.protectedDisplays = ({});
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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 snapshot’s size'
|
||||||
|
listed_bytes="$(run list | jq -r '.[0].bytes')"
|
||||||
|
actual_bytes="$(stat -c %s "$backups/$(run list | jq -r '.[0].name')")"
|
||||||
|
[[ "$listed_bytes" == "$actual_bytes" ]] \
|
||||||
|
|| fail "the list reports $listed_bytes bytes for a snapshot that is $actual_bytes on disk"
|
||||||
|
|
||||||
# ── Snapshots are capped ─────────────────────────────────────────────────────
|
# ── 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'
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 \
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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")" \
|
||||||
|
|||||||
Reference in New Issue
Block a user