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:
@@ -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 ?? []
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user