Add Panama system health diagnostics
This commit is contained in:
Executable
+403
@@ -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:]))
|
||||
Executable
+11
@@ -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'
|
||||
Executable
+10
@@ -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
|
||||
Executable
+15
@@ -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
|
||||
+9
@@ -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'
|
||||
+10
@@ -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"
|
||||
Executable
+9
@@ -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
|
||||
Executable
+204
@@ -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'
|
||||
Reference in New Issue
Block a user