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

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-24 23:31:52 -04:00
parent 9ffaf45a4d
commit be0e55214b
57 changed files with 5040 additions and 925 deletions
@@ -40,9 +40,11 @@ Singleton {
readonly property bool busy: query.running || mutation.running
// Published on every interface AND currently running: reachable now, as
// opposed to a stopped container that merely would be.
// opposed to a stopped container that merely would be. The stopped half had
// a property of its own that nothing ever read -- `exposed` already holds
// both, and a second derived list nobody asks for is a list that can only
// go wrong quietly.
readonly property var reachable: root.exposed.filter(entry => entry.running === true)
readonly property var wouldExpose: root.exposed.filter(entry => entry.running !== true)
readonly property var unusedImages: root.disk.unusedImages ?? []
readonly property var unusedVolumes: root.disk.unusedVolumes ?? []
+102
View File
@@ -24,15 +24,44 @@ Singleton {
property string timezone: ""
property bool ntpEnabled: false
property bool ntpSynchronized: false
// Whether timedatectl has answered even once. The status query is async, so
// for the first moment of the shell's life `ntpEnabled` is false because
// nothing has looked, not because network time is off -- and setTime's
// whole job is to refuse while it is on. Treating "not looked yet" as "off"
// is the same wrong answer that looks fine as everywhere else in Panama.
property bool statusRead: false
// What timedatectl last said the three clocks read. localTime is what the
// Clock card shows and what seeds the manual-set field, so it is kept
// ticking (see clockTick below) rather than frozen at the last scan;
// universalTime and rtcTime are facts, shown as read.
property string localTime: ""
property string universalTime: ""
property string rtcTime: ""
property string lastError: ""
// Milliseconds between this shell's own clock and the one timedatectl
// reported. Normally zero -- both read the same system clock -- but
// deriving the displayed time from the system's own answer rather than
// from QML's assumption means a clock that moves under us shows the move.
property real systemOffsetMs: 0
// Set by the page while the Clock card is on screen. The tick is a local
// recomputation, never another timedatectl call: a process per second to
// learn a value the local clock already tracks exactly would be a
// remarkable amount of work for a second hand.
property bool tracking: false
property var zones: []
readonly property bool busy: statusQuery.running || zonesQuery.running || writeRun.running
// The shape `setTime` accepts, and the shape the field should be seeded
// with. Seconds are optional because "set it to 9:30" is a whole request.
readonly property var timePattern:
new RegExp("^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}(?::\\d{2})?$")
// "America/New_York" -> "New York" for display, keeping the region as a
// separate field so the list can be grouped and searched sensibly.
function regionOf(zone: string): string {
@@ -102,10 +131,83 @@ Singleton {
root.ntpEnabled = value === "yes";
else if (key === "NTPSynchronized")
root.ntpSynchronized = value === "yes";
else if (key === "TimeUSec")
root.anchorClock(value);
else if (key === "RTCTimeUSec")
root.rtcTime = root.tidyStamp(value);
}
root.statusRead = true;
root.lastError = "";
}
// timedatectl prints "Mon 2026-08-24 21:22:55 EDT". The weekday is a
// duplicate of the date and the zone is already its own row, so what is
// left is the part a clock actually shows.
function tidyStamp(value: string): string {
const match = String(value).match(/(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})/);
return match ? match[1] : String(value).trim();
}
function anchorClock(value: string): void {
const stamp = root.tidyStamp(value);
// Parsed as local wall time, which is what timedatectl printed. A
// stamp this cannot read leaves the offset alone rather than jumping
// the displayed clock by whatever the misparse happened to produce.
const parsed = Date.parse(stamp.replace(" ", "T"));
root.systemOffsetMs = Number.isFinite(parsed) ? parsed - Date.now() : 0;
root.tickClock();
}
function tickClock(): void {
const now = new Date(Date.now() + root.systemOffsetMs);
root.localTime = Qt.formatDateTime(now, "yyyy-MM-dd HH:mm:ss");
// toISOString is already UTC, which is the whole question here; doing
// the offset arithmetic by hand is a way to get it wrong twice a year.
root.universalTime = now.toISOString().slice(0, 19).replace("T", " ") + " UTC";
}
Timer {
id: clockTick
interval: 1000
repeat: true
running: root.tracking
onTriggered: root.tickClock()
}
// Setting the clock by hand, which is only a coherent request while
// network time is off. With NTP on, timedatectl refuses outright and a
// toggle-then-set from the page would race the daemon putting the time
// back -- so the refusal happens here, where it can be explained, rather
// than as an opaque failure from a command the user did not type.
function setTime(iso: string): bool {
if (!root.statusRead) {
root.lastError = "The clock settings have not been read yet.";
root.refresh();
return false;
}
if (root.ntpEnabled) {
root.lastError = "Turn off network time before setting the clock by hand.";
return false;
}
const stamp = String(iso).trim();
if (!root.timePattern.test(stamp)) {
root.lastError = "Enter the time as YYYY-MM-DD HH:MM, with optional seconds.";
return false;
}
// Shape is not enough: "2026-13-45 99:99" matches the pattern and is
// not a moment. Round-tripping through Date is what rejects it.
const parsed = new Date(stamp.replace(" ", "T"));
if (!Number.isFinite(parsed.getTime())
|| Qt.formatDateTime(parsed, "yyyy-MM-dd HH:mm") !== stamp.slice(0, 16)) {
root.lastError = "That is not a real date and time.";
return false;
}
if (writeRun.running)
return false;
writeRun.exec(["timedatectl", "set-time", stamp]);
return true;
}
function refresh(): void {
if (!statusQuery.running)
statusQuery.running = true;
+215 -2
View File
@@ -27,13 +27,19 @@ Singleton {
property bool postRepairScanPending: false
readonly property bool actionable: root.status === "warning" || root.status === "error"
readonly property bool busy: scanProcess.running || repairProcess.running || root.postRepairScanPending
readonly property bool busy: scanProcess.running || repairProcess.running
|| singleCheckProcess.running || root.postRepairScanPending
readonly property string helperPath: Quickshell.env("PANAMA_HEALTH_HELPER")
|| Quickshell.shellDir + "/scripts/panama-doctor"
readonly property var statuses: ["ok", "warning", "error", "unconfigured"]
readonly property var groups: ["desktop-foundation", "input-media", "integrations", "panama-tools"]
readonly property var overallStatuses: ["healthy", "warning", "error"]
readonly property var settingsTargets: ["my-home", "datetime"]
// Every page a check's "open" action is allowed to send someone to. This
// list and the doctor's authored targets are one change, not two: a target
// the doctor emits but this does not accept fails validAction, and a single
// rejected action invalidates the WHOLE snapshot -- so half of the pair
// does not degrade the row, it blanks the page.
readonly property var settingsTargets: ["my-home", "datetime", "updates"]
readonly property var instructionTargets: ["ddc-permissions"]
Process {
@@ -113,6 +119,54 @@ Singleton {
}
}
Process {
id: singleCheckProcess
property string checkId: ""
property int baseGeneration: 0
property string outputText: ""
property int exitCode: -1
property bool exited: false
property bool streamFinished: false
property bool settled: false
stdout: StdioCollector {
onStreamFinished: {
singleCheckProcess.outputText = this.text;
singleCheckProcess.streamFinished = true;
root.settleSingleCheck();
}
}
onExited: (exitCode, exitStatus) => {
singleCheckProcess.exitCode = exitCode;
singleCheckProcess.exited = true;
root.settleSingleCheck();
}
}
// `tee` rather than a shell redirect: the path is a value, not a fragment
// of a command line, so nothing about it can be read as syntax.
Process {
id: saveProcess
property string payload: ""
property string targetPath: ""
stdinEnabled: true
stdout: StdioCollector {}
onStarted: {
saveProcess.write(saveProcess.payload);
saveProcess.stdinEnabled = false;
}
onExited: (exitCode, exitStatus) => {
root.lastSaveResult = exitCode === 0
? "Report saved to " + saveProcess.targetPath + "."
: "Could not save the health report.";
saveProcess.payload = "";
}
}
Process {
id: failureNotification
}
@@ -229,6 +283,11 @@ Singleton {
};
if (candidate.action !== undefined)
check.action = root.safeAction(candidate.action);
// Carried through rather than reconstructed: the page shows the exact
// command a repair will run before running it, and the helper is the
// only thing that knows what that is.
if (candidate.repairCommand !== undefined)
check.repairCommand = candidate.repairCommand;
return check;
}
@@ -343,6 +402,154 @@ Singleton {
&& candidate.message.length > 0;
}
// ── Re-checking one row ─────────────────────────────────────────────────
//
// A full scan runs thirty probes. Asking again about the one row somebody
// just repaired should not cost the other twenty-nine, so the helper is
// asked for that check alone and the answer is spliced into the accepted
// snapshot. The reply arrives in the full snapshot shape, which means it
// goes through exactly the same validation as a whole scan -- an invalid
// single-check reply leaves the existing row alone rather than replacing a
// good answer with a bad one.
property string refreshingId: ""
readonly property bool refreshingCheck: singleCheckProcess.running
function refreshCheck(id: string): bool {
if (root.busy || singleCheckProcess.running)
return false;
if (!root.checks.some(candidate => candidate.id === id))
return false;
root.refreshingId = id;
singleCheckProcess.checkId = id;
singleCheckProcess.baseGeneration = root.acceptedGeneration;
singleCheckProcess.outputText = "";
singleCheckProcess.exitCode = -1;
singleCheckProcess.exited = false;
singleCheckProcess.streamFinished = false;
singleCheckProcess.settled = false;
singleCheckProcess.exec([root.helperPath, "check", id]);
return true;
}
function settleSingleCheck(): void {
if (singleCheckProcess.settled || !singleCheckProcess.exited
|| !singleCheckProcess.streamFinished)
return;
singleCheckProcess.settled = true;
root.finishSingleCheck(singleCheckProcess.exitCode, singleCheckProcess.checkId,
singleCheckProcess.baseGeneration,
singleCheckProcess.outputText);
}
function finishSingleCheck(exitCode: int, id: string, baseGeneration: int, text: string): bool {
root.refreshingId = "";
// A full scan that landed while this one row was being re-checked is
// the newer answer for every row including this one. Splicing a stale
// row back into it would undo part of a scan nobody asked to undo.
if (baseGeneration !== root.acceptedGeneration)
return false;
if (exitCode !== 0) {
root.lastError = "That check could not be re-run.";
return false;
}
let candidate;
try {
candidate = JSON.parse(text.trim());
} catch (error) {
root.lastError = "That check returned an unreadable response.";
return false;
}
if (!root.validSnapshot(candidate) || candidate.checks.length !== 1
|| candidate.checks[0].id !== id) {
root.lastError = "That check returned an invalid response.";
return false;
}
const replacement = root.safeCheck(candidate.checks[0]);
const merged = root.checks.map(check => check.id === id ? replacement : check);
root.checks = merged;
root.summary = root.countsFor(merged);
root.status = root.summary.status;
root.snapshot = Object.assign({}, root.snapshot, {
checks: merged,
summary: root.summary
});
root.lastError = "";
return true;
}
// The same arithmetic the helper does, applied to a list that has had one
// row replaced. Recomputed rather than left alone: a warning that repaired
// itself must leave the headline count, not just its own row.
function countsFor(checks: var): var {
const counts = { ok: 0, warning: 0, error: 0, unconfigured: 0 };
for (const check of checks)
counts[check.status] += 1;
return {
status: counts.error > 0 ? "error" : counts.warning > 0 ? "warning" : "healthy",
healthy: counts.ok,
warnings: counts.warning,
errors: counts.error,
unconfigured: counts.unconfigured
};
}
// ── Saving the report ───────────────────────────────────────────────────
property string lastSaveResult: ""
readonly property string defaultReportPath:
(Quickshell.env("HOME") ?? "") + "/panama-health-report.txt";
// Plain text rather than the JSON copyReport puts on the clipboard: a file
// somebody saves is a file somebody opens, and a report they can read
// without a JSON viewer is worth more than one that round-trips.
function reportText(): string {
const lines = [
"Panama system health",
"Generated " + String(root.snapshot?.generatedAt ?? "at an unknown time"),
"Status: " + root.status
+ " (" + root.summary.healthy + " ok, "
+ root.summary.warnings + " warnings, "
+ root.summary.errors + " errors, "
+ root.summary.unconfigured + " unconfigured)",
""
];
for (const version of root.snapshot?.context?.versions ?? [])
lines.push(version.id + ": " + version.version);
lines.push("");
for (const check of root.checks) {
lines.push("[" + check.status + "] " + check.title + " (" + check.id + ")");
lines.push(" " + check.detail);
if (check.repairCommand)
lines.push(" repair: " + check.repairCommand);
}
return lines.join("\n") + "\n";
}
function saveReport(path: string): bool {
if (saveProcess.running)
return false;
const target = path && path.length > 0 ? path : root.defaultReportPath;
if (!target || target.indexOf("/") !== 0) {
root.lastSaveResult = "That is not a path this can write to.";
return false;
}
root.lastSaveResult = "";
saveProcess.targetPath = target;
saveProcess.payload = root.reportText();
// Re-arm stdin: the previous run closed it, and a disabled channel
// stays closed even after being set back to true mid-run. Same
// discipline as copyProcess above.
saveProcess.stdinEnabled = true;
saveProcess.exec(["tee", target]);
return true;
}
function copyReport(): bool {
if (copyProcess.running)
return false;
@@ -366,7 +573,9 @@ Singleton {
generation: root.generation,
acceptedGeneration: root.acceptedGeneration,
repairingId: root.repairingId,
refreshingId: root.refreshingId,
lastRepair: root.lastRepair,
lastSaveResult: root.lastSaveResult,
lastError: root.lastError,
checks: root.checks.map(check => check.id),
checkStates: root.checks.map(check => ({ id: check.id, status: check.status }))
@@ -427,6 +636,10 @@ Singleton {
|| typeof candidate.title !== "string" || candidate.title.length === 0
|| typeof candidate.detail !== "string" || candidate.detail.length === 0)
return false;
if (candidate.repairCommand !== undefined
&& (typeof candidate.repairCommand !== "string"
|| candidate.repairCommand.length === 0))
return false;
return candidate.action === undefined || root.validAction(candidate.action);
}
@@ -15,6 +15,7 @@ import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
import "DisplayLayout.js" as DisplayLayout
Singleton {
id: root
@@ -23,7 +24,6 @@ Singleton {
property var snapshots: []
property string lastError: ""
property string lastAction: ""
// Narrow service boundaries keep restore sequencing explicit and make it
// possible to verify the real handler in an isolated shell without ever
@@ -84,6 +84,9 @@ Singleton {
Process {
id: actionRun
property bool restoring: false
// Which verb is in flight, so a failure can name what failed. A
// restore has its own flag because it also drives the display handoff.
property string doneAction: "saved"
property string outputText: ""
stdout: StdioCollector {
onStreamFinished: actionRun.outputText = this.text
@@ -93,7 +96,9 @@ Singleton {
if (exitCode !== 0) {
root.lastError = actionRun.restoring
? "That snapshot could not be restored."
: "The settings could not be backed up.";
: actionRun.doneAction === "deleted"
? "That snapshot could not be deleted."
: "The settings could not be backed up.";
if (actionRun.restoring) {
root.setDisplayBlocked(false);
root.protectedDisplays = ({});
@@ -101,7 +106,6 @@ Singleton {
}
return;
}
root.lastAction = actionRun.restoring ? "restored" : "saved";
if (actionRun.restoring) {
const restoreAccepted = root.handleRestoreOutput(actionRun.outputText);
if (restoreAccepted)
@@ -209,9 +213,39 @@ Singleton {
if (actionRun.running)
return;
actionRun.restoring = false;
actionRun.doneAction = "saved";
actionRun.exec([root.helperPath, "save", root.serializeHomeState()]);
}
// A snapshot with a name on it. The name is passed through untouched --
// the helper owns what a usable name is, and a second sanitiser here would
// be a second answer to that question, guaranteed to disagree eventually.
function create(name: string): void {
if (actionRun.running)
return;
actionRun.restoring = false;
actionRun.doneAction = "saved";
actionRun.exec([root.helperPath, "create", String(name ?? ""),
root.serializeHomeState()]);
}
// Matched against the list rather than trusted, exactly as restore() does:
// no caller-supplied name reaches the helper even though it validates as
// well. This is the only verb that destroys a snapshot, so the page is
// expected to confirm before calling it.
function deleteBackup(name: string): bool {
if (actionRun.running)
return false;
if (!root.snapshots.some(snapshot => snapshot.name === name)) {
root.lastError = "That snapshot is not in the list.";
return false;
}
actionRun.restoring = false;
actionRun.doneAction = "deleted";
actionRun.exec([root.helperPath, "delete", name]);
return true;
}
function serializeHomeState(): string {
const current = root.readHomeState();
@@ -250,6 +284,34 @@ Singleton {
applyRestoredState.restart();
}
// The colour, depth and mirror fields a stored arrangement may carry beyond
// its geometry. Optional exactly as Displays.isPersistedLayoutEntry has
// them: an arrangement written before these existed still restores, and a
// field that is present but invalid refuses the whole entry rather than
// being guessed at.
//
// Leaving them out was a real loss, not a cosmetic one. The restored record
// is built on top of the LIVE layout, so a snapshot's colour profile,
// bit depth and SDR levels were silently replaced by whatever the display
// is showing right now -- and layoutsEqual, comparing only geometry, then
// judged the two identical and skipped the apply that would have put them
// back. A snapshot taken in HDR restored to whatever was on screen.
readonly property var storedDisplayFields:
["vrrMode", "colorProfile", "bitdepth", "sdrBrightness", "sdrSaturation", "mirrorOf"]
function validStoredField(field: string, value: var): bool {
switch (field) {
case "vrrMode": return DisplayLayout.validVrrMode(value);
case "colorProfile": return DisplayLayout.validColorProfile(value);
case "bitdepth": return DisplayLayout.validBitdepth(value);
case "sdrBrightness": return DisplayLayout.validSdrBrightness(value);
case "sdrSaturation": return DisplayLayout.validSdrSaturation(value);
case "mirrorOf": return typeof value === "string"
&& (value === "" || /^[A-Za-z0-9_.-]+$/.test(value));
default: return false;
}
}
function layoutFromStoredDisplays(stored: var): var {
if (!stored || typeof stored !== "object")
return null;
@@ -267,7 +329,7 @@ Singleton {
|| !Number.isInteger(entry.x) || !Number.isInteger(entry.y)
|| typeof entry.primary !== "boolean")
return null;
layout.push(Object.assign({}, live, {
const record = Object.assign({}, live, {
width: Number(match[1]),
height: Number(match[2]),
refreshRate: Number(match[3]),
@@ -277,7 +339,15 @@ Singleton {
x: entry.x,
y: entry.y,
primary: entry.primary
}));
});
for (const field of root.storedDisplayFields) {
if (entry[field] === undefined)
continue;
if (!root.validStoredField(field, entry[field]))
return null;
record[field] = entry[field];
}
layout.push(record);
}
return layout.filter(record => record.primary).length === 1 ? layout : null;
}
@@ -285,11 +355,23 @@ Singleton {
function layoutsEqual(left: var, right: var): bool {
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length)
return false;
const fields = ["name", "mode", "scale", "transform", "x", "y", "primary"];
const fields = ["name", "mode", "scale", "transform", "x", "y", "primary",
"vrrMode", "colorProfile", "bitdepth", "mirrorOf"];
// Compared with a tolerance rather than by identity, the same way
// Displays.storedFieldsDiffer does: these come back from the compositor
// as floats, and 1.0 read back as 0.9999999 is not a change anybody made.
const approximate = ["sdrBrightness", "sdrSaturation"];
const a = Array.from(left).sort((x, y) => x.name.localeCompare(y.name));
const b = Array.from(right).sort((x, y) => x.name.localeCompare(y.name));
return a.every((record, index) => fields.every(
field => record[field] === b[index][field]));
field => record[field] === b[index][field])
&& approximate.every(field => {
const one = record[field];
const other = b[index][field];
if (one === undefined || other === undefined)
return one === other;
return Math.abs(Number(one) - Number(other)) < 0.001;
}));
}
function failDisplayRestore(message: string): bool {
@@ -80,13 +80,28 @@ Singleton {
{ page: "storage", label: "Storage" },
{ page: "snapshots", label: "Snapshots" },
{ page: "containers", label: "Containers" },
{ page: "datetime", label: "Date & Time" },
{ page: "region", label: "Region & Language" },
{ page: "manual", label: "Manual" },
{ page: "datetime", label: "Date, Time & Region" },
{ page: "sync", label: "Sync & Backup" }
] }
]
// Leaves that are routable but are not tabs.
//
// The manual is reference material rather than a control surface: it is
// reached from About's Manual card, from a deep link, or from the search
// box, and a tenth tab spent on something nobody switches to while
// adjusting a setting made the System strip harder to read than the pages
// it addressed. It still needs to be a leaf -- every one of those callers
// hands `resolve()` the id "manual" and expects the page, not Home.
//
// Deliberately a separate array rather than a field inside a category: the
// two generators that regex-parse `categories` require each category to end
// `tabs: [...] }` and cross-check that every `page:` inside the array is
// accounted for, so a hidden leaf declared in there would break both.
readonly property var hiddenLeaves: [
{ page: "manual", label: "Manual", category: "system" }
]
// Pages whose entire backing stack can be absent hide once a scan has
// proven it absent: a Containers tab with no podman and a Snapshots tab
// with no snapper configuration render permanently empty, which reads as
@@ -103,19 +118,37 @@ Singleton {
return true;
}
// The hidden leaf with this id, or undefined.
function hiddenLeaf(page: string): var {
return root.hiddenLeaves.find(leaf => leaf.page === page);
}
// The category owning a leaf. Tab membership is checked before category
// ids so the three doubled ids land on their category either way.
function categoryOf(leaf: string): var {
const byTab = root.categories.find(cat => cat.tabs.some(tab => tab.page === leaf));
if (byTab)
return byTab;
const hidden = root.hiddenLeaf(leaf);
if (hidden) {
const owner = root.categories.find(cat => cat.page === hidden.category);
if (owner)
return owner;
}
return root.categories.find(cat => cat.page === leaf) ?? root.categories[0];
}
// Retired page ids keep resolving forever: old Vicinae commands, shell
// history, and muscle memory all hold them. Each maps to the leaf that
// absorbed its content.
readonly property var retired: ({ "home-phone": "my-home", "desktop": "bar" })
readonly property var retired: ({
"home-phone": "my-home",
"desktop": "bar",
// Region & Language merged into Date, Time & Region: one tab now owns
// the clock, the timezone, the language and the per-category formats,
// which were three answers to "what does this machine consider local".
"region": "datetime"
})
// Any id a caller may hold — leaf, category, retired id, or garbage — to
// the leaf that should render: a leaf resolves to itself, a category to
@@ -128,7 +161,7 @@ Singleton {
return root.retired[id];
const asLeaf = root.categories.some(cat => (cat.page === id && cat.tabs.length === 0)
|| cat.tabs.some(tab => tab.page === id));
if (asLeaf)
if (asLeaf || root.hiddenLeaf(id) !== undefined)
return id;
const category = root.categories.find(cat => cat.page === id);
if (category) {
@@ -141,6 +174,10 @@ Singleton {
// The available tabs of the category owning a leaf, for the strip above
// the page. One entry (or none) means no strip is worth rendering.
//
// A hidden leaf still gets its category's strip, with nothing selected:
// the manual is somewhere you arrive on purpose and leave again, and the
// strip is how you leave.
function tabsFor(leaf: string): var {
return root.categoryOf(leaf).tabs.filter(tab => root.pageAvailable(tab.page));
}
@@ -149,7 +186,8 @@ Singleton {
// what search results show, so a hit says where it will land.
function breadcrumb(leaf: string): string {
const category = root.categoryOf(leaf);
const tab = category.tabs.find(t => t.page === leaf);
const tab = category.tabs.find(t => t.page === leaf)
?? root.hiddenLeaves.find(entry => entry.page === leaf);
return tab ? category.label + " " + tab.label : category.label;
}
}
@@ -207,8 +207,21 @@ Singleton {
{ label: "Clear recent files", detail: "Empty the list of documents this desktop remembers you opening", page: "privacy" },
{ label: "Thumbnails", detail: "The cached previews of your pictures and videos, and clearing them", page: "privacy" },
{ label: "Application permissions", detail: "What the desktop portal has recorded: camera, microphone, screen, background", page: "privacy" },
{ label: "Language", detail: "The system language, applied to programs started afterwards", page: "region" },
{ label: "Regional formats", detail: "How dates, times, and numbers are written", page: "region" },
// Region & Language stopped being a tab of its own and became the
// Language & formats card on Date, Time & Region: a date format and the
// clock that shows it are one subject, and splitting them across two
// tabs meant changing how a date is written on a page that never showed
// one. These route to the merged tab, and `region` itself is a retired
// id that SettingsRoutes still resolves.
{ label: "Language", detail: "The system language, applied to programs started afterwards", page: "datetime" },
{ label: "Regional formats", detail: "How dates, times, and numbers are written", page: "datetime" },
{ label: "Date format", detail: "The locale that decides how dates and times are written", page: "datetime" },
{ label: "Number format", detail: "Which locale's decimal and thousands separators are used", page: "datetime" },
{ label: "Currency", detail: "The locale that decides how amounts of money are written", page: "datetime" },
{ label: "Measurement units", detail: "Metric or imperial, for programs that ask the system", page: "datetime" },
{ label: "Paper size", detail: "A4 or Letter, for programs that ask the system", page: "datetime" },
{ label: "First day of the week", detail: "Whether the week starts on Sunday or Monday, as your formats say", page: "datetime" },
{ label: "Set the clock by hand", detail: "Type a date and time, once network time is off", page: "datetime" },
{ label: "Online accounts", detail: "Sign in to mail, calendar, and contacts", page: "accounts" },
// Adding an account is the thing people search for, and they search for
// it by the name of the service. Nextcloud and mail are added on the
@@ -228,8 +241,25 @@ Singleton {
{ label: "iMessage", detail: "Opens BlueBubbles", page: "phone" },
{ label: "System information", detail: "Kernel, distribution, and hardware", page: "about" },
{ label: "Desktop version", detail: "Which Hyprland and Quickshell this session runs", page: "about" },
// About answers "what is this machine". Each fact on it is something
// people arrive looking for by its own name -- a hostname to hand to
// somebody, a serial number for a warranty claim, a kernel version for
// a bug report -- and none of them is the label of a preference, so
// none was findable before.
{ label: "Hostname", detail: "The name this machine answers to", page: "about" },
{ label: "Kernel version", detail: "The Linux kernel this session is running", page: "about" },
{ label: "Device model", detail: "The manufacturer and model of this machine", page: "about" },
{ label: "Installed memory", detail: "How much RAM this machine has", page: "about" },
{ label: "Uptime", detail: "How long this machine has been running since it last started", page: "about" },
{ label: "Serial number", detail: "The number on the machine, for a warranty or support call", page: "about" },
{ label: "BIOS version", detail: "The firmware version this machine boots with", page: "about" },
{ label: "Restore defaults", detail: "Return every Panama setting to its shipped value", page: "sync" },
{ label: "Carry settings to another machine", detail: "Export, preview, and import a settings file", page: "sync" },
// The verbs, not just the sentence. People arrive knowing they want to
// export or import, and searching either of those words used to find
// nothing at all.
{ label: "Export settings", detail: "Write your preferences to a file you can carry elsewhere", page: "sync" },
{ label: "Import settings", detail: "Preview a settings file from another machine, then apply it", page: "sync" },
{ label: "Settings backups", detail: "Snapshots of your preferences, restorable any time", page: "sync" },
{ label: "Pinned applications", detail: "Reorder the dock by dragging, here or on the dock itself", page: "dock" },
{ label: "Bar text", detail: "Keep the bar legible on any wallpaper", page: "bar" },
@@ -27,8 +27,13 @@ Singleton {
property string lastAction: ""
property int carried: 0
property int applied: 0
property var left: []
// What an import would do, one setting per row, with both values already
// rendered as text by the helper. Capped for display; changeCount is how
// many there really are, so the page can say what it is not showing.
property var changes: []
property int changeCount: 0
property var skipped: []
property string exportedFrom: ""
property bool previewed: false
@@ -54,8 +59,8 @@ Singleton {
root.lastError = String(parsed.error ?? "");
root.carried = Number(parsed.carried ?? 0);
root.applied = Number(parsed.applied ?? 0);
root.left = Array.isArray(parsed.left) ? parsed.left : [];
root.changes = Array.isArray(parsed.changes) ? parsed.changes : [];
root.changeCount = Number(parsed.changeCount ?? root.changes.length);
root.skipped = Array.isArray(parsed.skipped) ? parsed.skipped : [];
root.exportedFrom = String(parsed.exportedFrom ?? "");
root.previewed = root.lastAction === "preview" && root.lastError === "";
@@ -33,16 +33,100 @@ Singleton {
// so the UI can stop claiming the new locale is already in use.
property bool pendingRestart: false
// Guards read the Process objects directly; a derived binding is stale
// inside the handler that changes it. See DefaultApps.qml.
readonly property bool busy: apply.running || applyCategory.running || root.scanning
readonly property string currentLabel: {
const match = root.locales.find(locale => locale.value === root.current);
return match ? match.label : root.current;
}
// ── Per-category formats ────────────────────────────────────────────────
//
// Reading in one language while writing dates, numbers and currency the way
// your country does is the ordinary case. Each category is an OVERRIDE, and
// its absence -- "" here -- means "match language", which is not the same
// as an override that happens to equal LANG today: the two diverge the
// moment the language changes.
//
// Same restart discipline as the language itself. localectl only affects
// programs started afterwards, so an accepted change sets pendingRestart
// and the page says a sign-out is needed rather than claiming the new
// format is already in use.
readonly property var categories:
["LC_TIME", "LC_NUMERIC", "LC_MONETARY", "LC_MEASUREMENT", "LC_PAPER"]
property var categoryValues: ({})
// Bumped whenever an override is read or accepted, and read at the top of
// categoryValue() so a binding built on that call has something to
// invalidate. A bare function call captures no dependency and every reader
// would go stale -- see DesktopPreferences.get() for the same reason.
property int categoryRevision: 0
// "" means match language. Anything else is an installed locale name.
function categoryValue(category: string): string {
root.categoryRevision;
return String(root.categoryValues[category] ?? "");
}
function categoryLabel(category: string): string {
const value = root.categoryValue(category);
if (value === "")
return "Match language";
const match = root.locales.find(locale => locale.value === value);
return match ? match.label : value;
}
// Pass "" to remove the override. Refused for anything that is not one of
// the five categories: this reaches a privileged command, and the caller
// does not get to name the variable being written.
function setCategory(category: string, locale: string): bool {
if (root.categories.indexOf(category) < 0)
return false;
if (locale !== "" && !root.locales.some(entry => entry.value === locale))
return false;
if (root.categoryValue(category) === locale)
return true;
// A click while a change is still applying is queued rather than
// fired: assigning running = true to a running Process is a no-op, so
// a second setCategory would be silently dropped and applyCategory's
// handler would then adopt it as though it had been applied. Same
// queue discipline as set() above.
root.requestedCategories = Object.assign({}, root.requestedCategories);
root.requestedCategories[category] = locale;
if (!applyCategory.running)
root._applyNextCategory();
return true;
}
property var requestedCategories: ({})
function _applyNextCategory(): void {
const keys = Object.keys(root.requestedCategories);
if (keys.length === 0)
return;
const category = keys[0];
const value = String(root.requestedCategories[category]);
const remaining = Object.assign({}, root.requestedCategories);
delete remaining[category];
root.requestedCategories = remaining;
applyCategory.pendingCategory = category;
applyCategory.pendingValue = value;
applyCategory.command = [root.helperPath, "set", category, value];
applyCategory.running = true;
}
function refresh(): void {
if (root.scanning)
return;
root.scanning = true;
readCurrent.running = true;
readCategories.running = true;
list.running = true;
}
@@ -95,6 +179,49 @@ Singleton {
}
}
Process {
id: readCategories
command: [root.helperPath, "overrides"]
stdout: StdioCollector {
onStreamFinished: {
try {
const parsed = JSON.parse(this.text);
root.categoryValues = (parsed && typeof parsed === "object") ? parsed : ({});
} catch (error) {
root.categoryValues = ({});
console.warn("SystemLocale: could not parse the format overrides:", error);
}
root.categoryRevision += 1;
}
}
}
Process {
id: applyCategory
property string pendingCategory: ""
property string pendingValue: ""
// A refused change -- polkit dismissed, or a locale the system does not
// have -- must not move the UI. The value is only adopted on a zero
// exit, exactly as the language apply above does.
onExited: code => {
if (code === 0) {
const next = Object.assign({}, root.categoryValues);
next[applyCategory.pendingCategory] = applyCategory.pendingValue;
root.categoryValues = next;
root.categoryRevision += 1;
root.pendingRestart = true;
root.lastError = "";
} else {
root.lastError = "The system did not accept that format. It may have needed a password.";
}
applyCategory.pendingCategory = "";
applyCategory.pendingValue = "";
root._applyNextCategory();
}
}
Process {
id: apply
@@ -30,7 +30,14 @@ Singleton {
property bool bluebubblesDetected: false
property string hyprlandVersion: ""
// Asked of the binary, never restated. The literal is only what About falls
// back to on a machine where `qs --version` cannot be run: a hardcoded
// version is a fact that goes stale the first time Quickshell updates and
// then reports the wrong number with complete confidence.
property string quickshellVersion: "0.3.0"
property bool quickshellVersionRead: false
property string lastError: ""
// Explicit seams keep reset sequencing testable without changing the live
@@ -53,6 +60,7 @@ Singleton {
property var lockBusy: function() { return LockScreen.busy; }
readonly property bool busy: monitorQuery.running || serviceQuery.running || versionQuery.running
|| quickshellVersionQuery.running
|| configWrite.running || configVerify.running || bluebubblesQuery.running
readonly property bool bluebubblesAvailable: root.bluebubblesDetected
@@ -142,6 +150,25 @@ Singleton {
}
}
// Started once, from refresh(), and never again once it has answered --
// the version cannot change under a running shell. `qs --version` prints
// "Quickshell 0.3.1 (revision ..., distributed by ...)"; only the number
// is a fact About needs, and a line that does not match leaves the
// fallback in place rather than putting a parse failure on screen.
Process {
id: quickshellVersionQuery
command: ["qs", "--version"]
stdout: StdioCollector {
onStreamFinished: {
const match = this.text.match(/Quickshell\s+(\d+(?:\.\d+)*)/);
if (match) {
root.quickshellVersion = match[1];
root.quickshellVersionRead = true;
}
}
}
}
Process {
id: bluebubblesQuery
command: ["flatpak", "info", "app.bluebubbles.BlueBubbles"]
@@ -176,6 +203,8 @@ Singleton {
serviceQuery.running = true;
if (!versionQuery.running && !root.hyprlandVersion)
versionQuery.running = true;
if (!quickshellVersionQuery.running && !root.quickshellVersionRead)
quickshellVersionQuery.running = true;
if (!bluebubblesQuery.running)
bluebubblesQuery.running = true;
}
+158
View File
@@ -48,6 +48,40 @@ Singleton {
readonly property bool rebootNeeded: root.kernel?.rebootNeeded === true
readonly property bool everChecked: root.checkedAt > 0
// How much there is to fetch, when every pending item was priced. The
// helper omits the figure for a source it could only partly price, and a
// partial total presented as the whole download understates it -- which is
// the direction that surprises somebody on a metered connection.
readonly property int downloadBytes: Number(root.dnf?.downloadBytes ?? 0)
+ Number(root.flatpak?.downloadBytes ?? 0)
+ Number(root.firmware?.downloadBytes ?? 0)
readonly property string downloadSize: root.formatBytes(root.downloadBytes)
// Decimal units, matching the Storage page and About's disk row, and the
// way both dnf and flatpak report sizes themselves.
function formatBytes(bytes: int): string {
const value = Number(bytes ?? 0);
if (!(value > 0))
return "";
const units = ["B", "KB", "MB", "GB", "TB"];
let scaled = value;
let index = 0;
while (scaled >= 1000 && index < units.length - 1) {
scaled /= 1000;
index += 1;
}
return (scaled < 10 && index > 1 ? scaled.toFixed(1) : Math.round(scaled))
+ " " + units[index];
}
function sourceDownloadSize(source: string): string {
const record = source === "dnf" ? root.dnf
: source === "flatpak" ? root.flatpak
: source === "firmware" ? root.firmware : null;
return root.formatBytes(Number(record?.downloadBytes ?? 0));
}
// What has actually been installed, newest first, from both sources at
// once. Loaded on demand rather than with the snapshot: it asks dnf and
// flatpak for their whole transaction log, which is not worth doing every
@@ -155,6 +189,100 @@ Singleton {
applyProcess.running = true;
}
// One application rather than all of them. The ID is checked against the
// last scan by the helper, so a page that has gone stale cannot ask for
// something that is not actually waiting.
function applyFlatpakApp(id: string): void {
if (applyProcess.running || !id)
return;
root.lastError = "";
root.lastApplied = null;
applyProcess.command = [root.helperPath, "apply", "flatpak", id];
applyProcess.running = true;
}
// ── Changelogs, fetched once per item ───────────────────────────────────
//
// Asking dnf what changed costs a metadata load, so an expander that
// re-asked every time it opened would spend seconds re-learning the same
// answer. Records are keyed "source/name" and kept for the life of the
// shell; the update they describe cannot change while it is pending.
property var changelogs: ({})
// Bumped whenever a record lands, and read at the top of changelogFor() so
// a binding built on that call has something to invalidate. A bare
// function call captures no dependency and every reader would go stale --
// the same reason DesktopPreferences.get() reads its revision.
property int changelogRevision: 0
// [{source, name}] waiting their turn. One at a time rather than one
// process per expander: each fetch loads repository metadata, and running
// several concurrently would multiply that work for no benefit -- nobody
// reads two changelogs at once.
property var changelogQueue: []
readonly property bool loadingChangelog: changelogProcess.running
// Returns the record for an item, or null while one is being fetched.
// Starting the fetch is a side effect on purpose: the page asks for a
// changelog by rendering one, and there is nothing else to ask.
function changelogFor(source: string, name: string): var {
root.changelogRevision;
const key = source + "/" + name;
const known = root.changelogs[key];
if (known !== undefined)
return known;
if (!source || !name)
return null;
if (changelogProcess.key === key
|| root.changelogQueue.some(entry => entry.source + "/" + entry.name === key))
return null;
root.changelogQueue = root.changelogQueue.concat([{ source: source, name: name }]);
root.pumpChangelogs();
return null;
}
function pumpChangelogs(): void {
if (changelogProcess.running || root.changelogQueue.length === 0)
return;
const next = root.changelogQueue[0];
root.changelogQueue = root.changelogQueue.slice(1);
changelogProcess.key = next.source + "/" + next.name;
changelogProcess.outputText = "";
changelogProcess.exited = false;
changelogProcess.streamFinished = false;
changelogProcess.command = [root.helperPath, "changelog", next.source, next.name];
changelogProcess.running = true;
}
function settleChangelog(): void {
if (!changelogProcess.exited || !changelogProcess.streamFinished
|| changelogProcess.key === "")
return;
root.absorbChangelog(changelogProcess.key, changelogProcess.outputText);
changelogProcess.key = "";
root.pumpChangelogs();
}
function absorbChangelog(key: string, text: string): void {
let record = { kind: "none", text: "", error: "The changelog could not be read." };
try {
const parsed = JSON.parse(text);
record = {
kind: String(parsed.kind ?? "none"),
text: String(parsed.text ?? ""),
error: String(parsed.error ?? "")
};
} catch (error) {
console.warn("Updates: could not parse changelog output:", error);
}
const next = Object.assign({}, root.changelogs);
next[key] = record;
root.changelogs = next;
root.changelogRevision += 1;
}
function setAutomaticDnf(enabled: bool): void {
if (applyProcess.running)
return;
@@ -220,4 +348,34 @@ Singleton {
id: historyProcess
stdout: StdioCollector { onStreamFinished: root.absorbHistory(this.text) }
}
Process {
id: changelogProcess
// Which record the output belongs to. Held on the process rather than
// read back from the payload so a reply that failed to name itself
// still lands under the key that was asked for, instead of silently
// going nowhere and leaving the expander spinning forever.
property string key: ""
// Exit and stream-close arrive in either order. Settling on both --
// the same pair Health.qml waits on -- is what stops a reply being
// filed under an already-cleared key, which would leave the expander
// waiting on an answer that had in fact already arrived.
property string outputText: ""
property bool exited: false
property bool streamFinished: false
stdout: StdioCollector {
onStreamFinished: {
changelogProcess.outputText = this.text;
changelogProcess.streamFinished = true;
root.settleChangelog();
}
}
onExited: (exitCode, exitStatus) => {
changelogProcess.exited = true;
root.settleChangelog();
}
}
}