A Process's exited and streamFinished signals aren't guaranteed to fire in order, and several services decided an outcome on whichever fired first: KdeConnect could report a successful file transfer as failed if exited landed before the real stdout payload; Clipboard could present a failed history query as an empty-but-healthy one; Brightness could strand the last queued write of a drag; SoundFeedback and SystemLocale could drop or misapply a rapid second toggle/click because re-arming an already-running Process is a no-op. All five now wait for both signals and let the authoritative one decide, matching the pattern HomeAssistantConfig.qml already used correctly. Health's "copy report" never enabled stdin, so it copied nothing while claiming success. Capture announced every recording as saved regardless of the recorder's actual exit code. Connectivity never restarted Bluetooth discovery when the adapter was enabled from an already-open page. CalendarAgenda left the UI in "loading" forever if its helper died at startup, and the helper itself could crash unguarded instead of reporting unavailable. Geocoding silently dropped a query typed while the previous one was still in flight. Notifs leaked tracked-but-undisplayed notifications under Do Not Disturb, and dismissAll() skipped them. Claude-Session: https://claude.ai/code/session_01E6TJUAh41HaP25MVHWkhRZ
458 lines
16 KiB
QML
458 lines
16 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"
|
|
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: ""
|
|
|
|
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: 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);
|
|
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;
|
|
}
|
|
|
|
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,
|
|
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);
|
|
}
|
|
}
|