feat: build CouchOS Melee appliance
Some checks failed
ci / verify (push) Has been cancelled

This commit is contained in:
jackmhny
2026-08-03 02:52:28 +02:00
commit 04ef2a0d74
42 changed files with 3671 additions and 0 deletions

1
src/couchd/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""CouchOS daemon package."""

4
src/couchd/__main__.py Normal file
View File

@@ -0,0 +1,4 @@
from couchd.cli import entrypoint
raise SystemExit(entrypoint())

214
src/couchd/app.py Normal file
View File

@@ -0,0 +1,214 @@
"""HTTP control plane for CouchOS."""
from __future__ import annotations
import ipaddress
import hmac
import json
from dataclasses import dataclass, field
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Callable, Protocol
from urllib.parse import urlsplit
from couchd.discovery import DiscoveryError
TAILSCALE_CGNAT = ipaddress.ip_network("100.64.0.0/10")
MAX_CONTENT_LENGTH = 1024
class ConfigError(ValueError):
"""Raised when couchd configuration is invalid."""
@dataclass(frozen=True)
class Config:
bind_address: str
token: str
port: int = 8765
@dataclass(frozen=True)
class Response:
status_code: int
body: dict[str, object]
headers: dict[str, str] = field(
default_factory=lambda: {"Content-Type": "application/json; charset=utf-8"}
)
class Launcher(Protocol):
def launch_melee(self) -> dict[str, object]:
...
def open_slippi(self) -> dict[str, object]:
...
def stop(self) -> dict[str, object]:
...
class App:
def __init__(
self,
config: Config,
status_provider: Callable[[], dict[str, object]] | None = None,
launcher: Launcher | None = None,
) -> None:
self.config = config
self._status_provider = status_provider or self._default_status_provider
self._launcher = launcher
validate_bind_address(config.bind_address)
if not config.token.strip():
raise ConfigError("token must not be empty")
def handle_request(
self,
method: str,
path: str,
headers: dict[str, str] | None = None,
body: bytes | None = None,
) -> Response:
body = body or b""
headers = headers or {}
if not self._authorized(headers.get("Authorization")):
return Response(401, {"error": "missing or invalid bearer token"})
try:
content_length = int(headers.get("Content-Length", str(len(body))))
except ValueError:
return Response(400, {"error": "invalid content length"})
if content_length > MAX_CONTENT_LENGTH:
return Response(413, {"error": "request body too large"})
normalized_path = urlsplit(path).path or "/"
routes: dict[tuple[str, str], Callable[[], Response]] = {
("GET", "/status"): lambda: Response(200, self._status_provider()),
("POST", "/launch/melee"): self._launch_melee,
("POST", "/open/slippi"): self._open_slippi,
("POST", "/stop"): self._stop,
}
if method == "POST" and normalized_path in {"/launch/melee", "/open/slippi", "/stop"} and body:
return Response(400, {"error": "request body is not allowed"})
if (method, normalized_path) in routes:
try:
return routes[(method, normalized_path)]()
except (ConfigError, DiscoveryError, FileNotFoundError, PermissionError, ValueError) as exc:
return Response(409, {"error": str(exc)})
except Exception:
return Response(503, {"error": "launcher action failed"})
known_paths = {"/status", "/launch/melee", "/open/slippi", "/stop"}
if normalized_path in known_paths:
return Response(405, {"error": "method not allowed"})
return Response(404, {"error": "not found"})
def _authorized(self, authorization: str | None) -> bool:
if not authorization or not authorization.startswith("Bearer "):
return False
return hmac.compare_digest(authorization[7:], self.config.token)
def _launch_melee(self) -> Response:
if self._launcher is None:
return Response(503, {"error": "launcher unavailable"})
return Response(202, self._launcher.launch_melee())
def _open_slippi(self) -> Response:
if self._launcher is None:
return Response(503, {"error": "launcher unavailable"})
return Response(202, self._launcher.open_slippi())
def _stop(self) -> Response:
if self._launcher is None:
return Response(503, {"error": "launcher unavailable"})
return Response(202, self._launcher.stop())
@staticmethod
def _default_status_provider() -> dict[str, object]:
return {
"state": "unknown",
"pid": None,
"display": {"name": None, "mode": None},
"slippi_version": None,
"controller": {"connected": None, "name": None},
}
class CouchRequestHandler(BaseHTTPRequestHandler):
"""Bound request handler for a specific App instance."""
app: App
server_version = "couchd/0.1"
protocol_version = "HTTP/1.1"
def do_GET(self) -> None: # noqa: N802
self._handle()
def do_POST(self) -> None: # noqa: N802
self._handle()
def log_message(self, format: str, *args: object) -> None:
del format, args
def _handle(self) -> None:
try:
length = int(self.headers.get("Content-Length", "0"))
except ValueError:
self._write_response(Response(400, {"error": "invalid content length"}))
return
if length > MAX_CONTENT_LENGTH:
self._write_response(Response(413, {"error": "request body too large"}))
return
body = self.rfile.read(length) if length > 0 else b""
response = self.app.handle_request(
self.command,
self.path,
headers={key: value for key, value in self.headers.items()},
body=body,
)
self._write_response(response)
def _write_response(self, response: Response) -> None:
payload = json.dumps(response.body).encode("utf-8")
self.send_response(response.status_code)
for key, value in response.headers.items():
self.send_header(key, value)
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def make_http_server(app: App) -> ThreadingHTTPServer:
handler = type("BoundCouchRequestHandler", (CouchRequestHandler,), {"app": app})
return ThreadingHTTPServer((app.config.bind_address, app.config.port), handler)
def validate_bind_address(address: str) -> str:
ip = ipaddress.ip_address(address)
if ip.is_loopback or ip.is_unspecified:
raise ConfigError("loopback and unspecified addresses are not allowed")
if ip.version != 4 or ip not in TAILSCALE_CGNAT:
raise ConfigError("bind address must be a Tailscale IPv4 address")
return address
def load_token(token_path: str) -> str:
with open(token_path, "r", encoding="utf-8") as handle:
token = handle.read().strip()
if not token:
raise ConfigError(f"token file {token_path} is empty")
return token
def main(app: App) -> int:
server = make_http_server(app)
try:
server.serve_forever()
except KeyboardInterrupt:
return HTTPStatus.OK
finally:
server.server_close()
return HTTPStatus.OK

144
src/couchd/cli.py Normal file
View File

@@ -0,0 +1,144 @@
"""CLI entrypoints for couchd."""
from __future__ import annotations
import argparse
import json
import sys
from dataclasses import dataclass
from pathlib import Path
from couchd.app import App, Config, load_token, main as run_server
from couchd.discovery import DiscoveryConfig, SlippiDiscovery
from couchd.launcher import (
LaunchConfig,
LaunchService,
ProcessRegistry,
default_process_identity,
default_process_probe,
default_is_pid_running,
default_kill,
default_spawn,
default_terminate,
)
from couchd.status import build_default_status_collector
@dataclass(frozen=True)
class RuntimeConfig:
bind_address: str
port: int
token_file: Path
couch_home: Path
iso_path: Path | None
state_file: Path
display: str
launcher_shortcut: list[str] | None
xbox_bluetooth_address: str | None
xdg_runtime_dir: str | None
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(prog="couchd")
parser.add_argument("--config", required=True, help="Path to runtime JSON config")
subparsers = parser.add_subparsers(dest="command", required=True)
subparsers.add_parser("serve")
subparsers.add_parser("status")
subparsers.add_parser("launch-melee")
subparsers.add_parser("open-slippi")
subparsers.add_parser("stop")
return parser.parse_args(argv)
def load_runtime_config(path: Path) -> RuntimeConfig:
data = json.loads(path.read_text(encoding="utf-8"))
return RuntimeConfig(
bind_address=data["bind_address"],
port=int(data.get("port", 8765)),
token_file=Path(data["token_file"]),
couch_home=Path(data["couch_home"]),
iso_path=Path(data["iso_path"]) if data.get("iso_path") else None,
state_file=Path(data["state_file"]),
display=data.get("display", ":0"),
launcher_shortcut=data.get("launcher_shortcut"),
xbox_bluetooth_address=data.get("xbox_bluetooth_address"),
xdg_runtime_dir=data.get("xdg_runtime_dir"),
)
def build_services(runtime: RuntimeConfig) -> tuple[App, LaunchService]:
token = load_token(str(runtime.token_file))
discovery = SlippiDiscovery(
DiscoveryConfig(
couch_home=runtime.couch_home,
applications_dir=runtime.couch_home / "Applications",
launcher_fallback=runtime.launcher_shortcut,
)
)
couch_uid = runtime.couch_home.stat().st_uid
allowed_roots = discovery.allowed_roots
registry = ProcessRegistry(
is_pid_running=default_is_pid_running,
spawn=default_spawn,
terminate=default_terminate,
kill=default_kill,
state_file=runtime.state_file,
process_probe=lambda: default_process_probe(couch_uid, allowed_roots),
process_identity=lambda pid: default_process_identity(pid, couch_uid, allowed_roots),
)
launcher = LaunchService(
discovery=discovery,
registry=registry,
config=LaunchConfig(
couch_home=runtime.couch_home,
iso_path=runtime.iso_path,
display=runtime.display,
xdg_runtime_dir=runtime.xdg_runtime_dir,
launcher_shortcut=runtime.launcher_shortcut,
),
)
collector = build_default_status_collector(
discovery=discovery,
xbox_bluetooth_address=runtime.xbox_bluetooth_address,
)
def status_provider() -> dict[str, object]:
current = launcher.current_state()
return collector.collect(state=str(current["state"]), pid=current["pid"])
app = App(
Config(bind_address=runtime.bind_address, token=token, port=runtime.port),
status_provider=status_provider,
launcher=launcher,
)
return app, launcher
def command_status(app: App) -> int:
response = app.handle_request("GET", "/status", headers={"Authorization": f"Bearer {app.config.token}"})
print(json.dumps(response.body, indent=2, sort_keys=True))
return 0
def command_action(app: App, path: str) -> int:
response = app.handle_request("POST", path, headers={"Authorization": f"Bearer {app.config.token}"})
print(json.dumps(response.body, indent=2, sort_keys=True))
return 0 if response.status_code < 400 else 1
def entrypoint(argv: list[str] | None = None) -> int:
args = parse_args(argv or sys.argv[1:])
runtime = load_runtime_config(Path(args.config))
app, _launcher = build_services(runtime)
if args.command == "serve":
return int(run_server(app))
if args.command == "status":
return command_status(app)
if args.command == "launch-melee":
return command_action(app, "/launch/melee")
if args.command == "open-slippi":
return command_action(app, "/open/slippi")
if args.command == "stop":
return command_action(app, "/stop")
return 1

151
src/couchd/discovery.py Normal file
View File

@@ -0,0 +1,151 @@
"""Slippi discovery helpers."""
from __future__ import annotations
import re
import subprocess
from dataclasses import dataclass
from pathlib import Path
class AllowlistError(ValueError):
"""Raised when a discovered target is outside the allowlist."""
class DiscoveryError(RuntimeError):
"""Raised when launcher or Dolphin discovery fails."""
def allowlisted_target(
candidate: Path,
allowed_roots: list[Path],
allowed_names: set[str],
) -> Path:
resolved = candidate.resolve(strict=False)
if resolved.name not in allowed_names:
raise AllowlistError(f"disallowed target name: {resolved.name}")
if not any(
resolved == root.resolve(strict=False) or root.resolve(strict=False) in resolved.parents
for root in allowed_roots
):
raise AllowlistError(f"target {resolved} is outside allowed roots")
return resolved
@dataclass(frozen=True)
class DiscoveryConfig:
couch_home: Path
applications_dir: Path
launcher_fallback: list[str] | None = None
class SlippiDiscovery:
def __init__(self, config: DiscoveryConfig) -> None:
self._config = config
self._allowed_roots = [
config.applications_dir,
config.couch_home / ".config",
config.couch_home / ".local" / "share",
]
@property
def allowed_roots(self) -> list[Path]:
return list(self._allowed_roots)
def find_launcher(self) -> Path:
candidates = [self._config.applications_dir / "SlippiLauncher.AppImage"]
candidates.extend(
sorted(self._config.applications_dir.glob("Slippi-Launcher-*.AppImage"))
)
for candidate in reversed(candidates):
if candidate.exists():
return candidate.resolve(strict=False)
raise DiscoveryError("official Slippi Launcher AppImage not found")
def find_dolphin_binary(self) -> Path:
patterns = (
"Slippi Dolphin*",
"slippi-dolphin*",
"dolphin-emu",
"Dolphin*.AppImage",
)
matches: list[Path] = []
for root in self._allowed_roots:
if not root.exists():
continue
for pattern in patterns:
matches.extend(path for path in root.rglob(pattern) if path.is_file())
executable_matches = [path for path in matches if path.stat().st_mode & 0o111]
if not executable_matches:
raise DiscoveryError("no installed Slippi Dolphin target discovered")
executable_matches.sort(key=lambda item: item.stat().st_mtime, reverse=True)
return executable_matches[0].resolve(strict=False)
def resolve_melee_command(self, iso_path: Path) -> list[str]:
dolphin = self.find_dolphin_binary()
flags = self.inspect_supported_flags(dolphin)
exec_flag = "--exec" if "--exec" in flags else "-e" if "-e" in flags else None
if exec_flag is None:
raise DiscoveryError("installed Slippi Dolphin has no supported direct launch flag")
command = [str(dolphin), exec_flag, str(iso_path)]
if "--fullscreen" in flags:
command.append("--fullscreen")
return command
def discover_launcher_shortcuts(self) -> list[list[str]]:
shortcuts: list[list[str]] = []
seen: set[tuple[str, ...]] = set()
desktop_roots = [
self._config.couch_home / ".local" / "share" / "applications",
Path("/usr/share/applications"),
]
for root in desktop_roots:
if not root.exists():
continue
for desktop_file in root.rglob("*.desktop"):
try:
text = desktop_file.read_text(encoding="utf-8")
except OSError:
continue
lowered = text.lower()
if "slippi" not in lowered:
continue
if desktop_file.is_relative_to(root):
desktop_id = desktop_file.relative_to(root).with_suffix("")
else:
desktop_id = Path(desktop_file.stem)
identifier = "-".join(desktop_id.parts)
command = ["/usr/bin/gtk-launch", identifier]
key = tuple(command)
if key not in seen:
seen.add(key)
shortcuts.append(command)
return shortcuts
def validate_launcher_shortcut(self, shortcut: list[str]) -> list[str]:
if len(shortcut) != 2:
raise DiscoveryError("launcher_shortcut must be a discovered gtk-launch desktop entry")
if shortcut[0] not in {"gtk-launch", "/usr/bin/gtk-launch"}:
raise DiscoveryError("launcher_shortcut must use gtk-launch for a discovered desktop entry")
normalized = ["/usr/bin/gtk-launch", shortcut[1]]
if normalized not in self.discover_launcher_shortcuts():
raise DiscoveryError("configured launcher_shortcut is not among discovered launcher entries")
return normalized
def inspect_supported_flags(self, binary: Path) -> set[str]:
for flag in ("--help", "-h"):
try:
completed = subprocess.run( # noqa: S603
[str(binary), flag],
capture_output=True,
check=False,
text=True,
timeout=5,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
continue
text = "\n".join([completed.stdout, completed.stderr])
found = set(re.findall(r"(?<!\w)(--[a-z0-9-]+|-{1}[A-Za-z])(?!\w)", text))
if found:
return found
return set()

317
src/couchd/launcher.py Normal file
View File

@@ -0,0 +1,317 @@
"""Launch management for CouchOS."""
from __future__ import annotations
import json
import os
import signal
import subprocess
import time
from dataclasses import dataclass
from json import JSONDecodeError
from pathlib import Path
from typing import Callable
from couchd.discovery import DiscoveryError, SlippiDiscovery
@dataclass(frozen=True)
class LaunchRequest:
kind: str
argv: list[str]
env: dict[str, str] | None = None
cwd: str | None = None
@dataclass(frozen=True)
class RunningProcess:
pid: int
state: str
identity: str
class ProcessRegistry:
def __init__(
self,
is_pid_running: Callable[[int], bool],
spawn: Callable[[LaunchRequest], int],
terminate: Callable[[int], None] | None = None,
kill: Callable[[int], None] | None = None,
state_file: Path | None = None,
sleep: Callable[[float], None] | None = None,
process_probe: Callable[[], RunningProcess | None] | None = None,
process_identity: Callable[[int], str | None] | None = None,
) -> None:
self._is_pid_running = is_pid_running
self._spawn = spawn
self._terminate = terminate or (lambda pid: None)
self._kill = kill or (lambda pid: None)
self._sleep = sleep or time.sleep
self._state_file = state_file
self._process_probe = process_probe or (lambda: None)
self._process_identity = process_identity or (lambda pid: None)
self._pid: int | None = None
self._state = "idle"
self._identity: str | None = None
self._load_state()
def record_running(self, pid: int, state: str) -> None:
self._pid = pid
self._state = state
self._identity = self._process_identity(pid)
self._persist_state()
def current_state(self) -> dict[str, object]:
if self._pid is not None and self._matches_tracked_process(self._pid):
return {"state": self._state, "pid": self._pid}
adopted = self._process_probe()
if adopted is not None:
self._pid = adopted.pid
self._state = adopted.state
self._identity = adopted.identity
self._persist_state()
return {"state": self._state, "pid": self._pid}
self._pid = None
self._state = "idle"
self._identity = None
self._persist_state()
return {"state": "idle", "pid": None}
def launch(self, request: LaunchRequest) -> dict[str, object]:
current = self.current_state()
if current["pid"] is not None:
return {"state": "already_running", "pid": current["pid"]}
pid = self._spawn(request)
self._pid = pid
self._state = request.kind
self._identity = self._process_identity(pid)
self._persist_state()
return {"state": "launching", "pid": pid}
def stop(self, timeout_seconds: float = 8.0) -> dict[str, object]:
current = self.current_state()
pid = current["pid"]
if pid is None:
return {"state": "stopped", "pid": None}
if not self._matches_tracked_process(int(pid)):
self._clear_state()
return {"state": "stopped", "pid": None}
self._terminate(pid)
deadline = time.monotonic() + timeout_seconds
while time.monotonic() < deadline:
if not self._matches_tracked_process(pid):
self._clear_state()
return {"state": "stopped", "pid": None}
self._sleep(0.1)
if self._matches_tracked_process(pid):
self._kill(pid)
self._clear_state()
return {"state": "stopped", "pid": None}
def _load_state(self) -> None:
if self._state_file is None or not self._state_file.exists():
return
try:
data = json.loads(self._state_file.read_text(encoding="utf-8"))
except (OSError, JSONDecodeError):
self._clear_state()
return
pid = data.get("pid")
self._pid = pid if isinstance(pid, int) and pid > 0 else None
self._state = str(data.get("state", "idle"))
identity = data.get("identity")
self._identity = identity if isinstance(identity, str) and identity else None
def _persist_state(self) -> None:
if self._state_file is None:
return
self._state_file.parent.mkdir(parents=True, exist_ok=True)
payload = {"pid": self._pid, "state": self._state, "identity": self._identity}
temp_file = self._state_file.with_name(f"{self._state_file.name}.tmp")
temp_file.write_text(json.dumps(payload), encoding="utf-8")
temp_file.replace(self._state_file)
def _matches_tracked_process(self, pid: int) -> bool:
if not self._is_pid_running(pid):
return False
identity = self._process_identity(pid)
if identity is None:
return False
if self._identity is None:
self._identity = identity
self._persist_state()
return True
return identity == self._identity
def _clear_state(self) -> None:
self._pid = None
self._state = "idle"
self._identity = None
self._persist_state()
@dataclass(frozen=True)
class LaunchConfig:
couch_home: Path
iso_path: Path | None
display: str = ":0"
xdg_runtime_dir: str | None = None
launcher_shortcut: list[str] | None = None
class LaunchService:
def __init__(
self,
discovery: SlippiDiscovery,
registry: ProcessRegistry,
config: LaunchConfig,
) -> None:
self._discovery = discovery
self._registry = registry
self._config = config
def launch_melee(self) -> dict[str, object]:
request = self._build_melee_request()
return self._registry.launch(request)
def open_slippi(self) -> dict[str, object]:
launcher = self._discovery.find_launcher()
request = LaunchRequest(
kind="launcher",
argv=[str(launcher)],
env=self._session_env(),
cwd=str(self._config.couch_home),
)
return self._registry.launch(request)
def stop(self) -> dict[str, object]:
return self._registry.stop()
def current_state(self) -> dict[str, object]:
return self._registry.current_state()
def _build_melee_request(self) -> LaunchRequest:
if self._config.iso_path is None:
raise DiscoveryError("iso_path is not configured")
if not self._config.iso_path.is_file():
raise DiscoveryError(f"configured ISO does not exist: {self._config.iso_path}")
if not self._config.iso_path.resolve(strict=False).is_relative_to(
self._config.couch_home.resolve(strict=False)
):
raise DiscoveryError("configured ISO must live under /home/couch")
try:
command = self._discovery.resolve_melee_command(self._config.iso_path)
except DiscoveryError:
if not self._config.launcher_shortcut:
raise
command = self._discovery.validate_launcher_shortcut(self._config.launcher_shortcut)
return LaunchRequest(
kind="melee",
argv=command,
env=self._session_env(),
cwd=str(self._config.couch_home),
)
def _session_env(self) -> dict[str, str]:
env = {
"DISPLAY": self._config.display,
"HOME": str(self._config.couch_home),
"XAUTHORITY": str(self._config.couch_home / ".Xauthority"),
}
if self._config.xdg_runtime_dir:
env["XDG_RUNTIME_DIR"] = self._config.xdg_runtime_dir
return env
def default_is_pid_running(pid: int) -> bool:
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
return True
def default_spawn(request: LaunchRequest) -> int:
env = os.environ.copy()
if request.env:
env.update(request.env)
process = subprocess.Popen( # noqa: S603
request.argv,
cwd=request.cwd,
env=env,
preexec_fn=os.setsid,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
return process.pid
def default_terminate(pid: int) -> None:
_signal_process(pid, signal.SIGTERM)
def default_kill(pid: int) -> None:
_signal_process(pid, signal.SIGKILL)
def _signal_process(pid: int, sig: signal.Signals) -> None:
try:
process_group = os.getpgid(pid)
except ProcessLookupError:
return
if process_group == pid:
os.killpg(pid, sig)
return
os.kill(pid, sig)
def default_process_identity(
pid: int,
couch_uid: int,
allowed_roots: list[Path],
) -> str | None:
proc_dir = Path("/proc") / str(pid)
try:
stat_info = proc_dir.stat()
if stat_info.st_uid != couch_uid:
return None
exe_path = proc_dir / "exe"
resolved = exe_path.resolve(strict=True)
start_time = (proc_dir / "stat").read_text(encoding="utf-8").rsplit(")", 1)[1].split()[19]
except (FileNotFoundError, OSError):
return None
allowed = [root.resolve(strict=False) for root in allowed_roots]
resolved_root = resolved.resolve(strict=False)
if not any(root == resolved_root or root in resolved_root.parents for root in allowed):
return None
return f"{stat_info.st_uid}:{resolved_root}:{start_time}"
def default_process_probe(couch_uid: int, allowed_roots: list[Path]) -> RunningProcess | None:
matches: list[RunningProcess] = []
for proc_dir in Path("/proc").iterdir():
if not proc_dir.name.isdigit():
continue
pid = int(proc_dir.name)
identity = default_process_identity(pid, couch_uid, allowed_roots)
if identity is None:
continue
_uid, exe_path, _start_time = identity.split(":", 2)
exe_name = Path(exe_path).name.lower()
if "slippi" in exe_name and "launcher" in exe_name:
state = "launcher"
elif "dolphin" in exe_name:
state = "melee"
else:
continue
matches.append(RunningProcess(pid=pid, state=state, identity=identity))
if not matches:
return None
matches.sort(key=lambda item: item.pid, reverse=True)
return matches[0]

168
src/couchd/status.py Normal file
View File

@@ -0,0 +1,168 @@
"""Status collection helpers for couchd."""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
from typing import Callable
from couchd.discovery import DiscoveryError, SlippiDiscovery
class CommandError(RuntimeError):
"""Raised when a status probe cannot execute."""
class StatusCollector:
def __init__(
self,
display_probe: Callable[[], dict[str, object]],
slippi_probe: Callable[[], str],
dolphin_probe: Callable[[], str],
controller_probe: Callable[[], dict[str, object]],
) -> None:
self._display_probe = display_probe
self._slippi_probe = slippi_probe
self._dolphin_probe = dolphin_probe
self._controller_probe = controller_probe
def collect(self, state: str, pid: int | None) -> dict[str, object]:
status: dict[str, object] = {"state": state, "pid": pid}
try:
status["display"] = self._display_probe()
except (CommandError, TimeoutError) as exc:
status["display"] = {"mode": "unknown", "error": str(exc)}
try:
status["slippi_version"] = self._slippi_probe()
except (CommandError, TimeoutError) as exc:
status["slippi_version"] = "unknown"
status["slippi_error"] = str(exc)
try:
status["dolphin_version"] = self._dolphin_probe()
except (CommandError, TimeoutError) as exc:
status["dolphin_version"] = "unknown"
status["dolphin_error"] = str(exc)
try:
status["controller"] = self._controller_probe()
except (CommandError, TimeoutError) as exc:
status["controller"] = {"connected": "unknown", "error": str(exc)}
return status
class CommandRunner:
def run(self, argv: list[str], timeout: int = 5) -> subprocess.CompletedProcess[str]:
try:
return subprocess.run( # noqa: S603
argv,
capture_output=True,
check=False,
text=True,
timeout=timeout,
)
except FileNotFoundError as exc:
raise CommandError(f"{argv[0]} missing") from exc
def build_default_status_collector(
discovery: SlippiDiscovery,
xbox_bluetooth_address: str | None,
runner: CommandRunner | None = None,
) -> StatusCollector:
runner = runner or CommandRunner()
return StatusCollector(
display_probe=lambda: probe_display(runner),
slippi_probe=lambda: probe_slippi_version(discovery, runner),
dolphin_probe=lambda: probe_dolphin_version(discovery, runner),
controller_probe=lambda: probe_controller(xbox_bluetooth_address, runner),
)
def probe_display(runner: CommandRunner) -> dict[str, object]:
result = runner.run(["xrandr", "--query"])
if result.returncode != 0:
raise CommandError(result.stderr.strip() or "xrandr failed")
displays: dict[str, str] = {}
current_output: str | None = None
for raw_line in result.stdout.splitlines():
line = raw_line.rstrip()
if " connected" in line and not line.startswith(" "):
current_output = line.split()[0]
displays.setdefault(current_output, "unknown")
continue
if current_output is None or not line.startswith(" "):
continue
parts = line.split()
if not parts or "x" not in parts[0]:
continue
refresh = next((part.replace("*", "").replace("+", "") for part in parts[1:] if "*" in part), None)
if refresh is None:
continue
displays[current_output] = f"{parts[0]}@{refresh}"
primary_name = "HDMI-1" if "HDMI-1" in displays else next(iter(displays), None)
return {
"name": primary_name,
"mode": displays.get(primary_name, "unknown"),
"connected_outputs": displays,
}
def probe_slippi_version(discovery: SlippiDiscovery, runner: CommandRunner) -> str:
try:
launcher = discovery.find_launcher()
except DiscoveryError as exc:
raise CommandError(str(exc)) from exc
result = runner.run([str(launcher), "--version"])
output = "\n".join([result.stdout.strip(), result.stderr.strip()]).strip()
if result.returncode == 0 and output:
return output.splitlines()[0]
return launcher.stem
def probe_dolphin_version(discovery: SlippiDiscovery, runner: CommandRunner) -> str:
try:
dolphin = discovery.find_dolphin_binary()
except DiscoveryError as exc:
raise CommandError(str(exc)) from exc
result = runner.run([str(dolphin), "--version"])
output = "\n".join([result.stdout.strip(), result.stderr.strip()]).strip()
if result.returncode == 0 and output:
return output.splitlines()[0]
return dolphin.stem
def probe_controller(xbox_bluetooth_address: str | None, runner: CommandRunner) -> dict[str, object]:
if not xbox_bluetooth_address:
return {
"name": None,
"connected": "unknown",
"trusted": "unknown",
"address": None,
"error": "controller address not configured",
}
result = runner.run(["bluetoothctl", "info", xbox_bluetooth_address])
if result.returncode != 0:
raise CommandError(result.stderr.strip() or result.stdout.strip() or "bluetoothctl failed")
connected = "Connected: yes" in result.stdout
trusted = "Trusted: yes" in result.stdout
alias = "Xbox Wireless Controller"
for line in result.stdout.splitlines():
if line.strip().startswith("Alias: "):
alias = line.split(":", 1)[1].strip()
break
return {
"name": alias,
"connected": connected,
"trusted": trusted,
"address": xbox_bluetooth_address,
}
def write_status_json(path: Path, payload: dict[str, object]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")