diff --git a/config/dot/quickshell/health-harness.qml b/config/dot/quickshell/health-harness.qml new file mode 100644 index 0000000..f1c6f8d --- /dev/null +++ b/config/dot/quickshell/health-harness.qml @@ -0,0 +1,21 @@ +import Quickshell +import Quickshell.Io +import QtQuick + +import qs.services + +ShellRoot { + // The production singleton still performs its delayed startup scan. The + // harness turns it off before its 2200 ms deadline so each fixture drives + // only the state transition it is asserting. + Component.onCompleted: Health.startupScanEnabled = false + + IpcHandler { + target: "health-test" + + function accept(text: string, generation: int): bool { return Health.consumeSnapshot(text, generation); } + function queue(): void { Health.refresh(); Health.refresh(); } + function status(): string { return JSON.stringify(Health.diagnostics()); } + function repair(id: string): bool { return Health.repair(id, false); } + } +} diff --git a/config/dot/quickshell/services/Health.qml b/config/dot/quickshell/services/Health.qml new file mode 100644 index 0000000..9e41771 --- /dev/null +++ b/config/dot/quickshell/services/Health.qml @@ -0,0 +1,307 @@ +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; + } + + root.snapshot = candidate; + root.checks = candidate.checks; + root.summary = candidate.summary; + root.status = candidate.summary.status; + root.acceptedGeneration = scanGeneration; + root.diagnosticUnavailable = false; + root.lastError = ""; + return true; + } + + function rejectSnapshot(message: string): void { + root.diagnosticUnavailable = true; + root.lastError = message; + } + + 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); + } +} diff --git a/config/dot/quickshell/shell.qml b/config/dot/quickshell/shell.qml index 5cb03e0..e6e77e3 100644 --- a/config/dot/quickshell/shell.qml +++ b/config/dot/quickshell/shell.qml @@ -145,6 +145,26 @@ ShellRoot { } } + IpcHandler { + target: "health" + + function refresh(): bool { return Health.refresh(); } + function status(): string { + return JSON.stringify({ + summary: Health.summary, + busy: Health.busy, + generation: Health.generation, + acceptedGeneration: Health.acceptedGeneration, + checks: Health.checks.map(check => ({ id: check.id, status: check.status })) + }); + } + function open(): void { + ShellState.openSettings("services"); + Health.refresh(); + } + function repair(id: string): bool { return Health.repair(id, true); } + } + // A small diagnostics surface doubles as a deterministic contract harness. // Real producers call StatusEvents.publish() directly; fixtures never run // unless explicitly requested over IPC by the test suite. diff --git a/tests/quickshell/health-service-contract.sh b/tests/quickshell/health-service-contract.sh new file mode 100755 index 0000000..7d646ce --- /dev/null +++ b/tests/quickshell/health-service-contract.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash + +# Health owns the accepted diagnostic snapshot. A newer unreadable response +# must degrade diagnostics without discarding the last report that Settings +# and future health surfaces will render. + +set -euo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +harness="$repo_dir/config/dot/quickshell/health-harness.qml" +service="$repo_dir/config/dot/quickshell/services/Health.qml" +warning_snapshot='{"schemaVersion":1,"generatedAt":"2026-08-18T00:00:00Z","summary":{"status":"warning","healthy":0,"warnings":2,"errors":0,"unconfigured":0},"context":{"session":"hyprland","versions":[{"id":"quickshell","version":"0.3.0"}]},"checks":[{"id":"integration.calendar","group":"integrations","title":"Calendar","status":"warning","detail":"Calendar probe timed out.","action":{"kind":"open","label":"Open Date & Time","confirm":false,"target":"datetime"}},{"id":"panama.caffeine","group":"panama-tools","title":"Caffeine","status":"warning","detail":"Duplicate inhibitors are active.","action":{"kind":"repair","label":"Release duplicate inhibitors","confirm":false}}]}' + +fail() { + printf 'health service contract: %s\n' "$1" >&2 + exit 1 +} + +[[ -f "$service" ]] || fail 'Health.qml is missing' +[[ -f "$harness" ]] || fail 'health harness is missing' + +fixture_dir="$(mktemp -d /tmp/panama-health.XXXXXX)" +helper="$fixture_dir/panama-doctor" +printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'if [[ "$1" == "--json" ]]; then' \ + ' sleep 0.2' \ + " printf '%s\\n' '$warning_snapshot'" \ + ' exit 0' \ + 'fi' \ + 'if [[ "$1" == "--repair" && "$2" == "panama.caffeine" && "$3" == "--json" ]]; then' \ + ' exit 0' \ + 'fi' \ + 'exit 2' >"$helper" +chmod +x "$helper" + +run() { PANAMA_HEALTH_HELPER="$helper" qs -p "$harness" "$@"; } +harness_pid="" + +cleanup() { + [[ -n "$harness_pid" ]] && kill "$harness_pid" >/dev/null 2>&1 || true + rm -rf "$fixture_dir" +} +trap cleanup EXIT + +PANAMA_HEALTH_HELPER="$helper" qs -p "$harness" --daemonize >/dev/null +for _ in $(seq 1 40); do + run ipc show 2>/dev/null | rg -q '^target health-test$' && break + sleep 0.1 +done +run ipc show 2>/dev/null | rg -q '^target health-test$' || fail 'test IPC target did not start' +harness_pid="$(run list | awk '/Process ID:/ { print $3; exit }')" + +[[ "$(run ipc call health-test accept "$warning_snapshot" 0)" == "true" ]] \ + || fail 'valid warning snapshot was rejected' +state="$(run ipc call health-test status)" +jq -e '.status == "warning" and .acceptedGeneration == 0 and .checks == ["integration.calendar", "panama.caffeine"] and .diagnosticUnavailable == false' \ + >/dev/null <<<"$state" || fail "valid warning snapshot was not accepted intact: $state" + +[[ "$(run ipc call health-test accept "$warning_snapshot" -1)" == "false" ]] \ + || fail 'older generation replaced the current snapshot' +state="$(run ipc call health-test status)" +jq -e '.acceptedGeneration == 0 and .checks == ["integration.calendar", "panama.caffeine"]' \ + >/dev/null <<<"$state" || fail "older generation altered accepted state: $state" + +[[ "$(run ipc call health-test accept '{not json' 1)" == "false" ]] \ + || fail 'malformed snapshot was accepted' +state="$(run ipc call health-test status)" +jq -e '.diagnosticUnavailable == true and .checks == ["integration.calendar", "panama.caffeine"]' \ + >/dev/null <<<"$state" || fail "malformed snapshot discarded the last valid checks: $state" + +before_generation="$(jq -r .generation <<<"$state")" +run ipc call health-test queue >/dev/null +state="$(run ipc call health-test status)" +jq -e '.queuedRefresh == true and .generation == ($before + 1)' --argjson before "$before_generation" \ + >/dev/null <<<"$state" || fail "two refreshes did not retain exactly one follow-up: $state" +for _ in $(seq 1 120); do + state="$(run ipc call health-test status)" + jq -e '.busy == false and .generation == ($before + 2) and .queuedRefresh == false' --argjson before "$before_generation" \ + >/dev/null <<<"$state" && break + sleep 0.1 +done +jq -e '.busy == false and .generation == ($before + 2) and .queuedRefresh == false' --argjson before "$before_generation" \ + >/dev/null <<<"$state" || fail "queued refresh did not run exactly once: $state" + +[[ "$(run ipc call health-test repair panama.caffeine)" == "true" ]] \ + || fail 'repairable check was refused' +for _ in $(seq 1 120); do + state="$(run ipc call health-test status)" + jq -e '.busy == false and .generation == ($before + 3)' --argjson before "$before_generation" \ + >/dev/null <<<"$state" && break + sleep 0.1 +done +jq -e '.busy == false and .generation == ($before + 3)' --argjson before "$before_generation" \ + >/dev/null <<<"$state" || fail "accepted repair did not trigger one rescan: $state" + +[[ "$(run ipc call health-test repair unknown.check)" == "false" ]] \ + || fail 'unknown check started a repair' +[[ "$(run ipc call health-test repair integration.calendar)" == "false" ]] \ + || fail 'non-repairable check started a repair' +state="$(run ipc call health-test status)" +jq -e '.repairingId == "" and .generation == ($before + 3)' --argjson before "$before_generation" \ + >/dev/null <<<"$state" || fail "rejected repair altered process state: $state" + +trap - EXIT +cleanup +printf 'health service contract: PASS\n'