diff --git a/config/dot/quickshell/scripts/panama-doctor b/config/dot/quickshell/scripts/panama-doctor new file mode 100755 index 0000000..1dcf04e --- /dev/null +++ b/config/dot/quickshell/scripts/panama-doctor @@ -0,0 +1,403 @@ +#!/usr/bin/env python3 + +"""Read-only, redacted diagnostics for Panama-owned desktop functionality.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import subprocess +import sys +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Callable, Literal + +Status = Literal["ok", "warning", "error", "unconfigured"] +Group = Literal["desktop-foundation", "input-media", "integrations", "panama-tools"] +ActionKind = Literal["repair", "open", "instructions"] + + +@dataclass(frozen=True) +class Action: + kind: ActionKind + label: str + confirm: bool = False + # Only authored Settings page IDs and instruction IDs are allowed here. + # Repair commands never receive a caller-controlled target. + target: str | None = None + + +@dataclass(frozen=True) +class Check: + id: str + group: Group + title: str + status: Status + detail: str + action: Action | None = None + + +@dataclass(frozen=True) +class DoctorConfig: + root: Path + home: Path + config_home: Path + state_home: Path + runtime_dir: Path + path: str + timeout: float + + @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) + return environment + + +@dataclass(frozen=True) +class CommandResult: + state: Literal["ok", "missing", "timeout", "failed"] + stdout: str = "" + + +CHECK_ORDER = ( + "desktop.hyprland", "desktop.quickshell", "desktop.notifications", "desktop.portals", + "desktop.hyprpaper", "desktop.hypridle", "desktop.vicinae", "input.pipewire", + "input.clipboard", "input.wallpaper", "input.capture", "input.ocr", "input.brightness", + "integration.nextcloud", "integration.rustdesk", "integration.kdeconnect", "integration.bluebubbles", + "integration.home-assistant", "integration.calendar", "panama.runtime-links", "panama.vicinae-commands", + "panama.selected-terminal", "panama.selected-launcher", "panama.processes", "panama.caffeine", +) + +SYSTEMCTL_COMMANDS = { + "hyprpaper": ("systemctl", "--user", "is-active", "--quiet", "hyprpaper.service"), + "hypridle": ("systemctl", "--user", "is-active", "--quiet", "hypridle.service"), + "vicinae": ("systemctl", "--user", "is-active", "--quiet", "vicinae.service"), + "pipewire": ("systemctl", "--user", "is-active", "--quiet", "pipewire.service"), + "nextcloud": ("systemctl", "--user", "is-active", "--quiet", "nextcloud.service"), + "rustdesk": ("systemctl", "--user", "is-active", "--quiet", "rustdesk.service"), +} +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) + + +def environment_path(name: str, default: Path) -> Path: + value = os.environ.get(name) + return Path(value).expanduser() if value else default + + +def config_from_environment() -> DoctorConfig: + home = environment_path("PANAMA_DOCTOR_HOME", Path.home()) + config_home = environment_path("PANAMA_DOCTOR_CONFIG_HOME", Path(os.environ.get("XDG_CONFIG_HOME", home / ".config"))) + state_home = environment_path("PANAMA_DOCTOR_STATE_HOME", Path(os.environ.get("XDG_STATE_HOME", home / ".local/state"))) + runtime_dir = environment_path("PANAMA_DOCTOR_RUNTIME_DIR", Path(os.environ.get("XDG_RUNTIME_DIR", "/run/user/0"))) + root = environment_path("PANAMA_DOCTOR_ROOT", Path(__file__).resolve().parents[4]) + try: + timeout = float(os.environ.get("PANAMA_DOCTOR_TIMEOUT", "3")) + except ValueError: + timeout = 3.0 + return DoctorConfig(root, home, config_home, state_home, runtime_dir, os.environ.get("PANAMA_DOCTOR_PATH", os.environ.get("PATH", "")), max(0.05, min(timeout, 15.0))) + + +def run_command(command: tuple[str, ...], config: DoctorConfig, cwd: Path | None = None) -> CommandResult: + """Run an authored read-only command without reporting its unparsed output.""" + try: + completed = subprocess.run(command, capture_output=True, text=True, timeout=config.timeout, check=False, env=config.command_env, cwd=cwd) + except FileNotFoundError: + return CommandResult("missing") + except subprocess.TimeoutExpired: + return CommandResult("timeout") + if completed.returncode != 0: + return CommandResult("failed") + return CommandResult("ok", completed.stdout) + + +def executable_exists(name: str, config: DoctorConfig) -> bool: + return shutil.which(name, path=config.path) is not None + + +def action_json(action: Action) -> dict[str, object]: + result: dict[str, object] = {"kind": action.kind, "label": action.label, "confirm": action.confirm} + if action.target is not None: + result["target"] = action.target + return result + + +def check_json(check: Check) -> dict[str, object]: + result: dict[str, object] = {"id": check.id, "group": check.group, "title": check.title, "status": check.status, "detail": check.detail} + if check.action is not None: + result["action"] = action_json(check.action) + return result + + +def group_for(check_id: str) -> Group: + if check_id.startswith("desktop."): + return "desktop-foundation" + if check_id.startswith("input."): + return "input-media" + if check_id.startswith("integration."): + return "integrations" + return "panama-tools" + + +def service_check(check_id: str, title: str, service: str, config: DoctorConfig, action: Action | None = None) -> Check: + 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": + 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) + + +def ipc_target(config: DoctorConfig, target: str) -> CommandResult: + result = run_command(("qs", "ipc", "show"), config) + if result.state != "ok": + return result + return CommandResult("ok") if f"target {target}" in result.stdout.splitlines() else CommandResult("failed") + + +def simple_ipc_check(check_id: str, title: str, target: str, config: DoctorConfig) -> Check: + result = ipc_target(config, target) + if result.state == "ok": + return Check(check_id, "input-media", title, "ok", "Panama IPC target is available.") + if result.state == "missing": + return Check(check_id, "input-media", title, "error", "Required Quickshell executable is unavailable.") + if result.state == "timeout": + return Check(check_id, "input-media", title, "warning", "Panama IPC probe timed out.") + return Check(check_id, "input-media", title, "warning", "Panama IPC target is unavailable.") + + +def check_hyprland(config: DoctorConfig) -> Check: + if "hyprland" in os.environ.get("XDG_CURRENT_DESKTOP", "").casefold(): + return Check("desktop.hyprland", "desktop-foundation", "Hyprland", "ok", "Hyprland session detected.") + return Check("desktop.hyprland", "desktop-foundation", "Hyprland", "error", "Hyprland session is not active.") + + +def check_quickshell(config: DoctorConfig) -> Check: + result = run_command(("qs", "--version"), config) + repair = Action("repair", "Restart Panama", True) + if result.state == "ok" and VERSION_PATTERN.search(result.stdout): + return Check("desktop.quickshell", "desktop-foundation", "Quickshell", "ok", "Quickshell executable is available.") + if result.state == "missing": + return Check("desktop.quickshell", "desktop-foundation", "Quickshell", "error", "Required Quickshell executable is unavailable.", repair) + return Check("desktop.quickshell", "desktop-foundation", "Quickshell", "warning", "Quickshell probe returned an invalid result.", repair) + + +def check_notifications(config: DoctorConfig) -> Check: + result = ipc_target(config, "notifications") + return Check("desktop.notifications", "desktop-foundation", "Notifications", "ok", "Notification service is available.") if result.state == "ok" else Check("desktop.notifications", "desktop-foundation", "Notifications", "warning", "Notification service is unavailable.") + + +def check_portals(config: DoctorConfig) -> Check: + result = run_command(("busctl", "--user", "--no-pager", "list"), config) + if result.state == "ok" and any(line.startswith("org.freedesktop.portal.Desktop ") for line in result.stdout.splitlines()): + return Check("desktop.portals", "desktop-foundation", "Desktop portals", "ok", "Desktop portal service is available.") + detail = "Desktop portal probe timed out." if result.state == "timeout" else "Desktop portal probe is unavailable." if result.state == "missing" else "Desktop portal service is unavailable." + return Check("desktop.portals", "desktop-foundation", "Desktop portals", "warning", detail) + + +def check_brightness(config: DoctorConfig) -> Check: + result = run_command(("panama-brightness", "list"), config) + instructions = Action("instructions", "View setup instructions", target="ddc-permissions") + if result.state == "timeout": + return Check("input.brightness", "input-media", "External monitor brightness", "warning", "DDC/CI probe timed out.", instructions) + if result.state != "ok": + return Check("input.brightness", "input-media", "External monitor brightness", "warning", "DDC/CI support is unavailable.", instructions) + try: + listing = json.loads(result.stdout) + displays, error = listing["displays"], listing["error"] + if not isinstance(displays, list) or not isinstance(error, str): + raise ValueError + except (json.JSONDecodeError, KeyError, TypeError, ValueError): + return Check("input.brightness", "input-media", "External monitor brightness", "warning", "DDC/CI probe returned an invalid result.", instructions) + if error: + return Check("input.brightness", "input-media", "External monitor brightness", "warning", "No accessible DDC/CI bus.", instructions) + if not displays: + return Check("input.brightness", "input-media", "External monitor brightness", "unconfigured", "No DDC/CI display is configured.") + return Check("input.brightness", "input-media", "External monitor brightness", "ok", f"{len(displays)} DDC/CI display{'s' if len(displays) != 1 else ''} available.") + + +def check_nextcloud(config: DoctorConfig) -> Check: + if not (config.config_home / "autostart" / "nextcloud.desktop").is_file(): + return Check("integration.nextcloud", "integrations", "Nextcloud", "unconfigured", "Nextcloud autostart is not configured.") + return service_check("integration.nextcloud", "Nextcloud", "nextcloud", config, Action("open", "Open Nextcloud")) + + +def check_rustdesk(config: DoctorConfig) -> Check: + if not executable_exists("rustdesk", config): + return Check("integration.rustdesk", "integrations", "RustDesk", "unconfigured", "RustDesk is not installed.") + return service_check("integration.rustdesk", "RustDesk", "rustdesk", config, Action("open", "Open RustDesk")) + + +def check_kdeconnect(config: DoctorConfig) -> Check: + if not executable_exists("kdeconnect-cli", config): + return Check("integration.kdeconnect", "integrations", "KDE Connect", "unconfigured", "KDE Connect is not installed.") + result = run_command(("busctl", "--user", "--no-pager", "list"), config) + if result.state == "ok" and any(line.startswith("org.kde.kdeconnect ") for line in result.stdout.splitlines()): + return Check("integration.kdeconnect", "integrations", "KDE Connect", "ok", "KDE Connect service is available.") + return Check("integration.kdeconnect", "integrations", "KDE Connect", "warning", "KDE Connect service is unavailable.", Action("open", "Open KDE Connect")) + + +def check_bluebubbles(config: DoctorConfig) -> Check: + result = run_command(("flatpak", "info", "app.bluebubbles.BlueBubbles"), config) + if result.state == "ok": + return Check("integration.bluebubbles", "integrations", "BlueBubbles", "ok", "BlueBubbles is installed.") + if result.state in {"missing", "failed"}: + return Check("integration.bluebubbles", "integrations", "BlueBubbles", "unconfigured", "BlueBubbles is not installed.") + return Check("integration.bluebubbles", "integrations", "BlueBubbles", "warning", "BlueBubbles installation probe timed out.", Action("open", "Open BlueBubbles")) + + +def check_home_assistant(config: DoctorConfig) -> Check: + configured = all(name in os.environ for name in ("PANAMA_HOME_ASSISTANT_URL", "PANAMA_HOME_ASSISTANT_TOKEN")) + helper = config.config_home / "quickshell" / "scripts" / "panama-home-assistant" + if not configured: + return Check("integration.home-assistant", "integrations", "Home Assistant", "unconfigured", "Home Assistant is not configured.") + if not helper.is_file(): + return Check("integration.home-assistant", "integrations", "Home Assistant", "warning", "Home Assistant bridge is unavailable.", Action("open", "Open Home settings", target="home-phone")) + return Check("integration.home-assistant", "integrations", "Home Assistant", "ok", "Home Assistant credentials are configured.") + + +def check_calendar(config: DoctorConfig) -> Check: + result = run_command(("calendar-agenda", "probe"), config) + action = Action("open", "Open Date & Time", target="datetime") + if result.state == "missing": + return Check("integration.calendar", "integrations", "Calendar", "unconfigured", "Calendar integration is not installed.") + if result.state == "timeout": + return Check("integration.calendar", "integrations", "Calendar", "warning", "Calendar probe timed out.", action) + if result.state != "ok": + return Check("integration.calendar", "integrations", "Calendar", "warning", "Calendar probe failed.", action) + try: + enabled_sources = json.loads(result.stdout)["enabledSources"] + if not isinstance(enabled_sources, int) or isinstance(enabled_sources, bool): + raise ValueError + except (json.JSONDecodeError, KeyError, TypeError, ValueError): + return Check("integration.calendar", "integrations", "Calendar", "warning", "Calendar probe returned an invalid result.", action) + if enabled_sources <= 0: + return Check("integration.calendar", "integrations", "Calendar", "unconfigured", "No enabled calendar source is configured.") + return Check("integration.calendar", "integrations", "Calendar", "ok", f"{enabled_sources} enabled calendar source{'s' if enabled_sources != 1 else ''} configured.") + + +def check_runtime_links(config: DoctorConfig) -> Check: + names = ("hypr", "quickshell", "uwsm", "vicinae") + if any(not (config.config_home / name).is_symlink() or not (config.config_home / name).exists() for name in names): + return Check("panama.runtime-links", "panama-tools", "Panama runtime links", "warning", "One or more Panama runtime links are unavailable.", Action("repair", "Repair runtime links")) + return Check("panama.runtime-links", "panama-tools", "Panama runtime links", "ok", "Panama runtime links are available.") + + +def check_vicinae_commands(config: DoctorConfig) -> Check: + source = config.root / "config/local/share/vicinae/scripts" + installed = config.home / ".local/share/vicinae/scripts" + if source.is_dir() and installed.is_symlink() and installed.exists(): + return Check("panama.vicinae-commands", "panama-tools", "Panama commands", "ok", "Panama Vicinae commands are linked.") + return Check("panama.vicinae-commands", "panama-tools", "Panama commands", "warning", "Panama Vicinae commands are not linked.", Action("repair", "Repair command link")) + + +def executable_check(check_id: str, title: str, executable: str, config: DoctorConfig) -> Check: + if executable_exists(executable, config): + return Check(check_id, group_for(check_id), title, "ok", f"{title} executable is available.") + return Check(check_id, group_for(check_id), title, "warning", f"{title} executable is unavailable.") + + +def check_processes(config: DoctorConfig) -> Check: + counts: list[int] = [] + 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())) + elif result.state == "failed": + counts.append(0) + else: + return Check("panama.processes", "panama-tools", "Panama processes", "warning", "Process probe is unavailable.") + if any(count > 1 for count in counts): + return Check("panama.processes", "panama-tools", "Panama processes", "warning", "Duplicate Panama desktop processes detected.") + if counts[0] == 0: + return Check("panama.processes", "panama-tools", "Panama processes", "error", "Quickshell process is not running.") + return Check("panama.processes", "panama-tools", "Panama processes", "ok", "Panama desktop process counts are normal.") + + +def check_caffeine(config: DoctorConfig) -> Check: + result = run_command(("systemd-inhibit", "--list", "--no-pager", "--no-legend"), config) + 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"] + ) + 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: + return Check("panama.caffeine", "panama-tools", "Caffeine inhibitor", "ok", "One Panama Caffeine inhibitor is active.") + return Check("panama.caffeine", "panama-tools", "Caffeine inhibitor", "ok", "No Panama Caffeine inhibitor is active.") + + +def parse_version(result: CommandResult, pattern: re.Pattern[str] = VERSION_PATTERN) -> str: + match = pattern.search(result.stdout) if result.state == "ok" else None + return match.group(0) if match else "unavailable" + + +def context_versions(config: DoctorConfig) -> list[dict[str, str]]: + hyprland = run_command(("hyprctl", "version"), config) + quickshell = run_command(("qs", "--version"), config) + revision = run_command(("git", "rev-parse", "--short", "HEAD"), config, config.root) + fedora = "unavailable" + try: + match = re.search(r"^VERSION_ID=\"?([^\n\"]+)", Path("/etc/os-release").read_text(encoding="utf-8"), re.MULTILINE) + if match and re.fullmatch(r"[0-9.]+", match.group(1)): + fedora = match.group(1) + except OSError: + pass + 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 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), + "desktop.hyprpaper": lambda: service_check("desktop.hyprpaper", "Hyprpaper", "hyprpaper", config, Action("repair", "Restart Hyprpaper")), "desktop.hypridle": lambda: service_check("desktop.hypridle", "Hypridle", "hypridle", config, Action("repair", "Restart Hypridle")), "desktop.vicinae": lambda: service_check("desktop.vicinae", "Vicinae", "vicinae", config, Action("repair", "Restart Vicinae")), "input.pipewire": lambda: service_check("input.pipewire", "PipeWire", "pipewire", config), + "input.clipboard": lambda: simple_ipc_check("input.clipboard", "Clipboard", "clipboard", config), "input.wallpaper": lambda: simple_ipc_check("input.wallpaper", "Wallpaper", "wallpaper", config), "input.capture": lambda: simple_ipc_check("input.capture", "Capture", "capture", config), "input.ocr": lambda: executable_check("input.ocr", "OCR", "tesseract", config), "input.brightness": lambda: check_brightness(config), + "integration.nextcloud": lambda: check_nextcloud(config), "integration.rustdesk": lambda: check_rustdesk(config), "integration.kdeconnect": lambda: check_kdeconnect(config), "integration.bluebubbles": lambda: check_bluebubbles(config), "integration.home-assistant": lambda: check_home_assistant(config), "integration.calendar": lambda: check_calendar(config), + "panama.runtime-links": lambda: check_runtime_links(config), "panama.vicinae-commands": lambda: check_vicinae_commands(config), "panama.selected-terminal": lambda: executable_check("panama.selected-terminal", "Selected terminal", "kitty", config), "panama.selected-launcher": lambda: executable_check("panama.selected-launcher", "Selected launcher", "vicinae", config), "panama.processes": lambda: check_processes(config), "panama.caffeine": lambda: check_caffeine(config), + } + 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] + + +def snapshot(config: DoctorConfig) -> dict[str, object]: + checks = collect_checks(config) + 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]} + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description="Read-only Panama system diagnostics") + output = parser.add_mutually_exclusive_group() + output.add_argument("--json", action="store_true") + output.add_argument("--summary", action="store_true") + args = parser.parse_args(argv) + result = snapshot(config_from_environment()) + if args.summary: + summary = result["summary"] + assert isinstance(summary, dict) + print(f"Panama system health: {summary['status']} ({summary['healthy']} ok, {summary['warnings']} warnings, {summary['errors']} errors, {summary['unconfigured']} unconfigured)") + else: + print(json.dumps(result, separators=(",", ":"), sort_keys=False)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tests/quickshell/fixtures/doctor/bin/busctl b/tests/quickshell/fixtures/doctor/bin/busctl new file mode 100755 index 0000000..5fe779b --- /dev/null +++ b/tests/quickshell/fixtures/doctor/bin/busctl @@ -0,0 +1,11 @@ +#!/usr/bin/bash + +set -euo pipefail + +if [[ "${PANAMA_DOCTOR_FIXTURE_BUS:-ready}" == "missing" ]]; then + exit 1 +fi +printf '%s\n' \ + 'org.freedesktop.portal.Desktop 1000 portal' \ + 'org.kde.kdeconnect 1000 kdeconnect' \ + 'fixture clipboard body AA:BB:CC:DD:EE:FF' diff --git a/tests/quickshell/fixtures/doctor/bin/pgrep b/tests/quickshell/fixtures/doctor/bin/pgrep new file mode 100755 index 0000000..8941aa6 --- /dev/null +++ b/tests/quickshell/fixtures/doctor/bin/pgrep @@ -0,0 +1,10 @@ +#!/usr/bin/bash + +set -euo pipefail + +name="${!#}" +case ",${PANAMA_DOCTOR_FIXTURE_PROCESSES:-}," in + *",$name:duplicate,"*) printf '4101\n4102\n' ;; + *",$name:missing,"*) exit 1 ;; + *) printf '4101\n' ;; +esac diff --git a/tests/quickshell/fixtures/doctor/bin/qs b/tests/quickshell/fixtures/doctor/bin/qs new file mode 100755 index 0000000..d429301 --- /dev/null +++ b/tests/quickshell/fixtures/doctor/bin/qs @@ -0,0 +1,15 @@ +#!/usr/bin/bash + +set -euo pipefail + +case "${1:-}" in + --version) printf '%s\n' "${PANAMA_DOCTOR_FIXTURE_QS_VERSION:-Quickshell 0.2.0}" ;; + ipc) + if [[ "${PANAMA_DOCTOR_FIXTURE_QS:-ready}" == "malformed" ]]; then + printf 'fixture-secret-token AA:BB:CC:DD:EE:FF\n' + else + printf '%s\n' 'target notifications' 'target clipboard' 'target wallpaper' 'target capture' + fi + ;; + *) exit 2 ;; +esac diff --git a/tests/quickshell/fixtures/doctor/bin/systemctl b/tests/quickshell/fixtures/doctor/bin/systemctl new file mode 100755 index 0000000..6ae99d9 --- /dev/null +++ b/tests/quickshell/fixtures/doctor/bin/systemctl @@ -0,0 +1,9 @@ +#!/usr/bin/bash + +set -euo pipefail + +service="${4:-}" +case ",${PANAMA_DOCTOR_FIXTURE_STOPPED:-}," in + *",$service,"*) exit 3 ;; +esac +printf 'fixture-secret-token\n' diff --git a/tests/quickshell/fixtures/doctor/bin/systemd-inhibit b/tests/quickshell/fixtures/doctor/bin/systemd-inhibit new file mode 100755 index 0000000..8289c34 --- /dev/null +++ b/tests/quickshell/fixtures/doctor/bin/systemd-inhibit @@ -0,0 +1,10 @@ +#!/usr/bin/bash + +set -euo pipefail + +uid="$(/usr/bin/id -u)" +printf 'Panama %s fixture-user 4101 systemd-inhibit sleep:idle Caffeine block\n' "$uid" +if [[ "${PANAMA_DOCTOR_FIXTURE_CAFFEINE:-single}" == "duplicate" ]]; then + printf 'Panama %s fixture-user 4102 systemd-inhibit sleep:idle Caffeine block\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/fixtures/doctor/bin/vicinae b/tests/quickshell/fixtures/doctor/bin/vicinae new file mode 100755 index 0000000..5da7547 --- /dev/null +++ b/tests/quickshell/fixtures/doctor/bin/vicinae @@ -0,0 +1,9 @@ +#!/usr/bin/bash + +set -euo pipefail + +case "${1:-}" in + --version) printf 'Vicinae 0.26.0 fixture-secret-token\n' ;; + ping) exit 0 ;; + *) exit 2 ;; +esac diff --git a/tests/quickshell/panama-doctor-contract.sh b/tests/quickshell/panama-doctor-contract.sh new file mode 100755 index 0000000..3c36d29 --- /dev/null +++ b/tests/quickshell/panama-doctor-contract.sh @@ -0,0 +1,204 @@ +#!/usr/bin/env bash + +# The doctor is deliberately exercised through its command boundary. The +# fixture commands include sensitive-looking output so this test proves the +# report only retains explicitly parsed, non-sensitive observations. + +set -euo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +doctor="$repo_dir/config/dot/quickshell/scripts/panama-doctor" +fixture_root="$repo_dir/tests/quickshell/fixtures/doctor" + +fail() { + printf 'panama doctor contract: %s\n' "$1" >&2 + exit 1 +} + +fixture="$(mktemp -d /tmp/panama-doctor.XXXXXX)" +trap 'rm -rf "$fixture"' EXIT + +home="$fixture/home" +config_home="$home/.config" +state_home="$home/.local/state" +runtime_dir="$fixture/runtime" +bin_dir="$fixture/bin" +data_home="$home/.local/share" + +mkdir -p "$config_home" "$state_home" "$runtime_dir" "$bin_dir" "$data_home/vicinae" +cp "$fixture_root/bin/"* "$bin_dir/" +chmod +x "$bin_dir"/* + +# These are intentionally tiny stand-ins for authored executable probes. The +# named fixture scripts above cover probes whose output needs branch coverage. +for tool in hyprctl wl-paste grim tesseract kitty nextcloud rustdesk kdeconnect-cli; do + cat >"$bin_dir/$tool" <<'EOF' +#!/usr/bin/bash +case "${0##*/}" in + hyprctl) printf 'Hyprland 0.50.0\n' ;; +esac +EOF + chmod +x "$bin_dir/$tool" +done + +cat >"$bin_dir/flatpak" <<'EOF' +#!/usr/bin/bash +if [[ "${1:-}" == "info" && "${2:-}" == "app.bluebubbles.BlueBubbles" \ + && "${PANAMA_DOCTOR_FIXTURE_BLUEBUBBLES:-installed}" == "installed" ]]; then + printf 'BlueBubbles fixture-secret-token\n' + exit 0 +fi +exit 1 +EOF +chmod +x "$bin_dir/flatpak" + +cat >"$bin_dir/calendar-agenda" <<'EOF' +#!/usr/bin/bash +if [[ "${1:-}" != "probe" ]]; then + exit 2 +fi +case "${PANAMA_DOCTOR_FIXTURE_CALENDAR:-ready}" in + ready) printf '{"eds":true,"sourceRegistry":true,"enabledSources":2,"event":"fixture clipboard body"}\n' ;; + malformed) printf 'calendar AA:BB:CC:DD:EE:FF\n' ;; + timeout) /usr/bin/sleep 2; printf '{"enabledSources":2}\n' ;; + *) printf '{"eds":true,"sourceRegistry":true,"enabledSources":0}\n' ;; +esac +EOF +chmod +x "$bin_dir/calendar-agenda" + +cat >"$bin_dir/panama-brightness" <<'EOF' +#!/usr/bin/bash +case "${PANAMA_DOCTOR_FIXTURE_BRIGHTNESS:-ready}" in + ready) printf '{"displays":[{"connector":"AA:BB:CC:DD:EE:FF"}],"error":""}\n' ;; + denied) printf '{"displays":[],"error":"fixture-secret-token"}\n' ;; + malformed) printf 'fixture clipboard body\n' ;; +esac +EOF +chmod +x "$bin_dir/panama-brightness" + +mkdir -p "$config_home/autostart" +touch "$config_home/autostart/nextcloud.desktop" +for name in hypr quickshell uwsm vicinae; do + ln -s "$repo_dir/config/dot/$name" "$config_home/$name" +done +ln -s "$repo_dir/config/local/share/vicinae/scripts" "$data_home/vicinae/scripts" + +run_doctor() { + HOME="$home" \ + PATH="$bin_dir" \ + XDG_CURRENT_DESKTOP=Hyprland \ + PANAMA_HOME_ASSISTANT_URL='https://fixture.invalid' \ + PANAMA_HOME_ASSISTANT_TOKEN='fixture-secret-token' \ + PANAMA_DOCTOR_ROOT="$repo_dir" \ + PANAMA_DOCTOR_HOME="$home" \ + PANAMA_DOCTOR_CONFIG_HOME="$config_home" \ + PANAMA_DOCTOR_STATE_HOME="$state_home" \ + PANAMA_DOCTOR_RUNTIME_DIR="$runtime_dir" \ + PANAMA_DOCTOR_PATH="$bin_dir" \ + PANAMA_DOCTOR_TIMEOUT="${PANAMA_DOCTOR_TIMEOUT:-0.2}" \ + /usr/bin/python3 "$doctor" "$@" +} + +expected_order=$'desktop.hyprland\ndesktop.quickshell\ndesktop.notifications\ndesktop.portals\ndesktop.hyprpaper\ndesktop.hypridle\ndesktop.vicinae\ninput.pipewire\ninput.clipboard\ninput.wallpaper\ninput.capture\ninput.ocr\ninput.brightness\nintegration.nextcloud\nintegration.rustdesk\nintegration.kdeconnect\nintegration.bluebubbles\nintegration.home-assistant\nintegration.calendar\npanama.runtime-links\npanama.vicinae-commands\npanama.selected-terminal\npanama.selected-launcher\npanama.processes\npanama.caffeine' + +assert_schema_and_redaction() { + local snapshot="$1" + jq -e '.schemaVersion == 1 + and (.generatedAt | type == "string") + and (.summary.status | IN("healthy", "warning", "error")) + and (.context.session | IN("hyprland", "other")) + and (.context.versions | type == "array") + and ([.checks[].id] | length == 25) + and ([.checks[].id] | unique | length == 25) + and ([.checks[].status] | all(IN("ok", "warning", "error", "unconfigured")))' \ + >/dev/null <<<"$snapshot" || fail "invalid schema: $snapshot" + [[ "$(jq -r '.checks[].id' <<<"$snapshot")" == "$expected_order" ]] \ + || fail "checks are not in the authored order" + ! grep -Fq 'fixture-secret-token' <<<"$snapshot" \ + || fail 'report exposed a fixture secret' + ! grep -Fq 'fixture clipboard body' <<<"$snapshot" \ + || fail 'report exposed clipboard or calendar content' + ! grep -Fq 'AA:BB:CC:DD:EE:FF' <<<"$snapshot" \ + || fail 'report exposed a device address' +} + +check_status() { + local snapshot="$1" id="$2" expected="$3" + [[ "$(jq -r --arg id "$id" '.checks[] | select(.id == $id) | .status' <<<"$snapshot")" == "$expected" ]] \ + || fail "$id did not report $expected: $snapshot" +} + +snapshot="$(run_doctor --json)" +assert_schema_and_redaction "$snapshot" + +# A healthy systemd-backed service stays healthy. +check_status "$snapshot" desktop.hyprpaper ok + +# A missing required executable is an error rather than a crash. +mv "$bin_dir/qs" "$bin_dir/qs.off" +missing_qs="$(run_doctor --json)" +check_status "$missing_qs" desktop.quickshell error +mv "$bin_dir/qs.off" "$bin_dir/qs" + +# Optional integrations stay neutral until the user configures them. +rm "$config_home/autostart/nextcloud.desktop" +unconfigured_nextcloud="$(run_doctor --json)" +check_status "$unconfigured_nextcloud" integration.nextcloud unconfigured +touch "$config_home/autostart/nextcloud.desktop" + +# A configured integration that stopped is actionable with an authored label, +# never an application name or command derived from probe output. +stopped_nextcloud="$(PANAMA_DOCTOR_FIXTURE_STOPPED=nextcloud.service run_doctor --json)" +check_status "$stopped_nextcloud" integration.nextcloud warning +jq -e '.checks[] | select(.id == "integration.nextcloud") + | .action == {kind:"open", label:"Open Nextcloud", confirm:false}' \ + >/dev/null <<<"$stopped_nextcloud" || fail 'Nextcloud action was not authored' + +# DDC errors are classified without retaining connectors or bus addresses. +denied_brightness="$(PANAMA_DOCTOR_FIXTURE_BRIGHTNESS=denied run_doctor --json)" +check_status "$denied_brightness" input.brightness warning +jq -e '.checks[] | select(.id == "input.brightness") + | .action == {kind:"instructions", label:"View setup instructions", confirm:false, target:"ddc-permissions"}' \ + >/dev/null <<<"$denied_brightness" || fail 'DDC instructions were not authored' + +# A bounded probe timeout becomes a result, never a helper failure. +timed_calendar="$(PANAMA_DOCTOR_FIXTURE_CALENDAR=timeout PANAMA_DOCTOR_TIMEOUT=0.05 run_doctor --json)" +check_status "$timed_calendar" integration.calendar warning +jq -e '.checks[] | select(.id == "integration.calendar") + | .action == {kind:"open", label:"Open Date & Time", confirm:false, target:"datetime"}' \ + >/dev/null <<<"$timed_calendar" || fail 'calendar action was not authored' + +# Exact Panama/Caffeine inhibitor rows detect duplicates without exposing PIDs. +duplicated_caffeine="$(PANAMA_DOCTOR_FIXTURE_CAFFEINE=duplicate run_doctor --json)" +check_status "$duplicated_caffeine" panama.caffeine warning +jq -e '.checks[] | select(.id == "panama.caffeine") + | .action == {kind:"repair", label:"Release duplicate inhibitors", confirm:false}' \ + >/dev/null <<<"$duplicated_caffeine" || fail 'Caffeine repair action was not authored' +! jq -r '.checks[] | select(.id == "panama.caffeine") | .detail' <<<"$duplicated_caffeine" | grep -Eq '[0-9]{3,}' \ + || fail 'Caffeine detail exposed inhibitor PIDs' + +# Process counts use only exact authored names and never expose command lines or PIDs. +duplicated_processes="$(PANAMA_DOCTOR_FIXTURE_PROCESSES=quickshell:duplicate run_doctor --json)" +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' + +# Configured Home Assistant failures route to the exact authored Settings page. +rm "$config_home/quickshell" +mkdir -p "$config_home/quickshell/scripts" +home_assistant_failure="$(run_doctor --json)" +check_status "$home_assistant_failure" integration.home-assistant warning +jq -e '.checks[] | select(.id == "integration.home-assistant") + | .action == {kind:"open", label:"Open Home settings", confirm:false, target:"home-phone"}' \ + >/dev/null <<<"$home_assistant_failure" || fail 'Home Assistant action was not routed to home-phone' + +# Invalid probe text is contained in its own check and never copied to JSON. +malformed_calendar="$(PANAMA_DOCTOR_FIXTURE_CALENDAR=malformed run_doctor --json)" +check_status "$malformed_calendar" integration.calendar warning +assert_schema_and_redaction "$malformed_calendar" + +summary="$(run_doctor --summary)" +[[ "$summary" =~ ^Panama\ system\ health:\ (healthy|warning|error)\ \([0-9]+\ ok,\ [0-9]+\ warnings,\ [0-9]+\ errors,\ [0-9]+\ unconfigured\)$ ]] \ + || fail "summary is not concise: $summary" + +printf 'panama doctor contract: PASS\n'