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" readonly property bool busy: scanProcess.running || repairProcess.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: ["home-phone", "datetime"] 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: "" onStarted: copyProcess.write(copyProcess.payload) onExited: (exitCode, exitStatus) => { root.lastCopyResult = exitCode === 0 ? "Report copied." : "Could not copy the health report." copyProcess.payload = "" } } Process { id: failureNotification } Timer { id: startupScan interval: 2200 repeat: false running: root.startupScanEnabled onTriggered: root.refresh() } function refresh(): bool { 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); 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; 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; } function copyReport(): bool { if (copyProcess.running) return false; copyProcess.payload = JSON.stringify(root.snapshot, null, 2); root.lastCopyResult = ""; copyProcess.exec(["wl-copy"]); return true; } function diagnostics(): var { return { status: root.status, summary: root.summary, busy: root.busy, diagnosticUnavailable: root.diagnosticUnavailable, queuedRefresh: root.queuedRefresh, generation: root.generation, acceptedGeneration: root.acceptedGeneration, repairingId: root.repairingId, lastRepair: root.lastRepair, 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; 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); } }