Files
Panama/config/dot/quickshell/services/Health.qml
T

729 lines
28 KiB
QML

pragma Singleton
// The health helper is deliberately not a state owner. This singleton accepts
// complete, typed snapshots and keeps the last valid one available while a
// later scan or repair fails.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
property var snapshot: ({})
property var checks: []
property var summary: ({ status: "healthy", healthy: 0, warnings: 0, errors: 0, unconfigured: 0 })
property string status: "healthy"
property bool diagnosticUnavailable: false
property bool queuedRefresh: false
property int generation: 0
property int acceptedGeneration: 0
property string lastError: ""
property string repairingId: ""
property var lastRepair: ({})
property string lastCopyResult: ""
property bool startupScanEnabled: true
property bool postRepairScanPending: false
readonly property bool actionable: root.status === "warning" || root.status === "error"
// ── The one health verdict ──────────────────────────────────────────────
//
// The Health page's hero and the Settings sidebar's footer each used to
// work this out themselves, and they disagreed about the order of the
// first two questions: the hero asked "is the diagnostic unavailable?"
// first, the footer asked "have we got any checks?" first. A snapshot
// rejected AFTER a good one satisfies both -- checks are still there,
// diagnosticUnavailable is true -- so the same desktop was a red "Health
// check unavailable" in the hero and a green "Desktop is healthy" in the
// footer, at the same time, six inches apart.
//
// Unavailable comes first because it is the only state that says the other
// four are not known to be true. Everything after it describes checks that
// actually ran.
readonly property string headlineState: {
if (root.diagnosticUnavailable)
return "unavailable";
if (root.checks.length === 0)
return "checking";
if (root.status === "error")
return "error";
if (root.status === "warning")
return "warning";
return "healthy";
}
// Rendered verbatim by both surfaces. The words match the ones
// HealthCheckRow puts on an individual check, so "Needs attention" means
// the same thing wherever it appears.
readonly property string headline: {
switch (root.headlineState) {
case "unavailable": return "Health check unavailable";
case "checking": return "Checking the desktop";
case "error": return "Action required";
case "warning": return "Needs attention";
default: return "Desktop is healthy";
}
}
// "danger" | "warn" | "muted" | "ok". Named rather than a color: Theme is
// a UI concern and this is a service.
readonly property string tone: {
switch (root.headlineState) {
case "unavailable":
case "error": return "danger";
case "warning": return "warn";
case "checking": return "muted";
default: return "ok";
}
}
// How many checks the headline is about. Zero unless something is wrong,
// and shared for the same reason the headline is.
readonly property int observationCount: root.summary.warnings + root.summary.errors
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"]
// 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 {
id: scanProcess
property int scanGeneration: 0
property string outputText: ""
property int exitCode: -1
property bool exited: false
property bool streamFinished: false
property bool settled: false
stdout: StdioCollector {
id: scanOutput
property int generation: 0
onStreamFinished: {
scanProcess.outputText = this.text;
scanProcess.scanGeneration = generation;
scanProcess.streamFinished = true;
root.settleScan();
}
}
onExited: (exitCode, exitStatus) => {
scanProcess.exitCode = exitCode;
scanProcess.exited = true;
root.settleScan();
}
}
Process {
id: repairProcess
property string checkId: ""
property bool external: false
property string outputText: ""
property int exitCode: -1
property bool exited: false
property bool streamFinished: false
property bool settled: false
stdout: StdioCollector {
onStreamFinished: {
repairProcess.outputText = this.text;
repairProcess.streamFinished = true;
root.settleRepair();
}
}
onExited: (exitCode, exitStatus) => {
repairProcess.exitCode = exitCode;
repairProcess.exited = true;
root.settleRepair();
}
}
Process {
id: copyProcess
property string payload: ""
stdinEnabled: true
onStarted: {
copyProcess.write(copyProcess.payload);
// wl-copy reads stdin until EOF before it exits; leaving the
// channel open (Process.write alone never closes it) would hang
// it forever waiting for more input. Disabling stdin closes the
// write side -- see HomeAssistantConfig.qml's writeProc for the
// same stdinEnabled pattern.
copyProcess.stdinEnabled = false;
}
onExited: (exitCode, exitStatus) => {
root.lastCopyResult = exitCode === 0
? "Report copied."
: "Could not copy the health report."
copyProcess.payload = ""
}
}
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
}
Timer {
id: startupScan
interval: 2200
repeat: false
running: root.startupScanEnabled
onTriggered: root.refresh()
}
function refresh(): bool {
if (root.postRepairScanPending)
return false;
if (scanProcess.running || repairProcess.running) {
root.queuedRefresh = true;
return false;
}
root.generation += 1;
scanProcess.scanGeneration = root.generation;
scanProcess.outputText = "";
scanProcess.exitCode = -1;
scanProcess.exited = false;
scanProcess.streamFinished = false;
scanProcess.settled = false;
scanOutput.generation = root.generation;
scanProcess.exec([root.helperPath, "--json"]);
return true;
}
function settleScan(): void {
if (scanProcess.settled || !scanProcess.exited || !scanProcess.streamFinished)
return;
scanProcess.settled = true;
root.finishScan(scanProcess.exitCode, scanProcess.scanGeneration, scanProcess.outputText);
}
function finishScan(exitCode: int, scanGeneration: int, text: string): void {
if (exitCode === 0)
root.consumeSnapshot(text, scanGeneration);
else
root.rejectSnapshot("Panama diagnostics could not be read. Try refreshing.");
if (!root.queuedRefresh)
return;
root.queuedRefresh = false;
root.refresh();
}
function consumeSnapshot(text: string, scanGeneration: int): bool {
if (scanGeneration < root.acceptedGeneration)
return false;
let candidate;
try {
candidate = JSON.parse(text.trim());
} catch (error) {
root.rejectSnapshot("Panama diagnostics returned an unreadable response.");
return false;
}
if (!root.validSnapshot(candidate)) {
root.rejectSnapshot("Panama diagnostics returned an invalid response.");
return false;
}
const accepted = root.safeSnapshot(candidate);
root.snapshot = accepted;
root.checks = accepted.checks;
root.summary = accepted.summary;
root.status = accepted.summary.status;
root.acceptedGeneration = scanGeneration;
root.diagnosticUnavailable = false;
root.lastError = "";
return true;
}
function rejectSnapshot(message: string): void {
root.diagnosticUnavailable = true;
root.lastError = message;
}
function safeSnapshot(candidate: var): var {
return {
schemaVersion: 1,
generatedAt: candidate.generatedAt,
summary: {
status: candidate.summary.status,
healthy: candidate.summary.healthy,
warnings: candidate.summary.warnings,
errors: candidate.summary.errors,
unconfigured: candidate.summary.unconfigured
},
context: {
session: candidate.context.session,
versions: candidate.context.versions.map(version => ({
id: version.id,
version: version.version
}))
},
checks: candidate.checks.map(check => root.safeCheck(check))
};
}
function safeCheck(candidate: var): var {
const check = {
id: candidate.id,
group: candidate.group,
title: candidate.title,
status: candidate.status,
detail: candidate.detail
};
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;
}
function safeAction(candidate: var): var {
const action = {
kind: candidate.kind,
label: candidate.label,
confirm: candidate.confirm
};
if (Object.prototype.hasOwnProperty.call(candidate, "target"))
action.target = candidate.target;
return action;
}
function repair(id: string, external: bool): bool {
if (root.busy)
return false;
const check = root.checks.find(candidate => candidate.id === id);
if (!check || !check.action || check.action.kind !== "repair")
return false;
if (external && check.action.confirm)
return false;
root.repairingId = id;
root.lastError = "";
repairProcess.checkId = id;
repairProcess.external = external;
repairProcess.outputText = "";
repairProcess.exitCode = -1;
repairProcess.exited = false;
repairProcess.streamFinished = false;
repairProcess.settled = false;
repairProcess.exec([root.helperPath, "--repair", id, "--json"]);
return true;
}
function settleRepair(): void {
if (repairProcess.settled || !repairProcess.exited || !repairProcess.streamFinished)
return;
repairProcess.settled = true;
root.finishRepair(
repairProcess.exitCode,
repairProcess.checkId,
repairProcess.external,
repairProcess.outputText
);
}
function finishRepair(exitCode: int, id: string, external: bool, text: string): void {
let candidate;
try {
candidate = JSON.parse(text.trim());
} catch (error) {
candidate = null;
}
const result = root.validRepairResult(candidate, id, exitCode)
? {
schemaVersion: 1,
checkId: candidate.checkId,
accepted: candidate.accepted,
exitCode: candidate.exitCode,
message: candidate.message
}
: {
schemaVersion: 1,
checkId: id,
accepted: false,
exitCode: exitCode,
message: "Panama returned an invalid repair response."
};
const failed = !result.accepted || result.exitCode !== 0;
root.repairingId = "";
root.lastRepair = result;
root.lastError = failed ? result.message : "";
if (failed && external && !failureNotification.running) {
failureNotification.exec([
"notify-send", "-a", "Panama", "-i", "dialog-error-symbolic",
"Panama action failed", "The requested health repair could not be completed."
]);
}
// Any refresh requested while the repair was running is satisfied by
// this one observed post-repair scan. Process exit alone never changes
// the accepted check rows.
root.queuedRefresh = false;
root.postRepairScanPending = true;
Qt.callLater(root.startPostRepairScan);
}
function startPostRepairScan(): void {
root.postRepairScanPending = false;
root.queuedRefresh = false;
root.refresh();
}
function validRepairResult(candidate: var, id: string, processExitCode: int): bool {
if (!root.plainObject(candidate))
return false;
const keys = Object.keys(candidate).sort();
const expectedKeys = ["accepted", "checkId", "exitCode", "message", "schemaVersion"];
if (keys.length !== expectedKeys.length
|| !keys.every((key, index) => key === expectedKeys[index]))
return false;
return candidate.schemaVersion === 1
&& candidate.checkId === id
&& typeof candidate.accepted === "boolean"
&& Number.isInteger(candidate.exitCode)
&& candidate.exitCode === processExitCode
&& typeof candidate.message === "string"
&& 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;
copyProcess.payload = JSON.stringify(root.snapshot, null, 2);
root.lastCopyResult = "";
// Re-arm stdin: the previous run closed it (see copyProcess.onStarted)
// and a disabled channel stays closed even after being set back to
// true mid-run, so each new run needs it explicitly re-enabled.
copyProcess.stdinEnabled = true;
copyProcess.exec(["wl-copy"]);
return true;
}
function diagnostics(): var {
return {
status: root.status,
summary: root.summary,
busy: root.busy,
diagnosticUnavailable: root.diagnosticUnavailable,
headlineState: root.headlineState,
headline: root.headline,
tone: root.tone,
queuedRefresh: root.queuedRefresh,
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 }))
};
}
function validSnapshot(candidate: var): bool {
if (!root.plainObject(candidate)
|| candidate.schemaVersion !== 1
|| typeof candidate.generatedAt !== "string" || candidate.generatedAt.length === 0
|| !root.validSummary(candidate.summary)
|| !root.validContext(candidate.context)
|| !Array.isArray(candidate.checks) || candidate.checks.length === 0)
return false;
const ids = {};
const counts = { ok: 0, warning: 0, error: 0, unconfigured: 0 };
for (const check of candidate.checks) {
if (!root.validCheck(check) || ids[check.id])
return false;
ids[check.id] = true;
counts[check.status] += 1;
}
const computedStatus = counts.error > 0 ? "error" : counts.warning > 0 ? "warning" : "healthy";
return candidate.summary.healthy === counts.ok
&& candidate.summary.warnings === counts.warning
&& candidate.summary.errors === counts.error
&& candidate.summary.unconfigured === counts.unconfigured
&& candidate.summary.status === computedStatus;
}
function validSummary(candidate: var): bool {
if (!root.plainObject(candidate) || root.overallStatuses.indexOf(candidate.status) < 0)
return false;
for (const key of ["healthy", "warnings", "errors", "unconfigured"]) {
if (!Number.isInteger(candidate[key]) || candidate[key] < 0)
return false;
}
return true;
}
function validContext(candidate: var): bool {
if (!root.plainObject(candidate)
|| ["hyprland", "other"].indexOf(candidate.session) < 0
|| !Array.isArray(candidate.versions))
return false;
return candidate.versions.every(version => root.plainObject(version)
&& typeof version.id === "string" && version.id.length > 0
&& typeof version.version === "string" && version.version.length > 0);
}
function validCheck(candidate: var): bool {
if (!root.plainObject(candidate)
|| !/^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+$/.test(candidate.id)
|| root.groups.indexOf(candidate.group) < 0
|| root.statuses.indexOf(candidate.status) < 0
|| 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);
}
function validAction(candidate: var): bool {
if (!root.plainObject(candidate)
|| ["repair", "open", "instructions"].indexOf(candidate.kind) < 0
|| typeof candidate.label !== "string" || candidate.label.length === 0
|| typeof candidate.confirm !== "boolean")
return false;
const allowedKeys = ["kind", "label", "confirm", "target"];
if (!Object.keys(candidate).every(key => allowedKeys.indexOf(key) >= 0))
return false;
const hasTarget = Object.prototype.hasOwnProperty.call(candidate, "target");
if (!hasTarget)
return true;
if (typeof candidate.target !== "string" || candidate.target.length === 0 || candidate.kind === "repair")
return false;
return candidate.kind === "open"
? root.settingsTargets.indexOf(candidate.target) >= 0
: root.instructionTargets.indexOf(candidate.target) >= 0;
}
function plainObject(value: var): bool {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
}