diff --git a/config/dot/quickshell/scripts/panama-doctor b/config/dot/quickshell/scripts/panama-doctor index 1dcf04e..4004a54 100755 --- a/config/dot/quickshell/scripts/panama-doctor +++ b/config/dot/quickshell/scripts/panama-doctor @@ -54,20 +54,22 @@ class DoctorConfig: @property def command_env(self) -> dict[str, str]: - environment = dict(os.environ) - environment["PATH"] = self.path - environment["HOME"] = str(self.home) - environment["XDG_CONFIG_HOME"] = str(self.config_home) - environment["XDG_STATE_HOME"] = str(self.state_home) - environment["XDG_RUNTIME_DIR"] = str(self.runtime_dir) - environment.pop("PANAMA_HOME_ASSISTANT_URL", None) - environment.pop("PANAMA_HOME_ASSISTANT_TOKEN", None) + environment = { + "PATH": self.path, + "HOME": str(self.home), + "XDG_CONFIG_HOME": str(self.config_home), + "XDG_STATE_HOME": str(self.state_home), + "XDG_RUNTIME_DIR": str(self.runtime_dir), + } + for name in PROBE_ENVIRONMENT_KEYS: + if value := os.environ.get(name): + environment[name] = value return environment @dataclass(frozen=True) class CommandResult: - state: Literal["ok", "missing", "timeout", "failed"] + state: Literal["ok", "missing", "timeout", "failed", "unavailable"] stdout: str = "" @@ -91,6 +93,52 @@ SYSTEMCTL_COMMANDS = { PROCESS_NAMES = ("quickshell", "vicinae", "hyprpaper", "hypridle") VERSION_PATTERN = re.compile(r"\b\d+(?:\.\d+){0,3}(?:[-+._][A-Za-z0-9._-]+)?\b") REVISION_PATTERN = re.compile(r"\b[0-9a-f]{7,40}\b", re.IGNORECASE) +PROBE_ENVIRONMENT_KEYS = ( + "LANG", + "LC_ALL", + "LC_CTYPE", + "TZ", + "DBUS_SESSION_BUS_ADDRESS", + "WAYLAND_DISPLAY", + "DISPLAY", + "XAUTHORITY", + "PANAMA_DOCTOR_FIXTURE_STOPPED", + "PANAMA_DOCTOR_FIXTURE_PROCESSES", + "PANAMA_DOCTOR_FIXTURE_BUS", + "PANAMA_DOCTOR_FIXTURE_QS", + "PANAMA_DOCTOR_FIXTURE_QS_VERSION", + "PANAMA_DOCTOR_FIXTURE_BLUEBUBBLES", + "PANAMA_DOCTOR_FIXTURE_CALENDAR", + "PANAMA_DOCTOR_FIXTURE_BRIGHTNESS", + "PANAMA_DOCTOR_FIXTURE_CAFFEINE", +) +CHECK_TITLES = { + "desktop.hyprland": "Hyprland", + "desktop.quickshell": "Quickshell", + "desktop.notifications": "Notifications", + "desktop.portals": "Desktop portals", + "desktop.hyprpaper": "Hyprpaper", + "desktop.hypridle": "Hypridle", + "desktop.vicinae": "Vicinae", + "input.pipewire": "PipeWire", + "input.clipboard": "Clipboard", + "input.wallpaper": "Wallpaper", + "input.capture": "Capture", + "input.ocr": "OCR", + "input.brightness": "External monitor brightness", + "integration.nextcloud": "Nextcloud", + "integration.rustdesk": "RustDesk", + "integration.kdeconnect": "KDE Connect", + "integration.bluebubbles": "BlueBubbles", + "integration.home-assistant": "Home Assistant", + "integration.calendar": "Calendar", + "panama.runtime-links": "Panama runtime links", + "panama.vicinae-commands": "Panama commands", + "panama.selected-terminal": "Selected terminal", + "panama.selected-launcher": "Selected launcher", + "panama.processes": "Panama processes", + "panama.caffeine": "Caffeine inhibitor", +} def environment_path(name: str, default: Path) -> Path: @@ -119,6 +167,8 @@ def run_command(command: tuple[str, ...], config: DoctorConfig, cwd: Path | None return CommandResult("missing") except subprocess.TimeoutExpired: return CommandResult("timeout") + except OSError: + return CommandResult("unavailable") if completed.returncode != 0: return CommandResult("failed") return CommandResult("ok", completed.stdout) @@ -156,7 +206,7 @@ def service_check(check_id: str, title: str, service: str, config: DoctorConfig, result = run_command(SYSTEMCTL_COMMANDS[service], config) if result.state == "ok": return Check(check_id, group_for(check_id), title, "ok", "Service is active.") - if result.state == "missing": + if result.state in {"missing", "unavailable"}: return Check(check_id, group_for(check_id), title, "error", "Required system service probe is unavailable.") return Check(check_id, group_for(check_id), title, "warning", "Service is not active.", action) @@ -315,7 +365,10 @@ def check_processes(config: DoctorConfig) -> Check: for name in PROCESS_NAMES: result = run_command(("pgrep", "-u", str(os.getuid()), "-x", name), config) if result.state == "ok": - counts.append(sum(line.isdecimal() for line in result.stdout.splitlines())) + pids = result.stdout.splitlines() + if not pids or any(not pid.isdecimal() for pid in pids): + return Check("panama.processes", "panama-tools", "Panama processes", "warning", "Process probe returned an invalid result.") + counts.append(len(pids)) elif result.state == "failed": counts.append(0) else: @@ -332,10 +385,19 @@ def check_caffeine(config: DoctorConfig) -> Check: if result.state != "ok": return Check("panama.caffeine", "panama-tools", "Caffeine inhibitor", "warning", "Caffeine inhibitor probe is unavailable.") uid = str(os.getuid()) - inhibitors = sum( - 1 for line in result.stdout.splitlines() - if (parts := line.split()) and len(parts) >= 8 and parts[0] == "Panama" and parts[1] == uid and parts[3].isdecimal() and parts[-2:] == ["Caffeine", "block"] - ) + inhibitors = 0 + malformed = False + for line in result.stdout.splitlines(): + parts = line.split() + relevant = len(parts) >= 2 and parts[0] == "Panama" and parts[1] == uid and "Caffeine" in parts + if not relevant: + continue + if len(parts) >= 8 and parts[3].isdecimal() and parts[-2:] == ["Caffeine", "block"]: + inhibitors += 1 + else: + malformed = True + if malformed: + return Check("panama.caffeine", "panama-tools", "Caffeine inhibitor", "warning", "Caffeine inhibitor probe returned an invalid result.") if inhibitors > 1: return Check("panama.caffeine", "panama-tools", "Caffeine inhibitor", "warning", "Duplicate Panama Caffeine inhibitors detected.", Action("repair", "Release duplicate inhibitors")) if inhibitors == 1: @@ -362,6 +424,14 @@ def context_versions(config: DoctorConfig) -> list[dict[str, str]]: return [{"id": "hyprland", "version": parse_version(hyprland)}, {"id": "quickshell", "version": parse_version(quickshell)}, {"id": "fedora", "version": fedora}, {"id": "panama", "version": parse_version(revision, REVISION_PATTERN)}] +def unavailable_check(check_id: str) -> Check: + return Check(check_id, group_for(check_id), CHECK_TITLES[check_id], "warning", "Diagnostic probe could not be completed.") + + +def unavailable_versions() -> list[dict[str, str]]: + return [{"id": name, "version": "unavailable"} for name in ("hyprland", "quickshell", "fedora", "panama")] + + def collect_checks(config: DoctorConfig) -> list[Check]: probes: dict[str, Callable[[], Check]] = { "desktop.hyprland": lambda: check_hyprland(config), "desktop.quickshell": lambda: check_quickshell(config), "desktop.notifications": lambda: check_notifications(config), "desktop.portals": lambda: check_portals(config), @@ -372,15 +442,28 @@ def collect_checks(config: DoctorConfig) -> list[Check]: } with ThreadPoolExecutor(max_workers=8) as executor: futures = {check_id: executor.submit(probes[check_id]) for check_id in CHECK_ORDER} - return [futures[check_id].result() for check_id in CHECK_ORDER] + checks: list[Check] = [] + for check_id in CHECK_ORDER: + try: + checks.append(futures[check_id].result()) + except Exception: + checks.append(unavailable_check(check_id)) + return checks def snapshot(config: DoctorConfig) -> dict[str, object]: - checks = collect_checks(config) + try: + checks = collect_checks(config) + except Exception: + checks = [unavailable_check(check_id) for check_id in CHECK_ORDER] counts = {status: sum(check.status == status for check in checks) for status in ("ok", "warning", "error", "unconfigured")} overall: Literal["healthy", "warning", "error"] = "error" if counts["error"] else "warning" if counts["warning"] else "healthy" session = "hyprland" if "hyprland" in os.environ.get("XDG_CURRENT_DESKTOP", "").casefold() else "other" - return {"schemaVersion": 1, "generatedAt": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"), "summary": {"status": overall, "healthy": counts["ok"], "warnings": counts["warning"], "errors": counts["error"], "unconfigured": counts["unconfigured"]}, "context": {"session": session, "versions": context_versions(config)}, "checks": [check_json(check) for check in checks]} + try: + versions = context_versions(config) + except Exception: + versions = unavailable_versions() + return {"schemaVersion": 1, "generatedAt": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"), "summary": {"status": overall, "healthy": counts["ok"], "warnings": counts["warning"], "errors": counts["error"], "unconfigured": counts["unconfigured"]}, "context": {"session": session, "versions": versions}, "checks": [check_json(check) for check in checks]} def main(argv: list[str]) -> int: diff --git a/tests/quickshell/fixtures/doctor/bin/busctl b/tests/quickshell/fixtures/doctor/bin/busctl index 5fe779b..663b8fc 100755 --- a/tests/quickshell/fixtures/doctor/bin/busctl +++ b/tests/quickshell/fixtures/doctor/bin/busctl @@ -5,6 +5,10 @@ set -euo pipefail if [[ "${PANAMA_DOCTOR_FIXTURE_BUS:-ready}" == "missing" ]]; then exit 1 fi +if [[ "${PANAMA_DOCTOR_FIXTURE_BUS:-ready}" == "invalid-utf8" ]]; then + printf '\377\n' + exit 0 +fi printf '%s\n' \ 'org.freedesktop.portal.Desktop 1000 portal' \ 'org.kde.kdeconnect 1000 kdeconnect' \ diff --git a/tests/quickshell/fixtures/doctor/bin/pgrep b/tests/quickshell/fixtures/doctor/bin/pgrep index 8941aa6..0daced2 100755 --- a/tests/quickshell/fixtures/doctor/bin/pgrep +++ b/tests/quickshell/fixtures/doctor/bin/pgrep @@ -5,6 +5,7 @@ set -euo pipefail name="${!#}" case ",${PANAMA_DOCTOR_FIXTURE_PROCESSES:-}," in *",$name:duplicate,"*) printf '4101\n4102\n' ;; + *",$name:malformed,"*) printf 'not-a-pid\n' ;; *",$name:missing,"*) exit 1 ;; *) printf '4101\n' ;; esac diff --git a/tests/quickshell/fixtures/doctor/bin/systemctl b/tests/quickshell/fixtures/doctor/bin/systemctl index 6ae99d9..937a15f 100755 --- a/tests/quickshell/fixtures/doctor/bin/systemctl +++ b/tests/quickshell/fixtures/doctor/bin/systemctl @@ -2,6 +2,10 @@ set -euo pipefail +if [[ -n ${PANAMA_DOCTOR_FIXTURE_PROBE_SECRET+x} ]]; then + exit 97 +fi + service="${4:-}" case ",${PANAMA_DOCTOR_FIXTURE_STOPPED:-}," in *",$service,"*) exit 3 ;; diff --git a/tests/quickshell/fixtures/doctor/bin/systemd-inhibit b/tests/quickshell/fixtures/doctor/bin/systemd-inhibit index 8289c34..25698d9 100755 --- a/tests/quickshell/fixtures/doctor/bin/systemd-inhibit +++ b/tests/quickshell/fixtures/doctor/bin/systemd-inhibit @@ -7,4 +7,7 @@ printf 'Panama %s fixture-user 4101 systemd-inhibit sleep:idle Caffeine block\n' if [[ "${PANAMA_DOCTOR_FIXTURE_CAFFEINE:-single}" == "duplicate" ]]; then printf 'Panama %s fixture-user 4102 systemd-inhibit sleep:idle Caffeine block\n' "$uid" fi +if [[ "${PANAMA_DOCTOR_FIXTURE_CAFFEINE:-single}" == "malformed" ]]; then + printf 'Panama %s fixture-user invalid Caffeine\n' "$uid" +fi printf 'Other %s fixture-secret-token AA:BB:CC:DD:EE:FF fixture clipboard body ignore ignore\n' "$uid" diff --git a/tests/quickshell/panama-doctor-contract.sh b/tests/quickshell/panama-doctor-contract.sh index 3c36d29..d55db44 100755 --- a/tests/quickshell/panama-doctor-contract.sh +++ b/tests/quickshell/panama-doctor-contract.sh @@ -134,6 +134,22 @@ assert_schema_and_redaction "$snapshot" # A healthy systemd-backed service stays healthy. check_status "$snapshot" desktop.hyprpaper ok +# Arbitrary parent environment values are not propagated into probes. +sealed_environment="$(PANAMA_DOCTOR_FIXTURE_PROBE_SECRET=fixture-secret-token run_doctor --json)" +assert_schema_and_redaction "$sealed_environment" +check_status "$sealed_environment" desktop.hyprpaper ok + +# An OS-level launch failure is contained as a check result, never a failed +# doctor invocation or a partial snapshot. +chmod 0644 "$bin_dir/systemctl" +if ! launch_failure="$(run_doctor --json)"; then + chmod +x "$bin_dir/systemctl" + fail 'launch failure prevented the doctor from emitting JSON' +fi +chmod +x "$bin_dir/systemctl" +assert_schema_and_redaction "$launch_failure" +check_status "$launch_failure" desktop.hyprpaper error + # A missing required executable is an error rather than a crash. mv "$bin_dir/qs" "$bin_dir/qs.off" missing_qs="$(run_doctor --json)" @@ -183,6 +199,24 @@ check_status "$duplicated_processes" panama.processes warning ! jq -r '.checks[] | select(.id == "panama.processes") | .detail' <<<"$duplicated_processes" | grep -Eq '[0-9]{3,}' \ || fail 'process detail exposed a PID' +# Invalid output for a non-Quickshell authored process is not a normal zero +# count that can be hidden by the running Quickshell process. +malformed_processes="$(PANAMA_DOCTOR_FIXTURE_PROCESSES=hyprpaper:malformed run_doctor --json)" +check_status "$malformed_processes" panama.processes warning + +# Panama/Caffeine-shaped rows that do not satisfy the fixed inhibitor schema +# are unavailable rather than reported as a healthy no-inhibitor state. +malformed_caffeine="$(PANAMA_DOCTOR_FIXTURE_CAFFEINE=malformed run_doctor --json)" +check_status "$malformed_caffeine" panama.caffeine warning + +# A decoding error raised inside a concurrent probe is converted to a complete +# snapshot rather than escaping from Future.result(). +if ! invalid_probe="$(PANAMA_DOCTOR_FIXTURE_BUS=invalid-utf8 run_doctor --json)"; then + fail 'unexpected probe exception prevented the doctor from emitting JSON' +fi +assert_schema_and_redaction "$invalid_probe" +check_status "$invalid_probe" desktop.portals warning + # Configured Home Assistant failures route to the exact authored Settings page. rm "$config_home/quickshell" mkdir -p "$config_home/quickshell/scripts"