355 lines
12 KiB
QML
355 lines
12 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
|
|
|
|
readonly property bool actionable: root.status === "warning" || root.status === "error"
|
|
readonly property bool busy: scanProcess.running || repairProcess.running
|
|
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
|
|
|
|
onExited: (exitCode, exitStatus) => root.finishRepair(exitCode, checkId, external)
|
|
}
|
|
|
|
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.exec([root.helperPath, "--repair", id, "--json"]);
|
|
return true;
|
|
}
|
|
|
|
function finishRepair(exitCode: int, id: string, external: bool): void {
|
|
const succeeded = exitCode === 0;
|
|
root.repairingId = "";
|
|
root.lastRepair = ({ id: id, succeeded: succeeded });
|
|
if (!succeeded) {
|
|
root.lastError = "Panama could not repair this item. Try refreshing or use the recommended setup steps.";
|
|
if (external) {
|
|
failureNotification.exec([
|
|
"notify-send", "-a", "Panama", "-i", "dialog-error-symbolic",
|
|
"Panama action failed", "The requested health repair could not be completed."
|
|
]);
|
|
}
|
|
}
|
|
root.refresh();
|
|
}
|
|
|
|
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,
|
|
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);
|
|
}
|
|
}
|