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); } }