"""Portable Pfadregeln (PLAN.md §9, §9.1). - Alle Pfade relativ zum Anwendungsroot; keine Laufwerksbuchstaben. - Keine Abhängigkeit vom Working Directory. - Temporäre Dateien in userdata/cache, nicht im OS-Profil. - Schreibbarkeit wird beim Start geprüft (Read-only-Modus folgt Phase 1). """ from __future__ import annotations import os from dataclasses import dataclass from pathlib import Path @dataclass(frozen=True) class AppPaths: """Alle portablen Pfade je Anwendungsroot (§9-Struktur).""" root: Path @property def app(self) -> Path: return self.root / "app" @property def runtime(self) -> Path: return self.root / "runtime" @property def gstreamer_bin(self) -> Path: return self.runtime / "gstreamer" / "bin" @property def gstreamer_plugins(self) -> Path: return self.runtime / "gstreamer" / "lib" / "gstreamer-1.0" @property def web(self) -> Path: return self.root / "web" @property def projects(self) -> Path: return self.root / "projects" @property def media(self) -> Path: return self.root / "media" @property def userdata(self) -> Path: return self.root / "userdata" @property def database(self) -> Path: return self.userdata / "database" @property def cache(self) -> Path: return self.userdata / "cache" @property def identity(self) -> Path: return self.userdata / "identity" / "node_id" @property def config(self) -> Path: return self.root / "config" @property def logs(self) -> Path: return self.root / "logs" def ensure_writable(self) -> bool: """Prüft Schreibbarkeit des Roots (§9.1).""" probe = self.root / ".write_probe" try: probe.write_text("ok", encoding="ascii") probe.unlink() return True except OSError: return False def portable_environment(self) -> dict[str, str]: """Umgebungsvariablen für gebündelte GStreamer-Runtime (§9.2, ADR-0002). System-Plugins werden unterdrückt (leerer GST_PLUGIN_SYSTEM_PATH_1_0), damit ausschließlich die gebündelte, manifestierte Untermenge lädt. """ env = dict(os.environ) gs_bin = self.gstreamer_bin if gs_bin.is_dir(): path_var = "PATH" existing = env.get(path_var, "") env[path_var] = f"{gs_bin}{os.pathsep}{existing}" if existing else str(gs_bin) env["GST_PLUGIN_PATH_1_0"] = str(self.gstreamer_plugins) env["GST_PLUGIN_SYSTEM_PATH_1_0"] = "" return env def resolve_app_root(start_from: Path | None = None) -> Path: """Bestimmt den Anwendungsroot anhand der PORTABLE_MODE-Markierung (§9). Sucht vom gegebenen Pfad (Default: dieses Paket) aufwärts nach der Datei PORTABLE_MODE; im Entwickungsbaum ist das Repo-Root gemeint. """ current = Path(start_from or __file__).resolve() for candidate in [current, *current.parents]: if (candidate / "PORTABLE_MODE").is_file() or (candidate / "pyproject.toml").is_file(): return candidate raise RuntimeError("app root not found (PORTABLE_MODE or pyproject.toml missing)")