This commit is contained in:
1
src/couchd/__init__.py
Normal file
1
src/couchd/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""CouchOS daemon package."""
|
||||
4
src/couchd/__main__.py
Normal file
4
src/couchd/__main__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from couchd.cli import entrypoint
|
||||
|
||||
|
||||
raise SystemExit(entrypoint())
|
||||
214
src/couchd/app.py
Normal file
214
src/couchd/app.py
Normal 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
144
src/couchd/cli.py
Normal 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
151
src/couchd/discovery.py
Normal 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
317
src/couchd/launcher.py
Normal 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
168
src/couchd/status.py
Normal 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")
|
||||
Reference in New Issue
Block a user