729 lines
28 KiB
QML
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 colour: 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);
|
|
}
|
|
}
|