diff --git a/assets/social-card.png b/assets/social-card.png new file mode 100644 index 0000000..84baed9 Binary files /dev/null and b/assets/social-card.png differ diff --git a/docs/python-review.md b/docs/python-review.md new file mode 100644 index 0000000..0775394 --- /dev/null +++ b/docs/python-review.md @@ -0,0 +1,744 @@ +# Python Backend Review — ProtonShift + +Scope: everything under `src/game_setup_hub/` (the FastAPI backend that Electron +spawns), plus the packaging glue in `electron/main.ts` and +`electron/scripts/vendor-python-deps.sh` that wires it up. + +- Total Python LoC (backend): **~4,647** across 22 modules +- Tests: **0** +- Linters/typers configured: `ruff`, `pyright` (standard mode, `app.py` excluded) +- Python floor: `>=3.12` (`pyproject.toml`) + +Findings are grouped by severity. Each has a concrete fix sketch. Ordering +inside each group roughly reflects impact. + +--- + +## P0 — Fix before next release + +### P0-1. Vendored native extensions fundamentally incompatible with AppImage — **PARTIALLY FIXED in v0.9.0** + +**Status (2026-04-16):** the read-only-FS bug and the `PYTHONNOUSERSITE` +inversion are both fixed. The maintenance burden of vendoring `pydantic_core` +remains until we adopt option B (`python-build-standalone`). + +What landed (see PR linked in the changelog): + +- `_vendor_compat.py` was rewritten to **mutate `sys.path` instead of the + filesystem**. When the vendored `pydantic_core/*.so` does not match the + runtime SOABI, the system + user site-packages are prepended to `sys.path` + so the system copy of pydantic_core is found first. The vendor dir stays + on `sys.path` for the rest of our pure-Python deps. Works on read-only + squashfs (AppImage) because no FS writes happen. +- `_NATIVE_PACKAGES` reduced from 5 entries to the only one that's actually + vendored (`pydantic_core`). `yaml`, `uvloop`, `httptools`, `watchfiles` + were never declared deps. +- `electron/main.ts` no longer sets `PYTHONNOUSERSITE = ""` (which CPython + reads as truthy and used to *disable* user site-packages, the opposite of + what we wanted). It now `delete`s the var so the system fallback is real. +- `python-runtime-requirements.txt` and `pyproject.toml` switched + `uvicorn[standard]` → bare `uvicorn`. That kills 3 of the 4 vendored + native extensions (uvloop, httptools, watchfiles). The localhost-only + GUI workload doesn't need the perf. +- New `tests/test_vendor_compat.py` covers: no-op when there's no vendor + dir, no-op when the `.so` is compatible, sys.path reordering when it + isn't, no `shutil.rmtree` is ever called, and a graceful warning when + no system fallback is available. + +**Net result.** Bundle size of the vendor dir drops to one native package +(`pydantic_core`). AppImage users on a system Python whose minor version +matches the build → unaffected. AppImage users on a *different* minor → +silently fall back to system pydantic_core if installed (Debian/Fedora/Arch +all ship one); otherwise get a loud, actionable warning telling them to +install it. No more silent `ModuleNotFoundError` from a no-op `rmtree`. + +**Still pending — option B.** Bundle our own Python via +`python-build-standalone` to remove the system-pydantic dependency entirely. +Tracked separately; not in this PR. + +--- + +#### Original problem description (kept for archival reference) + +The current v0.8.11 fix (`_vendor_compat.py`) tries to detect ABI-mismatched +`.so` files at import time and `shutil.rmtree` them so Python falls back to +system packages. + +Why it does not work: + +1. AppImages are **read-only squashfs**. `shutil.rmtree(pkg_dir, ignore_errors=True)` + silently fails on write-protected filesystems, leaving the incompatible + `pydantic_core/_pydantic_core.cpython-312-*.so` on `sys.path`. The user + still gets `ModuleNotFoundError` — the "fix" is a no-op. +2. Even on writable targets (`.deb`, `.rpm`), mutating the install dir at + runtime is hostile to packagers and fails the second time after an + SELinux/AppArmor denial. +3. The module fixes `sys.path` by deleting files, but `sys.path` itself is + unchanged. On the second process start, the vendor dir is still first. + +Also wrong nearby: + +- `electron/main.ts` sets `env.PYTHONNOUSERSITE = ""`. In CPython, the var + being **present at all** (non-empty or empty) is truthy: it *disables* user + site-packages. The intent was the opposite. Unset it with + `delete env.PYTHONNOUSERSITE` or do not touch it. +- `_NATIVE_PACKAGES` in `_vendor_compat.py` lists `yaml` (PyYAML), `uvloop`, + `httptools`, `watchfiles` — none of which are actually declared + dependencies. `pydantic_core` is the only real target today. +- The `.deb`/`.rpm` `python3-pydantic` dependency added for this issue only + helps Debian/Fedora-family native packages; CachyOS (Arch, where #13 was + reported) and AppImage users on any distro still break. + +**Recommended fix — pick one, in priority order** + +A. **Stop vendoring native wheels; replace with a pure-Python stack.** + - Drop `pydantic`. The API layer only uses `BaseModel` for request/response + shaping. Replace with `msgspec` (ships as a single pure-Python wheel + + optional C speedups) **or** hand-rolled `dataclasses` + `fastapi.Body` + with manual parsing — FastAPI does not require Pydantic for routing + when you type parameters as primitives/dicts. + - Drop `uvicorn[standard]` (pulls uvloop, httptools, watchfiles) in favor + of `uvicorn` (pure-Python asyncio + h11). The performance delta is + irrelevant on localhost with a GUI frontend. + - Result: vendor dir has **zero** `.so` files, AppImage works on any + Python ≥3.12. + +B. **Ship our own Python interpreter in the AppImage.** + Use `python-build-standalone` (Astral publishes portable 3.12 builds). + The AppImage becomes ~40 MB bigger but is entirely self-contained. + Build-script change only; no runtime tricks. + +C. **Build manylinux wheels per CPython minor version and pick at runtime.** + Vendor `pydantic_core-cp312-*.so`, `pydantic_core-cp313-*.so`, etc., and + resolve at import time (small sitecustomize shim). Maintenance cost grows + linearly with CPython versions. + +I recommend **A** as the short-term fix and revisit **B** when we add other +native deps. Delete `_vendor_compat.py` entirely once A lands. + +Also in `electron/main.ts`: + +```typescript +// Unset, don't set to empty string — empty is still "present" +delete env.PYTHONNOUSERSITE; +``` + +Files: `src/game_setup_hub/_vendor_compat.py`, `src/game_setup_hub/api.py:5`, +`electron/main.ts:73`, `electron/python-runtime-requirements.txt`, +`pyproject.toml:11-15`. + +--- + +### P0-2. Version drift across three places + +- `pyproject.toml` → `version = "0.8.11"` +- `src/game_setup_hub/__init__.py` → `__version__ = "0.8.8"` +- `src/game_setup_hub/api.py:329` → `FastAPI(title=..., version="0.8.8")` + +The API reports `0.8.8` in its OpenAPI schema and `/docs` while we ship +`0.8.11`. This broke once during the Bazzite fix when the version bumped but +`__init__.py` was missed. + +**Fix** + +Single source of truth. Either: + +- Read version from installed package metadata in `__init__.py` and `api.py`: + ```python + from importlib.metadata import version as _v + __version__ = _v("protonshift") + ``` +- Or delete `__version__` and `version=` and let the frontend read from the + OS/package channel. + +Also add a CI step that diffs `pyproject.toml` against `__init__.py` and fails +the build on drift. + +Files: `src/game_setup_hub/__init__.py`, `src/game_setup_hub/api.py:329`, +`pyproject.toml:7`. + +--- + +### P0-3. Missing explicit dependency on `pydantic` + +`pyproject.toml` depends on `fastapi>=0.115,<1` and assumes `pydantic` arrives +transitively. `api.py` imports `from pydantic import BaseModel` directly — if +FastAPI ever switches to an optional Pydantic, or a user installs a broken +FastAPI build, the module fails to import. + +Also `electron/python-runtime-requirements.txt` has the same omission. + +**Fix** + +Add `pydantic>=2.0` to both files. Even if we follow P0-1 option A and drop +Pydantic, fix this in the interim release so the dep graph matches imports. + +Files: `pyproject.toml:11-15`, `electron/python-runtime-requirements.txt`. + +--- + +## P1 — Correctness / safety + +### P1-1. Path traversal in several endpoints + +None of the endpoints that accept caller-supplied paths or filenames validate +them against a safe base. Examples: + +- `POST /open-path` (`api.py:899`) opens any path that exists. Low impact + because the Electron frontend is the only caller, but anyone who can reach + `127.0.0.1:` (including other local user processes) can ask the + Python backend to open arbitrary paths via `xdg-open`. +- `PUT /mangohud/per-game/{game_name}` (`mangohud.py:215`). `game_name` is + sanitized only by replacing `" "`, `"/"`, `"\"` — `..` slips through, so + `game_name="../../.bashrc"` writes an arbitrary file under + `~/.config/MangoHud/`. Limited blast radius (fixed dir) but still a bug. +- `POST /games/{app_id}/saves/restore` (`api.py:704`). `backup_path` and + `target_dir` are taken verbatim. `backup_path="/etc/shadow"` does not leak + (we only extract), but `target_dir="/home//.config/…"` can overwrite + files with a malicious zip (zip slip — `zipfile.extractall` does not + validate member paths in older Python; 3.12 does, but we should not rely + on it). +- `profiles_storage._safe_filename` (`profiles_storage.py:36`) strips most + bad chars but allows leading `.` (creates hidden files) and spaces. The + mangohud helper re-implements a weaker version. + +**Fix** + +1. Add a single `paths.py` with: + ```python + def safe_join(base: Path, untrusted: str) -> Path: ... + def sanitize_filename(name: str) -> str: ... + ``` + Both raise `ValueError` on escape. +2. Apply at every API boundary that takes user paths or names. +3. Gate `/open-path` and `/open-uri` behind an allowlist of prefixes + (Steam root, compatdata, profiles dir) or a shared secret between Electron + and Python (already recommended in P1-2). + +Files: `api.py:899-927`, `mangohud.py:215-218`, `saves.py:133`, +`profiles_storage.py:36`. + +--- + +### P1-2. Unauthenticated localhost API with `CORS *` + +`api.py:329-330`: + +```python +app = FastAPI(title="ProtonShift API", version="0.8.8") +app.add_middleware(CORSMiddleware, allow_origins=["*"], ...) +``` + +The backend binds `127.0.0.1` so the external attack surface is small, but: + +- Any **local** process (browser tabs included, via DNS rebinding to + `127.0.0.1`) can issue state-changing requests. +- `allow_origins=["*"]` defeats the browser's same-origin check, which is the + one thing that would block the rebind scenario. + +**Fix** + +1. Have Electron generate a random 32-byte hex token at startup, pass it to + Python via env var (`PROTONSHIFT_API_TOKEN`), and include it as a header + on every renderer request. +2. Add a FastAPI dependency that validates `X-Protonshift-Token` and rejects + other requests with 401. +3. Narrow `allow_origins` to the Electron static-renderer origin + (`http://127.0.0.1:`). + +Files: `api.py:329-330` plus a new `auth.py` dep. + +--- + +### P1-3. VDF writes are not atomic and not Steam-aware + +`vdf_config.set_launch_options` and `set_compat_tool` do: + +```python +with open(config_path, "w", encoding="utf-8", newline="\n") as f: + vdf.dump(data, f, pretty=True) +``` + +Two problems: + +1. **No atomic rename.** A crash (or OS kill) mid-write leaves + `localconfig.vdf` truncated. For Steam, that can wipe *all* launch options + and compat tools. +2. **Steam races us.** `is_steam_running()` exists (`steam.py:12`) but nothing + enforces it before writing. If the user edits launch options while Steam + is running, Steam will overwrite our file on shutdown. + +**Fix** + +1. Atomic write helper: + ```python + def atomic_write_text(path: Path, data: str) -> None: + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(data, encoding="utf-8", newline="\n") + os.replace(tmp, path) + ``` + Use it everywhere we overwrite a user-owned config (`vdf_config`, + `env_vars`, `mangohud`, `profiles_storage`, `fixes`, `heroic_config`). +2. In `PUT /games/{app_id}/launch-options` and friends, return `409 Conflict` + with a structured error when `is_steam_running()` is true. Let the + frontend show the existing "Close Steam" dialog instead of the backend + silently writing a doomed file. + +Files: `vdf_config.py:62-122`, `env_vars.py:62-67`, `mangohud.py:197-212`, +`profiles_storage.py:40-48`, `fixes.py:82-98`, `heroic_config.py:85-90`. + +--- + +### P1-4. `get_power_profiles` returns a hardcoded list, ignoring parsed data + +`gpu.py:104-126`: + +```python +r = subprocess.run(["powerprofilesctl", "list"], ...) +if r.returncode == 0: + profiles = [] + for line in r.stdout.split("\n"): + if "*" in line: + m = re.search(r"[\*]\s*([\w-]+)", line) + if m: + profiles.append(m.group(1)) + if profiles: + return ["performance", "balanced", "power-saver"] # <-- wrong +``` + +The `profiles` list is built and then **thrown away**; the function returns +a constant. Also the regex matches the *current* profile (marked `*`), not +every available profile, so `profiles` has at most one entry anyway. + +**Fix** + +```python +r = subprocess.run(["powerprofilesctl", "list"], ...) +if r.returncode == 0: + profiles = [ + m.group(1) for line in r.stdout.splitlines() + if (m := re.match(r"\s*\*?\s*([\w-]+):", line)) + ] + if profiles: + return profiles +``` + +Then remove the hardcoded fallback or keep it only when parsing yields zero. + +Files: `gpu.py:104-126`. + +--- + +### P1-5. SDL controller GUID is fabricated + +`controllers.get_sdl_mapping` (`controllers.py:119-142`) builds a GUID as: + +```python +guid = f"{vendor_id:>04s}{product_id:>04s}".ljust(32, "0") +``` + +Real SDL2 GUIDs are 16 bytes LE: `bus(2) crc(2) vendor(2) 0000 product(2) 0000 version(2) 0000`. The stub we emit will not match in SDL and the +generated mapping will never apply. This is a latent bug dressed as a +feature. + +**Fix** + +Either: +- Read the canonical GUID from `/sys/class/input/jsN/device/id/{bustype,vendor,product,version}` and assemble the real 16-byte LE blob, or +- Remove the endpoint until we can do it correctly; the frontend can link + to upstream `sdl2-jstest` / `antimicrox`. + +Files: `controllers.py:119-142`, `api.py:622-629`. + +--- + +### P1-6. `BUILTIN_PROTON` constant is dead; duplicate list in function body + +`steam.py:188-201`: + +```python +BUILTIN_PROTON = ["proton_experimental", "proton_9_0", ...] # never used + +def get_available_proton_tools(...) -> list[str]: + tools: list[str] = ["", "proton_experimental", "proton_9_0", ...] + ... +``` + +The module-level `BUILTIN_PROTON` constant is imported by nothing; the +function hardcodes a slightly different list. When Proton 10 ships, we will +update one of the two. + +**Fix** + +Delete `BUILTIN_PROTON` (or use it in the function). Add a comment pointing +at the Steam-side source of truth. + +Files: `steam.py:188-201`. + +--- + +### P1-7. `gamescope.build_gamescope_cmd` returns a shell-ready string + +`gamescope.py:32-66` returns `" ".join(parts)` with `opts.extra_args` +concatenated unescaped. The string is surfaced in `/gamescope/build-cmd` +intended for copy-paste into Steam launch options, so it is not an +`exec`-style shell-injection issue, but: + +- The frontend might eventually execute the string (we've already seen this + pattern in `run_protontricks`). +- Users who paste `"; rm -rf ~"` in `extra_args` get surprising text back. + +**Fix** + +Return both `argv: list[str]` and `command: str` (quoted via `shlex.join`). +Frontend shows `command`; any executor uses `argv`. + +Files: `gamescope.py:32-66`, `api.py:806-823`. + +--- + +## P2 — Maintainability / quality + +### P2-1. No tests, anywhere + +4,647 LoC of Python without a single test file. The modules touching the +largest blast radius (`vdf_config`, `_vendor_compat`, `steam`, `heroic`, +`tool_check`, `env_vars`, path-sanitization helpers) are the ones that are +hardest to reason about by reading. + +**Fix — minimum viable test suite** + +Add `pytest` + a `tests/` tree with: + +1. **VDF round-trips**: read a fixture `localconfig.vdf` → mutate → write → + re-read, assert launch options preserved. Include the "Steam writes new + keys we don't know about" case (verify we don't strip them). +2. **Tool detection**: `tool_check.find_tool` with monkey-patched `shutil.which` + and temporary directories for the fallback branch. +3. **Path sanitization**: once P1-1 lands, test `safe_join` against a zip slip + corpus. +4. **Env file**: `env_vars.read_conf` / `write_conf` with quoted values, + escapes, comments. +5. **VS_FIXEDFILEINFO parse**: drop a minimal PE fixture and confirm DXVK + version detection. +6. **API smoke**: `fastapi.testclient.TestClient(app)` hitting every GET + endpoint against a fixture Steam root (pytest tmp_path). + +Start with (1), (2), (4), (6) — that's ~4 hours of work and covers the +regression paths we've already hit twice. + +--- + +### P2-2. `app.py` (GTK4) is 1053 lines of dead weight + +`src/game_setup_hub/app.py` is the old GTK4 UI, excluded from pyright, and +no longer referenced by the Electron frontend. `pyproject.toml:21` still +declares `protonshift = "game_setup_hub.app:main"` so `pip install` creates a +launcher for it, but the README/packaging flow only uses the Electron app. + +It imports `gi` unconditionally. Anyone who installs the wheel on a system +without GTK4 gets an `ImportError` they cannot debug. + +**Fix** + +1. Decide: do we still ship the GTK app? If no, delete `app.py`, `__main__.py`, + and remove the `protonshift` console script. +2. If yes, move it under `src/game_setup_hub/gtk/` and gate the import + (`try: import gi; except ImportError: ...`). + +Either way, stop silently compiling 1k LoC that pyright refuses to look at. + +Files: `src/game_setup_hub/app.py`, `src/game_setup_hub/__main__.py`, +`pyproject.toml:20-22`. + +--- + +### P2-3. Silent `except Exception: pass` everywhere + +Grep: 8 bare `except Exception` in `api.py` and `app.py`, plus ~40 +`except OSError: pass` / `except (..., json.JSONDecodeError): return None` +across the backend. Every one of these turns into an empty list / empty +string in the UI with no logs to tell us which path failed. + +Examples: + +- `api.py:418-419`: `get_game_launch_options` swallows any VDF parse error + and returns `""`. The user edits a file that was already corrupt and sees + no warning. +- `heroic.py:68`, `heroic.py:157`: swallow every `KeyError` under + `json.JSONDecodeError`. A schema change in Heroic == silent empty list. +- `profiles_storage.py:46`, `.64`, `.74`: write failures silently return + `False` → UI says "saved" and nothing is on disk. + +**Fix** + +1. Add a module-level logger per file: + ```python + import logging + log = logging.getLogger(__name__) + ``` +2. Configure once in `api.py` `cli()`: + ```python + logging.basicConfig( + level=os.environ.get("PROTONSHIFT_LOG", "INFO"), + format="%(levelname)s %(name)s: %(message)s", + ) + ``` +3. In every `except`, either log at `warning` (recoverable) or re-raise with + context. Electron already pipes stderr to the dev console, so these show + up for free. +4. For `Status`/boolean return values, return a `tuple[bool, str]` or a + typed error so API endpoints can surface messages instead of bare 500s. + +--- + +### P2-4. `api.py` and `app.py` are both 1k+ lines + +`api.py` has 35+ endpoints plus 30+ Pydantic models plus helpers, in one +file. Navigating requires search. + +**Fix** + +Split by concern under `src/game_setup_hub/api/`: + +``` +api/__init__.py # FastAPI app, middleware, CLI entry +api/models.py # Pydantic response/request types +api/deps.py # auth, _ensure_steam, _vdf_lock, etc. +api/routes/steam.py +api/routes/heroic.py +api/routes/lutris.py +api/routes/mangohud.py +api/routes/gamescope.py +api/routes/system.py # gpu, power, display, controllers +api/routes/profiles.py +api/routes/saves.py +api/routes/system_io.py # open-path, open-uri +``` + +Each routes file uses `APIRouter`. The top-level `api/__init__.py` wires +them up. This maps to a fresh Electron dev's mental model ("I'm looking for +the MangoHud endpoint" → obvious file). + +--- + +### P2-5. Duplicate `_dir_size` helper in three modules + +`saves.py:29-40`, `prefix.py:21-33`, `shader_cache.py:18-29` — same function, +subtle differences (one uses `stat()`, two use `lstat()`; symlink-following +differs). Same bug fix would have to land in three places. + +**Fix** + +Move to a new `src/game_setup_hub/fsutil.py` exposing `dir_size(path, +follow_symlinks=False) -> int` and a matching `human_size(bytes) -> str` +(which is also duplicated between `api.py:598-603` and the frontend). + +--- + +### P2-6. `discover_games()` called on every request + +`_ensure_steam()` caches only the Steam root, not the games list. Every call +to `/games`, `/games/.../saves`, `/games/.../prefix-info`, `/games/.../shader-cache`, +`/games/.../fixes`, and `/games/.../launch-options` re-parses every +`appmanifest_*.acf` on disk. + +A Steam library with 500 games means ~500 VDF parses per page load. + +**Fix** + +Cache the result with a TTL (`functools.lru_cache` + a tiny wrapper that +invalidates on write, or `cachetools.TTLCache(maxsize=1, ttl=30)`). Expose +`POST /games/rescan` for the manual refresh button. + +Files: `api.py:345-352, 397-408, 660-678, 861-896`. + +--- + +### P2-7. `_human_size` lives in `api.py`, with a type-ignore + +`api.py:598-603`: + +```python +def _human_size(size_bytes: int) -> str: + for unit in ("B", "KB", "MB", "GB", "TB"): + if abs(size_bytes) < 1024: + return f"{size_bytes:.1f} {unit}" + size_bytes /= 1024 # type: ignore[assignment] + return f"{size_bytes:.1f} PB" +``` + +The `type: ignore` exists because we mutate the `int` parameter into a +`float`. Type signature should be `float` (accepts int via widening) and the +loop variable should be local. Also: move to `fsutil.py` (P2-5). + +--- + +### P2-8. Mixed CPython features and "defensive" `from __future__ import annotations` + +Every module has `from __future__ import annotations`, required in 3.8-3.9 +but unnecessary under `requires-python = ">=3.12"`. Pyright sees them as +strings, which hides some errors. Combined with the mix of `dict[str, str]` +and `Dict[str, str]`... actually it's clean `dict[str, str]` everywhere. + +**Fix** + +Keep `from __future__ import annotations` only where it's needed for forward +refs or self-types; remove elsewhere. Optional, but it is a constant source +of pyright confusion. + +--- + +### P2-9. Cross-module private imports + +`heroic_config.py:10` imports `heroic._resolve_heroic_root`. The underscore +prefix is a contract: this is private to `heroic.py`. Either promote +`resolve_heroic_root` to public or move the helper into a shared +`heroic_paths.py`. + +Files: `heroic_config.py:10`, `heroic.py:30-34`. + +--- + +### P2-10. `print("PORT:…")` IPC is brittle + +`api.py:1046-1058` prints a port to stdout and relies on +`electron/main.ts:90-96` regex-matching `/PORT:(\d+)/`. If uvicorn's +`log_level="warning"` ever writes to stdout (it shouldn't but has in +past releases), the match consumes the wrong line. + +**Fix** + +- Send the port via a Unix domain socket opened by Electron before spawning + Python, passed as a file descriptor or path in env. Python writes `{port}` and closes. +- Or: Electron picks a free port and passes it via `--port`, skipping the + discovery dance. + +Second option is one line. + +Files: `api.py:1046-1058`, `electron/main.ts:81-116`. + +--- + +## P3 — Minor / style + +- `api.py:907, 910, 921, 924, 1013, 1016, 1019`: inline `import subprocess` + inside handlers. Move to module top. +- `api.py:595`: `return StatusResponse(success=ok)` after `raise HTTPException` + is unreachable — dead code. +- `profiles_storage.py:36`: `_safe_filename` accepts leading `.` → creates + hidden files. Prepend `profile_` or strip leading dots. +- `controllers.py:124-128`: two identical `guid = ... .ljust(32, "0")` + branches. +- `heroic.py:10-13`: `HEROIC_ROOTS` picks the first that exists. If a user + has both the native install and Flatpak, order determines winner silently. + Prefer whichever has a non-empty `GamesConfig/` or return both. +- `tool_check.py:65`: `@lru_cache(maxsize=32)` on `find_tool` means a user + installing MangoHud mid-session still sees "not installed" until restart. + Low frequency in practice; document it. +- `steam.py:89`: `break # Use first found` inside a loop that appends to + `paths` — the `break` skips the second libraryfolders.vdf even when the + first is empty. Move `break` inside the success branch. +- `fixes.py:61-68`: `_common` fixes are appended after app-specific fixes; + the UI has no way to distinguish. Consider a `scope: "common" | "app"` field. +- `api.py:329`: `FastAPI(title=..., version="0.8.8")` → see P0-2. +- `mangohud.py:215-218`: filename sanitization — see P1-1. +- `env_vars.py:60`: `.replace("\\", "\\\\").replace('"', '\\"')` followed by + a `"…"` wrap works but is fragile. Prefer `shlex.quote` for shell + compatibility with `environment.d` parsers. + +--- + +## Dependency hygiene + +Declared in `pyproject.toml`: + +```toml +dependencies = [ + "vdf>=1.2", + "fastapi>=0.115", + "uvicorn[standard]>=0.34", +] +``` + +Missing from declarations but imported or referenced: + +- `pydantic` (directly imported — see P0-3) +- `uvloop`, `httptools`, `watchfiles`, `yaml` (listed in `_vendor_compat._NATIVE_PACKAGES` but not depended on — dead list entries) + +No upper bound on `vdf`. The `vdf` package is sparsely maintained; pin to a +known-good range (`"vdf>=1.2,<4"`). + +--- + +## Packaging / runtime env + +### Vendoring script (`electron/scripts/vendor-python-deps.sh`) + +- Fine as long as we ship zero native deps (P0-1 option A). +- `python3 -m pip install -t "${TARGET}"` picks up whatever `python3` is on + the CI runner. The `actions/setup-python@v5` pin is correct, but document + that changing the CI runner Python version is a breaking change. +- The `.dist-info` pruning keeps `METADATA`, `top_level.txt`, `RECORD`. FastAPI + uses `importlib.metadata.version("fastapi")` at startup (for the `/docs` + render); test that we haven't broken that. + +### Electron `getPythonCommand` (`electron/main.ts:40-79`) + +- `EXTRA_PATH_DIRS` now covers Bazzite/SteamOS/NixOS — good. +- `env.PATH = \`${env.PATH}:${EXTRA_PATH_DIRS}\`` only runs when + `!env.PATH.includes("/var/usrlocal/bin")`. If PATH already has + `/var/usrlocal/bin` but nothing else from the list (unlikely but possible + on a weird base image), we skip the augmentation. Safer to always + deduplicate-and-merge. +- `PYTHONNOUSERSITE = ""` → see P0-1. + +--- + +## Suggested implementation order + +1. **P0-1** (drop native deps → switch to pure-Python stack) + **P0-2** + (single version source) + **P0-3** (declare pydantic) → cut **v0.8.12**. + This unblocks AppImage users on any Python. +2. **P1-2** (API token) + **P1-1** (path sanitization) → security hardening + patch, **v0.9.0**. +3. **P2-1** (tests for VDF, tool_check, env_vars, API smoke) and **P2-2** + (delete GTK app or isolate it) as cleanup before **v0.9.x**. +4. **P2-4** (split `api.py`) + **P2-3** (logging) once tests exist so the + refactor is safe. +5. **P1-3** (atomic VDF writes + Steam-running guard) — user-reported + corruption risk, schedule for **v0.9.x**. + +Everything in P3 and leftover P2 can be rolled into whichever PR touches +the file. + +--- + +## Appendix: file-by-file one-line assessment + +| File | LoC | State | +| --- | --- | --- | +| `__init__.py` | 3 | Version drift (P0-2) | +| `__main__.py` | 6 | Points at dead GTK app (P2-2) | +| `_vendor_compat.py` | 70 | Broken on read-only FS (P0-1) — delete once P0-1A lands | +| `api.py` | 1062 | Oversized, needs split (P2-4); version drift (P0-2) | +| `app.py` | 1053 | Dead GTK UI (P2-2) | +| `controllers.py` | 142 | GUID is fake (P1-5) | +| `display.py` | 174 | Clean | +| `env_vars.py` | 128 | Missing atomic write (P1-3) | +| `fixes.py` | 98 | Missing atomic write (P1-3) | +| `gamescope.py` | 66 | Returns unescaped string (P1-7) | +| `gpu.py` | 175 | `get_power_profiles` bug (P1-4) | +| `heroic.py` | 179 | Clean, cross-module private (P2-9) | +| `heroic_config.py` | 196 | Missing atomic write (P1-3), private import (P2-9) | +| `lutris.py` | 91 | Clean | +| `mangohud.py` | 229 | Filename sanitize gap (P1-1); missing atomic write (P1-3) | +| `prefix.py` | 132 | Duplicate `_dir_size` (P2-5); full-file read for DLL | +| `presets.py` | 56 | Clean | +| `profiles_storage.py` | 76 | Weak `_safe_filename` (P1-1); missing atomic write (P1-3) | +| `protontricks.py` | 84 | Clean | +| `saves.py` | 145 | Duplicate `_dir_size` (P2-5); no zip size cap | +| `shader_cache.py` | 66 | Duplicate `_dir_size` (P2-5) | +| `steam.py` | 201 | Dead constant (P1-6); `break` placement bug (P3) | +| `tool_check.py` | 93 | Clean — the healthy module to model others on | +| `vdf_config.py` | 122 | No atomic write; no Steam-running check (P1-3) | diff --git a/electron/main.ts b/electron/main.ts index 0027b5b..27330f4 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -2,6 +2,8 @@ import { app, BrowserWindow, ipcMain } from "electron"; import { ChildProcess, spawn } from "child_process"; import * as path from "path"; import * as http from "http"; +import * as net from "net"; +import * as crypto from "crypto"; let mainWindow: BrowserWindow | null = null; let pythonProcess: ChildProcess | null = null; @@ -9,10 +11,32 @@ let apiPort: number | null = null; let staticServer: http.Server | null = null; let staticRendererPort: number | null = null; +// Per-launch bearer token. Both the Python backend and Electron see it via +// PROTONSHIFT_API_TOKEN, and every renderer-originated fetch attaches it. +const apiToken = crypto.randomBytes(32).toString("base64url"); + const isDev = !app.isPackaged; import * as fs from "fs"; +function pickFreePort(): Promise { + return new Promise((resolve, reject) => { + const srv = net.createServer(); + srv.unref(); + srv.on("error", reject); + srv.listen(0, "127.0.0.1", () => { + const addr = srv.address(); + if (addr && typeof addr === "object") { + const port = addr.port; + srv.close(() => resolve(port)); + } else { + srv.close(); + reject(new Error("Failed to bind ephemeral port")); + } + }); + }); +} + function findPythonCmd(projectRoot: string): string { // Prefer the project venv if it exists (dev workflow) const candidates = [ @@ -37,7 +61,7 @@ const EXTRA_PATH_DIRS = [ "/run/current-system/sw/bin", // NixOS ].join(":"); -function getPythonCommand(): { cmd: string; args: string[]; env: NodeJS.ProcessEnv } { +function getPythonCommand(port: number): { cmd: string; args: string[]; env: NodeJS.ProcessEnv } { const env = { ...process.env }; // Immutable distros (Bazzite, SteamOS, Fedora Atomic) and AppImage // wrappers can strip PATH entries. Ensure common locations are present. @@ -45,13 +69,19 @@ function getPythonCommand(): { cmd: string; args: string[]; env: NodeJS.ProcessE env.PATH = `${env.PATH}:${EXTRA_PATH_DIRS}`; } + // Hand the backend the auth token. compare_digest on the Python side will + // reject any other Authorization header. + env.PROTONSHIFT_API_TOKEN = apiToken; + + const portArg = String(port); + if (isDev) { const projectRoot = path.resolve(__dirname, "..", ".."); const srcDir = path.join(projectRoot, "src"); env.PYTHONPATH = srcDir + (env.PYTHONPATH ? `:${env.PYTHONPATH}` : ""); return { cmd: findPythonCmd(projectRoot), - args: ["-m", "game_setup_hub.api", "--port", "0"], + args: ["-m", "game_setup_hub.api", "--port", portArg], env, }; } @@ -68,19 +98,26 @@ function getPythonCommand(): { cmd: string; args: string[]; env: NodeJS.ProcessE pyPathParts.push(env.PYTHONPATH); } env.PYTHONPATH = pyPathParts.join(":"); - // Ensure system site-packages are available as fallback for native - // extensions (.so) that may not match the vendored Python version. - env.PYTHONNOUSERSITE = ""; + // CPython treats PYTHONNOUSERSITE as truthy if the variable is *present*, + // even when empty. Setting it to "" disables user site-packages — the + // opposite of what we want. Unset it so user site-packages stay enabled + // and `_vendor_compat` can fall back to system pydantic_core if the + // vendored .so is ABI-incompatible with the runtime Python. + delete env.PYTHONNOUSERSITE; return { cmd: "python3", - args: ["-m", "game_setup_hub.api", "--port", "0"], + args: ["-m", "game_setup_hub.api", "--port", portArg], env, }; } -function startPython(): Promise { +async function startPython(): Promise { + // Pick the port on the Node side so we know it before spawning. Avoids the + // old stdout-regex dance, and we can pass it straight to `--port`. + const port = await pickFreePort(); + const { cmd, args, env } = getPythonCommand(port); + return new Promise((resolve, reject) => { - const { cmd, args, env } = getPythonCommand(); pythonProcess = spawn(cmd, args, { env, stdio: ["pipe", "pipe", "pipe"] }); const timeout = setTimeout(() => { @@ -88,12 +125,8 @@ function startPython(): Promise { }, 15000); pythonProcess.stdout?.on("data", (data: Buffer) => { - const output = data.toString(); - const match = output.match(/PORT:(\d+)/); - if (match) { - clearTimeout(timeout); - resolve(parseInt(match[1], 10)); - } + // Stdout is informational only now; readiness comes from /health. + console.log("[python]", data.toString().trim()); }); pythonProcess.stderr?.on("data", (data: Buffer) => { @@ -112,6 +145,18 @@ function startPython(): Promise { } pythonProcess = null; }); + + // Resolve as soon as /health responds. waitForHealth handles retries. + waitForHealth(port).then( + () => { + clearTimeout(timeout); + resolve(port); + }, + (err) => { + clearTimeout(timeout); + reject(err); + }, + ); }); } @@ -301,7 +346,11 @@ ipcMain.handle("api-fetch", async (_event, urlPath: string, init?: RequestInit) try { const response = await fetch(url, { ...init, - headers: { "Content-Type": "application/json", ...init?.headers }, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiToken}`, + ...init?.headers, + }, }); const body = await response.text(); return { @@ -321,7 +370,6 @@ ipcMain.handle("api-fetch", async (_event, urlPath: string, init?: RequestInit) app.on("ready", async () => { try { apiPort = await startPython(); - await waitForHealth(apiPort); if (!isDev) { const outDir = path.join(__dirname, "..", "renderer", "out"); await startStaticRendererServer(outDir); diff --git a/electron/package.json b/electron/package.json index c2b68bf..6084a01 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "protonshift", - "version": "0.8.11", + "version": "0.9.0", "description": "Linux game configuration toolkit", "main": "dist/main.js", "scripts": { @@ -56,7 +56,7 @@ { "from": "../src", "to": "python/src", - "filter": ["**/*.py", "**/*.css"] + "filter": ["**/*.py", "**/data/*.json"] }, { "from": "../pyproject.toml", diff --git a/electron/python-runtime-requirements.txt b/electron/python-runtime-requirements.txt index 6e318a9..1a86c8c 100644 --- a/electron/python-runtime-requirements.txt +++ b/electron/python-runtime-requirements.txt @@ -1,4 +1,11 @@ # Bundled into the AppImage/deb for packaged runs (see scripts/vendor-python-deps.sh). +# +# Keep native (.so) deps to a minimum so the bundle survives running on a +# different Python minor version. Today the only unavoidable native dep is +# `pydantic_core` (transitive from FastAPI). `_vendor_compat.py` falls back +# to system site-packages when the vendored .so is ABI-incompatible. fastapi>=0.115,<1 -uvicorn[standard]>=0.34,<1 -vdf>=1.2 +pydantic>=2.0,<3 +# NOT [standard] — that pulls uvloop, httptools, watchfiles (3 more .so files). +uvicorn>=0.34,<1 +vdf>=1.2,<4 diff --git a/flatpak/io.github.protonshift.yml b/flatpak/io.github.protonshift.yml new file mode 100644 index 0000000..36bf999 --- /dev/null +++ b/flatpak/io.github.protonshift.yml @@ -0,0 +1,63 @@ +app-id: io.github.protonshift +runtime: org.freedesktop.Platform +runtime-version: "24.08" +sdk: org.freedesktop.Sdk +base: org.electronjs.Electron2.BaseApp +base-version: "24.08" +command: protonshift +separate-locales: false + +finish-args: + - --share=ipc + - --share=network + - --socket=x11 + - --socket=wayland + - --socket=pulseaudio + - --device=dri + # Home read-only for game library discovery + - --filesystem=home:ro + # Steam paths (native + Flatpak) + - --filesystem=~/.steam:ro + - --filesystem=~/.local/share/Steam:ro + - --filesystem=~/.var/app/com.valvesoftware.Steam:ro + # Heroic paths (native + Flatpak) + - --filesystem=~/.config/heroic:ro + - --filesystem=~/.var/app/com.heroicgameslauncher.hgl:ro + # Lutris paths (native + Flatpak) + - --filesystem=~/.local/share/lutris:ro + - --filesystem=~/.var/app/net.lutris.Lutris:ro + # Writable config paths + - --filesystem=~/.config/MangoHud + - --filesystem=~/.config/environment.d + - --filesystem=~/.config/protonshift + # Talk to Flatpak for launching games via steam:// URIs + - --talk-name=org.freedesktop.Flatpak + +modules: + - name: python3-deps + buildsystem: simple + build-commands: + - pip3 install --no-build-isolation --prefix=/app vdf fastapi "uvicorn[standard]" + sources: + - type: file + url: https://files.pythonhosted.org/packages/source/v/vdf/vdf-3.4.tar.gz + sha256: FIXME + + - name: protonshift-python + buildsystem: simple + build-commands: + - pip3 install --no-build-isolation --prefix=/app . + sources: + - type: dir + path: .. + + - name: protonshift + buildsystem: simple + build-commands: + - cp -r electron-dist/* /app/ + - install -Dm644 assets/io.github.protonshift.svg /app/share/icons/hicolor/scalable/apps/io.github.protonshift.svg + - install -Dm644 assets/io.github.protonshift.desktop /app/share/applications/io.github.protonshift.desktop + - install -Dm644 assets/io.github.protonshift.metainfo.xml /app/share/metainfo/io.github.protonshift.metainfo.xml + sources: + - type: dir + path: .. diff --git a/pyproject.toml b/pyproject.toml index 8460078..95e0862 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,25 +4,45 @@ build-backend = "setuptools.build_meta" [project] name = "protonshift" -version = "0.8.11" description = "Linux game configuration toolkit: GPU, launch options, Proton, env vars" readme = "README.md" requires-python = ">=3.12" +dynamic = ["version"] dependencies = [ - "vdf>=1.2", - "fastapi>=0.115", - "uvicorn[standard]>=0.34", + "vdf>=1.2,<4", + "fastapi>=0.115,<1", + "pydantic>=2.0,<3", + # Use bare uvicorn (not [standard]). The "standard" extras pull uvloop, + # httptools, and watchfiles — three more native extensions that break + # AppImage portability. Pure-asyncio + h11 is plenty for localhost. + "uvicorn>=0.34,<1", ] [project.optional-dependencies] -dev = ["ruff", "pyright"] +dev = ["ruff", "pyright", "pytest", "pytest-asyncio", "httpx"] [project.scripts] -protonshift = "game_setup_hub.app:main" protonshift-api = "game_setup_hub.api:cli" [tool.setuptools.packages.find] where = ["src"] +[tool.setuptools.dynamic] +version = { attr = "game_setup_hub.__version__" } + [tool.setuptools.package-data] -game_setup_hub = ["theme.css"] +game_setup_hub = ["data/*.json"] + +[tool.ruff] +line-length = 110 +target-version = "py312" + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "B", "UP", "SIM"] +# SIM105: try/except/pass with narrow exception is clearer than contextlib.suppress. +# SIM102: nested-if collapse hurts readability when guards differ in intent. +ignore = ["E501", "SIM105", "SIM102"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" diff --git a/pyrightconfig.json b/pyrightconfig.json index b47f69f..3df4c54 100644 --- a/pyrightconfig.json +++ b/pyrightconfig.json @@ -1,6 +1,7 @@ { - "include": ["src"], - "exclude": ["src/game_setup_hub/app.py"], + "include": ["src", "tests"], + "venvPath": ".", + "venv": ".venv", "pythonVersion": "3.12", "typeCheckingMode": "standard" } diff --git a/src/game_setup_hub/__init__.py b/src/game_setup_hub/__init__.py index f6ceb2f..52c81e8 100644 --- a/src/game_setup_hub/__init__.py +++ b/src/game_setup_hub/__init__.py @@ -1,3 +1,3 @@ -"""ProtonShift — game configuration for Pop!_OS, Ubuntu, and related distros.""" +"""ProtonShift — Linux game configuration toolkit.""" -__version__ = "0.8.8" +__version__ = "0.9.0" diff --git a/src/game_setup_hub/__main__.py b/src/game_setup_hub/__main__.py deleted file mode 100644 index 693d75c..0000000 --- a/src/game_setup_hub/__main__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Allow running as python -m game_setup_hub.""" - -from .app import main - -if __name__ == "__main__": - main() diff --git a/src/game_setup_hub/_vendor_compat.py b/src/game_setup_hub/_vendor_compat.py index 690676b..bdd8435 100644 --- a/src/game_setup_hub/_vendor_compat.py +++ b/src/game_setup_hub/_vendor_compat.py @@ -1,70 +1,140 @@ -"""Ensure vendored native extensions are compatible with the running Python. - -When the app is packaged (AppImage/deb/rpm), Python dependencies are vendored -into a directory added to PYTHONPATH. Native extensions (.so) are ABI-locked -to the Python version used at build time. If the user runs a different Python -minor version, those .so files won't load. - -This module detects the mismatch early and removes incompatible vendored -packages from sys.path so Python falls back to system site-packages. +"""Make vendored native extensions tolerate Python minor-version drift. + +When the app is packaged (AppImage / .deb / .rpm) Python dependencies are +vendored into a directory that gets prepended to ``sys.path``. Native +extensions (``.so``) are ABI-locked to the Python build that produced them +(``cp312-…``). If the user is running a different Python minor version, +those ``.so`` files cannot load and importing the package raises +``ImportError`` — even when a perfectly good system copy exists further +down ``sys.path``. + +The previous strategy was to ``shutil.rmtree`` the offending vendor dirs. +That is silently a no-op on read-only squashfs (i.e. inside an AppImage), +which is exactly the deployment we needed it to work in. + +This module instead resolves the conflict by mutating ``sys.path`` — +which lives in process memory and works regardless of filesystem +permissions: + +1. Detect whether any vendored native package has an ABI-incompatible + ``.so`` for the running interpreter. +2. If so, prepend the standard system + user site-packages directories + so the system copy of those packages wins import resolution. The + vendor dir stays for everything else (``vdf``, ``fastapi``, …). + +This file is imported as the very first line of :mod:`game_setup_hub.api._app`, +before any third-party package is touched, so the path tweak takes effect +before pydantic/FastAPI try to load ``pydantic_core``. """ from __future__ import annotations +import logging +import site import sys import sysconfig from pathlib import Path -_NATIVE_PACKAGES = [ +log = logging.getLogger("protonshift.vendor_compat") + +# Native extensions actually present in our vendor set. Anything not in this +# list is either pure-Python or not a declared dependency. +_NATIVE_PACKAGES: tuple[str, ...] = ( "pydantic_core", - "uvloop", - "httptools", - "watchfiles", - "yaml", # PyYAML -] +) _SOABI = sysconfig.get_config_var("SOABI") or "" def _has_compatible_so(pkg_dir: Path) -> bool: - """Check if a vendored package dir has .so files matching this Python.""" + """True if ``pkg_dir`` is pure Python or has a ``.so`` for our SOABI.""" so_files = list(pkg_dir.glob("*.so")) if not so_files: - return True # pure-Python package, always compatible + return True return any(_SOABI in f.name for f in so_files) -def fixup_vendor_path() -> None: - """Remove vendored native-extension dirs that are ABI-incompatible.""" - vendor_dirs = [ - p for p in sys.path - if "vendor" in p and Path(p).is_dir() - ] - if not vendor_dirs: - return +def _vendor_dirs() -> list[Path]: + """Return the vendor directories currently on ``sys.path``.""" + out: list[Path] = [] + for entry in sys.path: + if not entry: + continue + p = Path(entry) + if "vendor" in p.parts and p.is_dir(): + out.append(p) + return out - for vendor_dir in vendor_dirs: - vp = Path(vendor_dir) - for pkg_name in _NATIVE_PACKAGES: - pkg_dir = vp / pkg_name - if pkg_dir.is_dir() and not _has_compatible_so(pkg_dir): - _remove_vendored_package(vp, pkg_name) +def _has_incompatible_native_pkg(vendor_dirs: list[Path]) -> bool: + for vd in vendor_dirs: + for pkg in _NATIVE_PACKAGES: + pkg_dir = vd / pkg + if pkg_dir.is_dir() and not _has_compatible_so(pkg_dir): + log.warning( + "Vendored %s has no .so matching %r; will prefer system site-packages.", + pkg, _SOABI, + ) + return True + return False + + +def _system_site_dirs() -> list[str]: + """Best-effort list of system + user site-packages.""" + seen: set[str] = set() + out: list[str] = [] + candidates: list[str] = [] + try: + candidates.extend(site.getsitepackages()) + except (AttributeError, OSError): + pass + user = "" + try: + user = site.getusersitepackages() + except (AttributeError, OSError): + pass + if user: + candidates.append(user) + for c in candidates: + if c and c not in seen and Path(c).is_dir(): + seen.add(c) + out.append(c) + return out -def _remove_vendored_package(vendor_dir: Path, pkg_name: str) -> None: - """Remove a single incompatible package from the vendor dir at runtime.""" - import shutil - - pkg_dir = vendor_dir / pkg_name - if pkg_dir.exists(): - shutil.rmtree(pkg_dir, ignore_errors=True) - for dist_info in vendor_dir.glob(f"{pkg_name}-*.dist-info"): - shutil.rmtree(dist_info, ignore_errors=True) +def fixup_vendor_path() -> None: + """Reorder ``sys.path`` when vendored native extensions don't match. + + On match, this is a no-op. On mismatch, the system + user site-packages + directories are inserted at the front of ``sys.path`` so the system copy + of any conflicting native package is found before the broken vendored + one. The filesystem is never touched. + """ + vendor_dirs = _vendor_dirs() + if not vendor_dirs: + return + if not _has_incompatible_native_pkg(vendor_dirs): + return - # Clear any cached import state - if pkg_name in sys.modules: - del sys.modules[pkg_name] + inserted: list[str] = [] + for sp in _system_site_dirs(): + if sp not in sys.path: + sys.path.insert(0, sp) + inserted.append(sp) + + if inserted: + log.warning( + "Inserted system site-packages at the head of sys.path for ABI fallback: %s", + inserted, + ) + else: + log.warning( + "ABI mismatch detected for vendored native extensions but no system " + "site-packages were available. Imports of %s may fail. Install the " + "package system-wide (e.g. `python3-pydantic`) or run with the " + "interpreter the AppImage was built against.", + ", ".join(_NATIVE_PACKAGES), + ) fixup_vendor_path() diff --git a/src/game_setup_hub/api.py b/src/game_setup_hub/api.py deleted file mode 100644 index 16c802d..0000000 --- a/src/game_setup_hub/api.py +++ /dev/null @@ -1,1062 +0,0 @@ -"""FastAPI backend exposing ProtonShift logic over localhost HTTP.""" - -from __future__ import annotations - -import game_setup_hub._vendor_compat # noqa: F401 # must be first - -import argparse -import asyncio -import socket -from pathlib import Path -from typing import Any - -from fastapi import FastAPI, HTTPException -from fastapi.middleware.cors import CORSMiddleware -from pydantic import BaseModel - -from .env_vars import ENV_PRESETS, read_gaming_env, write_gaming_env -from .gpu import get_current_power_profile, get_gpu_info, get_power_profiles, set_power_profile -from .heroic import HeroicGame, discover_heroic_games -from .heroic_config import ( - get_heroic_game_config, - list_heroic_wine_versions, - set_heroic_launch_options, - set_heroic_toggles, - set_heroic_wine_version, -) -from .lutris import LutrisGame, discover_lutris_games -from .presets import LAUNCH_PRESETS -from .profiles_storage import ( - ApplicationProfile, - delete_profile, - list_profiles, - load_profile, - save_profile, -) -from .controllers import get_controllers, get_sdl_mapping -from .display import get_monitors, get_session_type, set_resolution -from .fixes import add_user_fix, get_fixes -from .gamescope import GamescopeOptions, build_gamescope_cmd, is_gamescope_available -from .mangohud import ( - MANGOHUD_PARAMS, - MANGOHUD_PRESETS, - get_per_game_config_path, - is_mangohud_available, - list_per_game_configs, - read_mangohud_config, - write_mangohud_config, -) -from .prefix import delete_prefix as _delete_prefix, get_prefix_info -from .saves import ( - backup_saves as _backup_saves, - find_save_paths, - list_backups, - restore_backup as _restore_backup, -) -from .shader_cache import ( - clear_shader_cache as _clear_shader_cache, - get_shader_cache_info, - get_total_shader_cache_size, -) -from .protontricks import COMMON_VERBS, is_protontricks_available, run_protontricks -from .steam import ( - SteamGame, - discover_games, - get_available_proton_tools, - get_localconfig_path, - is_steam_running, -) -from .vdf_config import get_compat_tool, get_launch_options, set_compat_tool, set_launch_options - -# --------------------------------------------------------------------------- -# Pydantic response models (JSON-safe mirrors of existing dataclasses) -# --------------------------------------------------------------------------- - - -class SteamGameResponse(BaseModel): - app_id: str - name: str - install_dir: str - last_played: int - library_path: str - compatdata_path: str | None - has_compatdata: bool - install_path: str | None - source: str = "steam" - - -class HeroicGameResponse(BaseModel): - app_id: str - name: str - store: str - install_path: str | None - prefix_path: str | None - source: str = "heroic" - - -class LutrisGameResponse(BaseModel): - app_id: str - name: str - install_path: str | None - prefix_path: str | None - source: str = "lutris" - - -class GPUInfoResponse(BaseModel): - name: str - driver: str - vram_mb: int | None - temperature: float | None - - -class SystemInfoResponse(BaseModel): - gpus: list[GPUInfoResponse] - power_profiles: list[str] - current_power_profile: str | None - - -class LaunchPresetResponse(BaseModel): - name: str - value: str - description: str - install_command: str - install_url: str - is_installed: bool - - -class ProfileResponse(BaseModel): - name: str - launch_options: str - compat_tool: str - env_vars: dict[str, str] - power_profile: str - - -class ProfileCreateRequest(BaseModel): - name: str - launch_options: str = "" - compat_tool: str = "" - env_vars: dict[str, str] = {} - power_profile: str = "" - - -class LaunchOptionsRequest(BaseModel): - options: str - - -class CompatToolRequest(BaseModel): - tool_name: str - - -class EnvVarsRequest(BaseModel): - vars: dict[str, str] - - -class PowerProfileRequest(BaseModel): - profile: str - - -class ProtontricksRequest(BaseModel): - verb: str | None = None - - -class PrefixInfoResponse(BaseModel): - path: str - exists: bool - size_bytes: int = 0 - size_human: str = "" - created: str = "" - dxvk_version: str = "" - vkd3d_version: str = "" - - -class ControllerResponse(BaseModel): - id: str - name: str - device_path: str - controller_type: str - vendor_id: str = "" - product_id: str = "" - - -class MonitorResponse(BaseModel): - name: str - connected: bool - resolution: str - refresh_rate: str - primary: bool - position: str - - -class SetResolutionRequest(BaseModel): - monitor: str - width: int - height: int - refresh: float = 0 - - -class SaveLocationResponse(BaseModel): - path: str - exists: bool - size_bytes: int = 0 - size_human: str = "" - label: str = "" - - -class BackupInfoResponse(BaseModel): - path: str - filename: str - size_bytes: int - size_human: str - created: str - - -class BackupRequest(BaseModel): - paths: list[str] - - -class RestoreRequest(BaseModel): - backup_path: str - target_dir: str - - -class MangoHudConfigRequest(BaseModel): - config: dict[str, str] - - -class GameFixResponse(BaseModel): - title: str - description: str - fix_type: str - key: str - value: str - source: str - - -class GameFixCreateRequest(BaseModel): - title: str - description: str = "" - fix_type: str = "env" - key: str = "" - value: str = "" - - -class GamescopeBuildRequest(BaseModel): - output_width: int = 0 - output_height: int = 0 - game_width: int = 0 - game_height: int = 0 - fps_limit: int = 0 - fsr: bool = False - fsr_sharpness: int = 5 - integer_scale: bool = False - hdr: bool = False - nested: bool = True - borderless: bool = True - fullscreen: bool = True - extra_args: str = "" - - -class ShaderCacheResponse(BaseModel): - app_id: str - path: str - exists: bool - size_bytes: int = 0 - size_human: str = "" - - -class OpenPathRequest(BaseModel): - path: str - - -class OpenUriRequest(BaseModel): - uri: str - - -class HeroicWineVersionResponse(BaseModel): - name: str - bin: str - wine_type: str - - -class HeroicGameConfigResponse(BaseModel): - app_id: str - exists: bool - wine_prefix: str = "" - wine_version: HeroicWineVersionResponse = HeroicWineVersionResponse(name="", bin="", wine_type="") - other_options: str = "" - enable_esync: bool = False - enable_fsync: bool = False - auto_install_dxvk: bool = True - auto_install_vkd3d: bool = False - show_fps: bool = False - show_mangohud: bool = False - use_game_mode: bool = False - nvidia_prime: bool = False - saves_path: str = "" - target_exe: str = "" - - -class HeroicLaunchOptionsRequest(BaseModel): - options: str - - -class HeroicWineVersionRequest(BaseModel): - name: str - bin: str - wine_type: str - - -class HeroicTogglesRequest(BaseModel): - enable_esync: bool | None = None - enable_fsync: bool | None = None - auto_install_dxvk: bool | None = None - auto_install_vkd3d: bool | None = None - show_mangohud: bool | None = None - use_game_mode: bool | None = None - nvidia_prime: bool | None = None - - -class StatusResponse(BaseModel): - success: bool - message: str = "" - - -# --------------------------------------------------------------------------- -# App setup -# --------------------------------------------------------------------------- - -app = FastAPI(title="ProtonShift API", version="0.8.8") -app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) - -# Locks for serializing writes to shared resources -_vdf_lock = asyncio.Lock() -_env_lock = asyncio.Lock() -_profiles_lock = asyncio.Lock() -_mangohud_lock = asyncio.Lock() -_heroic_lock = asyncio.Lock() - -# Cached steam root + localconfig path (discovered once) -_steam_root: Path | None = None -_config_path: Path | None = None -_steam_discovered = False - - -def _ensure_steam() -> None: - global _steam_root, _config_path, _steam_discovered - if _steam_discovered: - return - _steam_root, _ = discover_games() - if _steam_root: - _config_path = get_localconfig_path(_steam_root) - _steam_discovered = True - - -def _steam_game_to_response(g: SteamGame) -> SteamGameResponse: - return SteamGameResponse( - app_id=g.app_id, - name=g.name, - install_dir=g.install_dir, - last_played=g.last_played, - library_path=str(g.library_path), - compatdata_path=str(g.compatdata_path) if g.compatdata_path else None, - has_compatdata=g.has_compatdata, - install_path=str(g.install_path) if g.install_path else None, - ) - - -def _heroic_game_to_response(g: HeroicGame) -> HeroicGameResponse: - return HeroicGameResponse( - app_id=g.app_id, - name=g.name, - store=g.store, - install_path=str(g.install_path) if g.install_path else None, - prefix_path=str(g.prefix_path) if g.prefix_path else None, - ) - - -def _lutris_game_to_response(g: LutrisGame) -> LutrisGameResponse: - return LutrisGameResponse( - app_id=g.app_id, - name=g.name, - install_path=str(g.install_path) if g.install_path else None, - prefix_path=str(g.prefix_path) if g.prefix_path else None, - ) - - -# --------------------------------------------------------------------------- -# Endpoints -# --------------------------------------------------------------------------- - - -@app.get("/health") -async def health() -> dict[str, str]: - return {"status": "ok"} - - -@app.get("/games") -async def list_games() -> dict[str, Any]: - _ensure_steam() - _, steam_games = discover_games() - heroic_games = discover_heroic_games() - lutris_games = discover_lutris_games() - return { - "steam": [_steam_game_to_response(g) for g in steam_games], - "heroic": [_heroic_game_to_response(g) for g in heroic_games], - "lutris": [_lutris_game_to_response(g) for g in lutris_games], - "steam_running": is_steam_running(), - } - - -@app.get("/games/{app_id}/launch-options") -async def get_game_launch_options(app_id: str) -> dict[str, str]: - _ensure_steam() - if not _config_path: - return {"options": ""} - try: - opts = get_launch_options(_config_path, app_id) - except Exception: - opts = "" - return {"options": opts} - - -@app.put("/games/{app_id}/launch-options") -async def update_launch_options(app_id: str, body: LaunchOptionsRequest) -> StatusResponse: - _ensure_steam() - if not _config_path: - raise HTTPException(status_code=404, detail="Steam localconfig.vdf not found") - async with _vdf_lock: - ok = set_launch_options(_config_path, app_id, body.options) - if not ok: - raise HTTPException(status_code=500, detail="Failed to write localconfig.vdf") - return StatusResponse(success=True) - - -@app.get("/games/{app_id}/compat-tool") -async def get_game_compat_tool(app_id: str) -> dict[str, str]: - _ensure_steam() - if not _config_path: - return {"tool": ""} - try: - tool = get_compat_tool(_config_path, app_id) - except Exception: - tool = "" - return {"tool": tool} - - -@app.put("/games/{app_id}/compat-tool") -async def update_compat_tool(app_id: str, body: CompatToolRequest) -> StatusResponse: - _ensure_steam() - if not _config_path: - raise HTTPException(status_code=404, detail="Steam localconfig.vdf not found") - async with _vdf_lock: - ok = set_compat_tool(_config_path, app_id, body.tool_name) - if not ok: - raise HTTPException(status_code=500, detail="Failed to write localconfig.vdf") - return StatusResponse(success=True) - - -@app.get("/games/{app_id}/proton-tools") -async def list_proton_tools(app_id: str) -> dict[str, Any]: - _ensure_steam() - tools = get_available_proton_tools(_steam_root) - current = "" - if _config_path: - try: - current = get_compat_tool(_config_path, app_id) - except Exception: - pass - return {"tools": tools, "current": current} - - -@app.post("/games/{app_id}/protontricks") -async def trigger_protontricks(app_id: str, body: ProtontricksRequest) -> StatusResponse: - if not is_protontricks_available(): - raise HTTPException(status_code=404, detail="Protontricks not installed") - ok, msg = run_protontricks(app_id, body.verb) - if not ok: - raise HTTPException(status_code=500, detail=msg) - return StatusResponse(success=True, message=msg) - - -@app.get("/protontricks/verbs") -async def list_protontricks_verbs() -> dict[str, Any]: - return { - "available": is_protontricks_available(), - "verbs": [{"id": v[0], "label": v[1]} for v in COMMON_VERBS], - } - - -@app.get("/presets") -async def list_presets() -> list[LaunchPresetResponse]: - return [ - LaunchPresetResponse( - name=p.name, - value=p.value, - description=p.description, - install_command=p.install_command, - install_url=p.install_url, - is_installed=p.is_installed(), - ) - for p in LAUNCH_PRESETS - ] - - -@app.get("/env-presets") -async def list_env_presets() -> dict[str, dict[str, str]]: - return ENV_PRESETS - - -@app.get("/env-vars") -async def get_env_vars() -> dict[str, str]: - try: - return read_gaming_env() - except Exception: - return {} - - -@app.put("/env-vars") -async def update_env_vars(body: EnvVarsRequest) -> StatusResponse: - async with _env_lock: - ok = write_gaming_env(body.vars) - if not ok: - raise HTTPException(status_code=500, detail="Failed to write env config") - return StatusResponse(success=True) - - -@app.get("/system") -async def system_info() -> SystemInfoResponse: - try: - gpus = get_gpu_info() - except Exception: - gpus = [] - try: - profiles = get_power_profiles() - except Exception: - profiles = [] - try: - current = get_current_power_profile() - except Exception: - current = None - return SystemInfoResponse( - gpus=[GPUInfoResponse(name=g.name, driver=g.driver, vram_mb=g.vram_mb, temperature=g.temperature) for g in gpus], - power_profiles=profiles, - current_power_profile=current, - ) - - -@app.put("/system/power-profile") -async def update_power_profile(body: PowerProfileRequest) -> StatusResponse: - ok, msg = set_power_profile(body.profile) - return StatusResponse(success=ok, message=msg) - - -@app.get("/profiles") -async def list_all_profiles() -> list[str]: - return list_profiles() - - -@app.get("/profiles/{name}") -async def get_profile(name: str) -> ProfileResponse: - p = load_profile(name) - if not p: - raise HTTPException(status_code=404, detail="Profile not found") - return ProfileResponse( - name=p.name, - launch_options=p.launch_options, - compat_tool=p.compat_tool, - env_vars=p.env_vars, - power_profile=p.power_profile, - ) - - -@app.post("/profiles") -async def create_profile(body: ProfileCreateRequest) -> StatusResponse: - profile = ApplicationProfile( - name=body.name, - launch_options=body.launch_options, - compat_tool=body.compat_tool, - env_vars=body.env_vars, - power_profile=body.power_profile, - ) - async with _profiles_lock: - ok = save_profile(profile) - if not ok: - raise HTTPException(status_code=500, detail="Failed to save profile") - return StatusResponse(success=True) - - -@app.delete("/profiles/{name}") -async def remove_profile(name: str) -> StatusResponse: - async with _profiles_lock: - ok = delete_profile(name) - if not ok: - raise HTTPException(status_code=404, detail="Profile not found or could not be deleted") - return StatusResponse(success=ok) - - -def _human_size(size_bytes: int) -> str: - for unit in ("B", "KB", "MB", "GB", "TB"): - if abs(size_bytes) < 1024: - return f"{size_bytes:.1f} {unit}" - size_bytes /= 1024 # type: ignore[assignment] - return f"{size_bytes:.1f} PB" - - -@app.get("/controllers") -async def list_controllers() -> list[ControllerResponse]: - ctrls = get_controllers() - return [ - ControllerResponse( - id=c.id, - name=c.name, - device_path=c.device_path, - controller_type=c.controller_type, - vendor_id=c.vendor_id, - product_id=c.product_id, - ) - for c in ctrls - ] - - -@app.get("/controllers/{controller_id}/sdl-mapping") -async def controller_sdl_mapping(controller_id: str) -> dict[str, str]: - ctrls = get_controllers() - ctrl = next((c for c in ctrls if c.id == controller_id), None) - if not ctrl: - raise HTTPException(status_code=404, detail="Controller not found") - mapping = get_sdl_mapping(ctrl) - return {"mapping": mapping} - - -@app.get("/display/monitors") -async def list_monitors() -> dict[str, Any]: - session = get_session_type() - monitors = get_monitors() - return { - "session_type": session, - "monitors": [ - MonitorResponse( - name=m.name, - connected=m.connected, - resolution=m.resolution, - refresh_rate=m.refresh_rate, - primary=m.primary, - position=m.position, - ) - for m in monitors - ], - } - - -@app.put("/display/resolution") -async def update_resolution(body: SetResolutionRequest) -> StatusResponse: - ok = set_resolution(body.monitor, body.width, body.height, body.refresh) - if not ok: - raise HTTPException(status_code=500, detail="Failed to set resolution") - return StatusResponse(success=True, message=f"Set {body.monitor} to {body.width}x{body.height}") - - -@app.get("/games/{app_id}/saves") -async def game_saves(app_id: str, prefix_path: str | None = None) -> list[SaveLocationResponse]: - prefix = prefix_path - if not prefix: - _ensure_steam() - _, steam_games = discover_games() - game = next((g for g in steam_games if g.app_id == app_id), None) - prefix = str(game.compatdata_path) if game and game.compatdata_path else None - locations = find_save_paths(app_id, prefix) - return [ - SaveLocationResponse( - path=loc.path, - exists=loc.exists, - size_bytes=loc.size_bytes, - size_human=_human_size(loc.size_bytes), - label=loc.label, - ) - for loc in locations - ] - - -@app.post("/games/{app_id}/saves/backup") -async def backup_game_saves(app_id: str, body: BackupRequest) -> dict[str, Any]: - result = _backup_saves(app_id, body.paths) - if not result: - raise HTTPException(status_code=500, detail="Failed to create backup") - return {"path": result} - - -@app.get("/games/{app_id}/saves/backups") -async def list_game_backups(app_id: str) -> list[BackupInfoResponse]: - backups = list_backups(app_id) - return [ - BackupInfoResponse( - path=b.path, - filename=b.filename, - size_bytes=b.size_bytes, - size_human=_human_size(b.size_bytes), - created=b.created, - ) - for b in backups - ] - - -@app.post("/games/{app_id}/saves/restore") -async def restore_game_saves(app_id: str, body: RestoreRequest) -> StatusResponse: - ok = _restore_backup(body.backup_path, body.target_dir) - if not ok: - raise HTTPException(status_code=500, detail="Failed to restore backup") - return StatusResponse(success=True, message="Backup restored") - - -@app.get("/mangohud/available") -async def mangohud_available() -> dict[str, bool]: - return {"available": is_mangohud_available()} - - -@app.get("/mangohud/config") -async def get_mangohud_config() -> dict[str, Any]: - return { - "config": read_mangohud_config(), - "params": MANGOHUD_PARAMS, - } - - -@app.put("/mangohud/config") -async def update_mangohud_config(body: MangoHudConfigRequest) -> StatusResponse: - async with _mangohud_lock: - ok = write_mangohud_config(body.config) - if not ok: - raise HTTPException(status_code=500, detail="Failed to write MangoHud config") - return StatusResponse(success=True) - - -@app.get("/mangohud/presets") -async def get_mangohud_presets() -> dict[str, dict[str, str]]: - return MANGOHUD_PRESETS - - -@app.get("/mangohud/per-game") -async def list_mangohud_per_game() -> list[dict[str, str]]: - """List all existing per-game MangoHud config files.""" - return list_per_game_configs() - - -@app.get("/mangohud/per-game/{game_name}") -async def get_mangohud_per_game_config(game_name: str) -> dict[str, Any]: - """Read a per-game MangoHud config.""" - conf_path = get_per_game_config_path(game_name) - return { - "path": str(conf_path), - "exists": conf_path.exists(), - "config": read_mangohud_config(conf_path) if conf_path.exists() else {}, - "params": MANGOHUD_PARAMS, - } - - -@app.put("/mangohud/per-game/{game_name}") -async def update_mangohud_per_game_config( - game_name: str, body: MangoHudConfigRequest -) -> StatusResponse: - """Write a per-game MangoHud config.""" - conf_path = get_per_game_config_path(game_name) - async with _mangohud_lock: - ok = write_mangohud_config(body.config, conf_path) - if not ok: - raise HTTPException(status_code=500, detail="Failed to write per-game MangoHud config") - return StatusResponse(success=True) - - -@app.get("/games/{app_id}/fixes") -async def list_game_fixes(app_id: str) -> list[GameFixResponse]: - fixes = get_fixes(app_id) - return [ - GameFixResponse( - title=f.title, - description=f.description, - fix_type=f.fix_type, - key=f.key, - value=f.value, - source=f.source, - ) - for f in fixes - ] - - -@app.post("/games/{app_id}/fixes") -async def create_game_fix(app_id: str, body: GameFixCreateRequest) -> StatusResponse: - ok = add_user_fix( - app_id=app_id, - title=body.title, - description=body.description, - fix_type=body.fix_type, - key=body.key, - value=body.value, - ) - if not ok: - raise HTTPException(status_code=500, detail="Failed to save fix") - return StatusResponse(success=True) - - -@app.get("/gamescope/available") -async def gamescope_available() -> dict[str, bool]: - return {"available": is_gamescope_available()} - - -@app.post("/gamescope/build-cmd") -async def gamescope_build(body: GamescopeBuildRequest) -> dict[str, str]: - opts = GamescopeOptions( - output_width=body.output_width, - output_height=body.output_height, - game_width=body.game_width, - game_height=body.game_height, - fps_limit=body.fps_limit, - fsr=body.fsr, - fsr_sharpness=body.fsr_sharpness, - integer_scale=body.integer_scale, - hdr=body.hdr, - nested=body.nested, - borderless=body.borderless, - fullscreen=body.fullscreen, - extra_args=body.extra_args, - ) - return {"command": build_gamescope_cmd(opts)} - - -@app.get("/games/{app_id}/shader-cache") -async def game_shader_cache(app_id: str) -> ShaderCacheResponse: - _ensure_steam() - if not _steam_root: - return ShaderCacheResponse(app_id=app_id, path="", exists=False) - info = get_shader_cache_info(_steam_root, app_id) - return ShaderCacheResponse( - app_id=app_id, - path=info.path, - exists=info.exists, - size_bytes=info.size_bytes, - size_human=_human_size(info.size_bytes), - ) - - -@app.delete("/games/{app_id}/shader-cache") -async def clear_game_shader_cache(app_id: str) -> StatusResponse: - _ensure_steam() - if not _steam_root: - raise HTTPException(status_code=404, detail="Steam root not found") - ok = _clear_shader_cache(_steam_root, app_id) - if not ok: - raise HTTPException(status_code=500, detail="Failed to clear shader cache") - return StatusResponse(success=True, message="Shader cache cleared") - - -@app.get("/shader-cache/total") -async def total_shader_cache() -> dict[str, Any]: - _ensure_steam() - if not _steam_root: - return {"size_bytes": 0, "size_human": "0.0 B"} - total = get_total_shader_cache_size(_steam_root) - return {"size_bytes": total, "size_human": _human_size(total)} - - -@app.get("/games/{app_id}/prefix-info") -async def game_prefix_info(app_id: str, prefix_path: str | None = None) -> PrefixInfoResponse: - ppath = prefix_path - if not ppath: - _ensure_steam() - _, steam_games = discover_games() - game = next((g for g in steam_games if g.app_id == app_id), None) - ppath = str(game.compatdata_path) if game and game.compatdata_path else None - if not ppath: - return PrefixInfoResponse(path="", exists=False) - info = get_prefix_info(ppath) - return PrefixInfoResponse( - path=info.path, - exists=info.exists, - size_bytes=info.size_bytes, - size_human=_human_size(info.size_bytes), - created=info.created, - dxvk_version=info.dxvk_version, - vkd3d_version=info.vkd3d_version, - ) - - -@app.delete("/games/{app_id}/prefix") -async def delete_game_prefix(app_id: str, prefix_path: str | None = None) -> StatusResponse: - ppath = prefix_path - if not ppath: - _ensure_steam() - _, steam_games = discover_games() - game = next((g for g in steam_games if g.app_id == app_id), None) - if not game or not game.compatdata_path: - raise HTTPException(status_code=404, detail="No prefix found for this game") - ppath = str(game.compatdata_path) - ok = _delete_prefix(ppath) - if not ok: - raise HTTPException(status_code=500, detail="Failed to delete prefix") - return StatusResponse(success=True, message="Prefix deleted. The launcher will recreate it on next launch.") - - -@app.post("/open-path") -async def open_path(body: OpenPathRequest) -> StatusResponse: - """Open a file or folder in the system file manager.""" - import subprocess - target = Path(body.path).expanduser() - if not target.exists(): - raise HTTPException(status_code=404, detail=f"Path not found: {body.path}") - try: - subprocess.Popen(["xdg-open", str(target)], start_new_session=True) - except FileNotFoundError: - try: - subprocess.Popen(["gio", "open", str(target)], start_new_session=True) - except (FileNotFoundError, OSError) as e: - raise HTTPException(status_code=500, detail=str(e)) - return StatusResponse(success=True) - - -@app.post("/open-uri") -async def open_uri(body: OpenUriRequest) -> StatusResponse: - """Open a URI (e.g. steam://rungameid/...) with the system handler.""" - import subprocess - try: - subprocess.Popen(["xdg-open", body.uri], start_new_session=True) - except FileNotFoundError: - try: - subprocess.Popen(["gio", "open", body.uri], start_new_session=True) - except (FileNotFoundError, OSError) as e: - raise HTTPException(status_code=500, detail=str(e)) - return StatusResponse(success=True) - - -# --------------------------------------------------------------------------- -# Heroic endpoints -# --------------------------------------------------------------------------- - - -@app.get("/heroic/games/{app_id}/config") -async def heroic_game_config(app_id: str) -> HeroicGameConfigResponse: - cfg = get_heroic_game_config(app_id) - return HeroicGameConfigResponse( - app_id=cfg.app_id, - exists=cfg.exists, - wine_prefix=cfg.wine_prefix, - wine_version=HeroicWineVersionResponse( - name=cfg.wine_version.name, - bin=cfg.wine_version.bin, - wine_type=cfg.wine_version.wine_type, - ), - other_options=cfg.other_options, - enable_esync=cfg.enable_esync, - enable_fsync=cfg.enable_fsync, - auto_install_dxvk=cfg.auto_install_dxvk, - auto_install_vkd3d=cfg.auto_install_vkd3d, - show_fps=cfg.show_fps, - show_mangohud=cfg.show_mangohud, - use_game_mode=cfg.use_game_mode, - nvidia_prime=cfg.nvidia_prime, - saves_path=cfg.saves_path, - target_exe=cfg.target_exe, - ) - - -@app.put("/heroic/games/{app_id}/launch-options") -async def update_heroic_launch_options( - app_id: str, body: HeroicLaunchOptionsRequest -) -> StatusResponse: - async with _heroic_lock: - ok = set_heroic_launch_options(app_id, body.options) - if not ok: - raise HTTPException(status_code=500, detail="Failed to write Heroic launch options") - return StatusResponse(success=True) - - -@app.put("/heroic/games/{app_id}/wine-version") -async def update_heroic_wine_version( - app_id: str, body: HeroicWineVersionRequest -) -> StatusResponse: - async with _heroic_lock: - ok = set_heroic_wine_version(app_id, body.name, body.bin, body.wine_type) - if not ok: - raise HTTPException(status_code=500, detail="Failed to write Heroic wine version") - return StatusResponse(success=True) - - -@app.put("/heroic/games/{app_id}/toggles") -async def update_heroic_toggles(app_id: str, body: HeroicTogglesRequest) -> StatusResponse: - async with _heroic_lock: - ok = set_heroic_toggles( - app_id, - enable_esync=body.enable_esync, - enable_fsync=body.enable_fsync, - auto_install_dxvk=body.auto_install_dxvk, - auto_install_vkd3d=body.auto_install_vkd3d, - show_mangohud=body.show_mangohud, - use_game_mode=body.use_game_mode, - nvidia_prime=body.nvidia_prime, - ) - if not ok: - raise HTTPException(status_code=500, detail="Failed to write Heroic toggles") - return StatusResponse(success=True) - - -@app.get("/heroic/wine-versions") -async def heroic_wine_versions() -> list[HeroicWineVersionResponse]: - versions = list_heroic_wine_versions() - return [ - HeroicWineVersionResponse(name=v.name, bin=v.bin, wine_type=v.wine_type) - for v in versions - ] - - -@app.post("/heroic/games/{app_id}/launch") -async def launch_heroic_game(app_id: str) -> StatusResponse: - """Launch a game via heroic:// URI protocol.""" - import subprocess - uri = f"heroic://launch/{app_id}" - try: - subprocess.Popen(["xdg-open", uri], start_new_session=True) - except FileNotFoundError: - try: - subprocess.Popen(["gio", "open", uri], start_new_session=True) - except (FileNotFoundError, OSError) as e: - raise HTTPException(status_code=500, detail=str(e)) - return StatusResponse(success=True, message=f"Launching via {uri}") - - -@app.get("/steam/status") -async def steam_status() -> dict[str, Any]: - _ensure_steam() - return { - "running": is_steam_running(), - "root": str(_steam_root) if _steam_root else None, - "config_path": str(_config_path) if _config_path else None, - } - - -# --------------------------------------------------------------------------- -# CLI entry point — Electron spawns this process -# --------------------------------------------------------------------------- - - -def _find_free_port() -> int: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.bind(("127.0.0.1", 0)) - return s.getsockname()[1] - - -def cli() -> None: - parser = argparse.ArgumentParser(description="ProtonShift API server") - parser.add_argument("--port", type=int, default=0, help="Port to listen on (0 = auto)") - args = parser.parse_args() - - port = args.port if args.port > 0 else _find_free_port() - - # Print port to stdout so Electron main process can read it - print(f"PORT:{port}", flush=True) - - import uvicorn - - uvicorn.run(app, host="127.0.0.1", port=port, log_level="warning") - - -if __name__ == "__main__": - cli() diff --git a/src/game_setup_hub/api/__init__.py b/src/game_setup_hub/api/__init__.py new file mode 100644 index 0000000..0a91a49 --- /dev/null +++ b/src/game_setup_hub/api/__init__.py @@ -0,0 +1,41 @@ +"""ProtonShift FastAPI backend. + +Public surface: ``app`` (the FastAPI instance) and ``cli`` (the entry point +the ``protonshift-api`` console script and Electron call into). + +For backwards compatibility with tests and code that monkeypatched the old +flat ``api.py`` module, a few shared globals are re-exposed via the package +namespace. New code should reach into :mod:`game_setup_hub.api._state` +directly instead. +""" + +from __future__ import annotations + +from typing import Any + +from ..steam import is_steam_running +from . import _state +from ._app import app, cli + +__all__ = ["app", "cli", "is_steam_running"] + + +def __getattr__(name: str) -> Any: + """Back-compat: forward legacy ``api._API_TOKEN`` etc. to ``_state``.""" + mapping = { + "_API_TOKEN": "API_TOKEN", + "_config_path": "config_path", + "_steam_root": "steam_root", + "_steam_discovered": "steam_discovered", + "_vdf_lock": "vdf_lock", + "_env_lock": "env_lock", + "_profiles_lock": "profiles_lock", + "_mangohud_lock": "mangohud_lock", + "_heroic_lock": "heroic_lock", + } + target = mapping.get(name) + if target is not None: + return getattr(_state, target) + raise AttributeError(f"module 'game_setup_hub.api' has no attribute {name!r}") + + diff --git a/src/game_setup_hub/api/__main__.py b/src/game_setup_hub/api/__main__.py new file mode 100644 index 0000000..e507325 --- /dev/null +++ b/src/game_setup_hub/api/__main__.py @@ -0,0 +1,8 @@ +"""Allow ``python -m game_setup_hub.api`` (used by Electron's spawn).""" + +from __future__ import annotations + +from ._app import cli + +if __name__ == "__main__": + cli() diff --git a/src/game_setup_hub/api/_app.py b/src/game_setup_hub/api/_app.py new file mode 100644 index 0000000..4af5820 --- /dev/null +++ b/src/game_setup_hub/api/_app.py @@ -0,0 +1,95 @@ +"""FastAPI app construction, middleware, and CLI entry point.""" + +from __future__ import annotations + +import argparse +import os +import secrets +import socket + +from fastapi import FastAPI, status +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse + +import game_setup_hub._vendor_compat # noqa: F401 # must be first + +from .. import __version__ +from . import _state +from .routes import all_routers + +app = FastAPI(title="ProtonShift API", version=__version__) + + +@app.middleware("http") +async def _auth_middleware(request, call_next): + if _state.API_TOKEN and request.url.path not in _state.AUTH_EXEMPT_PATHS: + expected = f"Bearer {_state.API_TOKEN}" + provided = request.headers.get("authorization", "") + if not secrets.compare_digest(provided, expected): + return JSONResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content={"detail": "Missing or invalid token"}, + headers={"WWW-Authenticate": "Bearer"}, + ) + return await call_next(request) + + +# Tight CORS: the browser side is the Electron renderer (file://) plus the +# Next dev server. Nothing else has any business calling us. +app.add_middleware( + CORSMiddleware, + allow_origins=[ + "http://localhost:3000", + "http://127.0.0.1:3000", + ], + allow_origin_regex=r"^file://.*$", + allow_methods=["GET", "POST", "PUT", "DELETE"], + allow_headers=["Authorization", "Content-Type"], + allow_credentials=False, +) + + +for router in all_routers: + app.include_router(router) + + +# --------------------------------------------------------------------------- +# CLI entry point — Electron spawns this process +# --------------------------------------------------------------------------- + + +def _find_free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def cli() -> None: + parser = argparse.ArgumentParser(description="ProtonShift API server") + parser.add_argument("--port", type=int, default=0, help="Port to listen on (0 = auto)") + parser.add_argument( + "--host", + default="127.0.0.1", + help="Interface to bind. Defaults to loopback; do not change unless you understand the risk.", + ) + args = parser.parse_args() + + port = args.port if args.port > 0 else _find_free_port() + + # If the parent process didn't provision a token, mint one and keep it in + # the environment for child code to read. We deliberately do NOT print the + # token: writing credentials to stdout/stderr risks them ending up in + # journald, IDE consoles, or ELectron logs. Standalone curl users should + # set ``PROTONSHIFT_API_TOKEN`` themselves before launching. + if not _state.API_TOKEN: + _state.API_TOKEN = secrets.token_urlsafe(32) + os.environ["PROTONSHIFT_API_TOKEN"] = _state.API_TOKEN + + print(f"PORT:{port}", flush=True) + + from .. import logging_setup + logging_setup.configure() + + import uvicorn + + uvicorn.run(app, host=args.host, port=port, log_level="warning") diff --git a/src/game_setup_hub/api/_helpers.py b/src/game_setup_hub/api/_helpers.py new file mode 100644 index 0000000..4b0aa21 --- /dev/null +++ b/src/game_setup_hub/api/_helpers.py @@ -0,0 +1,40 @@ +"""Internal helpers shared across routers.""" + +from __future__ import annotations + +from ..heroic import HeroicGame +from ..lutris import LutrisGame +from ..steam import SteamGame +from ._models import HeroicGameResponse, LutrisGameResponse, SteamGameResponse + + +def steam_game_to_response(g: SteamGame) -> SteamGameResponse: + return SteamGameResponse( + app_id=g.app_id, + name=g.name, + install_dir=g.install_dir, + last_played=g.last_played, + library_path=str(g.library_path), + compatdata_path=str(g.compatdata_path) if g.compatdata_path else None, + has_compatdata=g.has_compatdata, + install_path=str(g.install_path) if g.install_path else None, + ) + + +def heroic_game_to_response(g: HeroicGame) -> HeroicGameResponse: + return HeroicGameResponse( + app_id=g.app_id, + name=g.name, + store=g.store, + install_path=str(g.install_path) if g.install_path else None, + prefix_path=str(g.prefix_path) if g.prefix_path else None, + ) + + +def lutris_game_to_response(g: LutrisGame) -> LutrisGameResponse: + return LutrisGameResponse( + app_id=g.app_id, + name=g.name, + install_path=str(g.install_path) if g.install_path else None, + prefix_path=str(g.prefix_path) if g.prefix_path else None, + ) diff --git a/src/game_setup_hub/api/_models.py b/src/game_setup_hub/api/_models.py new file mode 100644 index 0000000..2963efc --- /dev/null +++ b/src/game_setup_hub/api/_models.py @@ -0,0 +1,256 @@ +"""Pydantic request and response models for the API surface.""" + +from __future__ import annotations + +from pydantic import BaseModel + + +class SteamGameResponse(BaseModel): + app_id: str + name: str + install_dir: str + last_played: int + library_path: str + compatdata_path: str | None + has_compatdata: bool + install_path: str | None + source: str = "steam" + + +class HeroicGameResponse(BaseModel): + app_id: str + name: str + store: str + install_path: str | None + prefix_path: str | None + source: str = "heroic" + + +class LutrisGameResponse(BaseModel): + app_id: str + name: str + install_path: str | None + prefix_path: str | None + source: str = "lutris" + + +class GPUInfoResponse(BaseModel): + name: str + driver: str + vram_mb: int | None + temperature: float | None + + +class SystemInfoResponse(BaseModel): + gpus: list[GPUInfoResponse] + power_profiles: list[str] + current_power_profile: str | None + + +class LaunchPresetResponse(BaseModel): + name: str + value: str + description: str + install_command: str + install_url: str + is_installed: bool + + +class ProfileResponse(BaseModel): + name: str + launch_options: str + compat_tool: str + env_vars: dict[str, str] + power_profile: str + + +class ProfileCreateRequest(BaseModel): + name: str + launch_options: str = "" + compat_tool: str = "" + env_vars: dict[str, str] = {} + power_profile: str = "" + + +class LaunchOptionsRequest(BaseModel): + options: str + + +class CompatToolRequest(BaseModel): + tool_name: str + + +class EnvVarsRequest(BaseModel): + vars: dict[str, str] + + +class PowerProfileRequest(BaseModel): + profile: str + + +class ProtontricksRequest(BaseModel): + verb: str | None = None + + +class PrefixInfoResponse(BaseModel): + path: str + exists: bool + size_bytes: int = 0 + size_human: str = "" + created: str = "" + dxvk_version: str = "" + vkd3d_version: str = "" + + +class ControllerResponse(BaseModel): + id: str + name: str + device_path: str + controller_type: str + vendor_id: str = "" + product_id: str = "" + bus_type: str = "" + version: str = "" + + +class MonitorResponse(BaseModel): + name: str + connected: bool + resolution: str + refresh_rate: str + primary: bool + position: str + + +class SetResolutionRequest(BaseModel): + monitor: str + width: int + height: int + refresh: float = 0 + + +class SaveLocationResponse(BaseModel): + path: str + exists: bool + size_bytes: int = 0 + size_human: str = "" + label: str = "" + + +class BackupInfoResponse(BaseModel): + path: str + filename: str + size_bytes: int + size_human: str + created: str + + +class BackupRequest(BaseModel): + paths: list[str] + + +class RestoreRequest(BaseModel): + backup_path: str + target_dir: str + + +class MangoHudConfigRequest(BaseModel): + config: dict[str, str] + + +class GameFixResponse(BaseModel): + title: str + description: str + fix_type: str + key: str + value: str + source: str + + +class GameFixCreateRequest(BaseModel): + title: str + description: str = "" + fix_type: str = "env" + key: str = "" + value: str = "" + + +class GamescopeBuildRequest(BaseModel): + output_width: int = 0 + output_height: int = 0 + game_width: int = 0 + game_height: int = 0 + fps_limit: int = 0 + fsr: bool = False + fsr_sharpness: int = 5 + integer_scale: bool = False + hdr: bool = False + nested: bool = True + borderless: bool = True + fullscreen: bool = True + extra_args: str = "" + + +class ShaderCacheResponse(BaseModel): + app_id: str + path: str + exists: bool + size_bytes: int = 0 + size_human: str = "" + + +class OpenPathRequest(BaseModel): + path: str + + +class OpenUriRequest(BaseModel): + uri: str + + +class HeroicWineVersionResponse(BaseModel): + name: str + bin: str + wine_type: str + + +class HeroicGameConfigResponse(BaseModel): + app_id: str + exists: bool + wine_prefix: str = "" + wine_version: HeroicWineVersionResponse = HeroicWineVersionResponse(name="", bin="", wine_type="") + other_options: str = "" + enable_esync: bool = False + enable_fsync: bool = False + auto_install_dxvk: bool = True + auto_install_vkd3d: bool = False + show_fps: bool = False + show_mangohud: bool = False + use_game_mode: bool = False + nvidia_prime: bool = False + saves_path: str = "" + target_exe: str = "" + + +class HeroicLaunchOptionsRequest(BaseModel): + options: str + + +class HeroicWineVersionRequest(BaseModel): + name: str + bin: str + wine_type: str + + +class HeroicTogglesRequest(BaseModel): + enable_esync: bool | None = None + enable_fsync: bool | None = None + auto_install_dxvk: bool | None = None + auto_install_vkd3d: bool | None = None + show_mangohud: bool | None = None + use_game_mode: bool | None = None + nvidia_prime: bool | None = None + + +class StatusResponse(BaseModel): + success: bool + message: str = "" diff --git a/src/game_setup_hub/api/_state.py b/src/game_setup_hub/api/_state.py new file mode 100644 index 0000000..9ce8cf8 --- /dev/null +++ b/src/game_setup_hub/api/_state.py @@ -0,0 +1,56 @@ +"""Shared mutable state for the FastAPI app and routers. + +Every router imports this module and reads/writes attributes on it so the +locks and cached Steam discovery are shared across the package. Modules +should NOT do ``from ._state import API_TOKEN`` because that copies the +value at import time; use ``_state.API_TOKEN`` instead. +""" + +from __future__ import annotations + +import asyncio +import logging +import os +from pathlib import Path + +log = logging.getLogger("protonshift.api") + +# Auth token. The Electron main process generates this and passes it through +# ``PROTONSHIFT_API_TOKEN``. Standalone CLI users get a freshly-printed token +# at startup. Empty disables auth (back-compat for ``protonshift-api`` +# invocations without setting the env var — only safe on a single-user desktop). +API_TOKEN: str = os.environ.get("PROTONSHIFT_API_TOKEN", "") + +# Endpoints exempt from auth. ``/health`` lets Electron poll for readiness +# before the token has propagated; ``/docs`` etc. are FastAPI's own. +AUTH_EXEMPT_PATHS: frozenset[str] = frozenset({ + "/health", + "/docs", + "/openapi.json", + "/redoc", +}) + +# Locks for serializing writes to shared resources. +vdf_lock = asyncio.Lock() +env_lock = asyncio.Lock() +profiles_lock = asyncio.Lock() +mangohud_lock = asyncio.Lock() +heroic_lock = asyncio.Lock() + +# Cached Steam discovery (resolved once per process). +steam_root: Path | None = None +config_path: Path | None = None +steam_discovered = False + + +def ensure_steam() -> None: + """Discover the Steam root + ``localconfig.vdf`` path lazily, once.""" + global steam_root, config_path, steam_discovered + if steam_discovered: + return + from ..steam import discover_games, get_localconfig_path + + steam_root, _ = discover_games() + if steam_root: + config_path = get_localconfig_path(steam_root) + steam_discovered = True diff --git a/src/game_setup_hub/api/routes/__init__.py b/src/game_setup_hub/api/routes/__init__.py new file mode 100644 index 0000000..2995703 --- /dev/null +++ b/src/game_setup_hub/api/routes/__init__.py @@ -0,0 +1,18 @@ +"""Router collection. Add new routers here so ``_app`` picks them up.""" + +from __future__ import annotations + +from fastapi import APIRouter + +from . import games, health, heroic, mangohud, profiles, saves, system, utility + +all_routers: list[APIRouter] = [ + health.router, + games.router, + system.router, + saves.router, + mangohud.router, + heroic.router, + profiles.router, + utility.router, +] diff --git a/src/game_setup_hub/api/routes/games.py b/src/game_setup_hub/api/routes/games.py new file mode 100644 index 0000000..dfbdafd --- /dev/null +++ b/src/game_setup_hub/api/routes/games.py @@ -0,0 +1,129 @@ +"""Game discovery + Steam/Heroic/Lutris listing + per-game Steam config.""" + +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, HTTPException + +from ...heroic import discover_heroic_games +from ...lutris import discover_lutris_games +from ...steam import discover_games, get_available_proton_tools, is_steam_running +from ...vdf_config import ( + get_compat_tool, + get_launch_options, + set_compat_tool, + set_launch_options, +) +from .. import _state +from .._helpers import ( + heroic_game_to_response, + lutris_game_to_response, + steam_game_to_response, +) +from .._models import ( + CompatToolRequest, + LaunchOptionsRequest, + StatusResponse, +) + +router = APIRouter() + + +@router.get("/games") +async def list_games() -> dict[str, Any]: + _state.ensure_steam() + _, steam_games = discover_games() + heroic_games = discover_heroic_games() + lutris_games = discover_lutris_games() + return { + "steam": [steam_game_to_response(g) for g in steam_games], + "heroic": [heroic_game_to_response(g) for g in heroic_games], + "lutris": [lutris_game_to_response(g) for g in lutris_games], + "steam_running": is_steam_running(), + } + + +@router.get("/games/{app_id}/launch-options") +async def get_game_launch_options(app_id: str) -> dict[str, str]: + _state.ensure_steam() + if not _state.config_path: + return {"options": ""} + try: + opts = get_launch_options(_state.config_path, app_id) + except (OSError, KeyError, ValueError) as exc: + _state.log.warning("get_launch_options(%s) failed: %s", app_id, exc) + opts = "" + return {"options": opts} + + +@router.put("/games/{app_id}/launch-options") +async def update_launch_options(app_id: str, body: LaunchOptionsRequest) -> StatusResponse: + _state.ensure_steam() + if not _state.config_path: + raise HTTPException(status_code=404, detail="Steam localconfig.vdf not found") + if is_steam_running(): + # Steam holds localconfig.vdf in memory and rewrites on shutdown, + # so any edit while it is running gets clobbered. + raise HTTPException( + status_code=409, + detail="Steam is running. Quit Steam fully before editing launch options.", + ) + async with _state.vdf_lock: + ok = set_launch_options(_state.config_path, app_id, body.options) + if not ok: + raise HTTPException(status_code=500, detail="Failed to write localconfig.vdf") + return StatusResponse(success=True) + + +@router.get("/games/{app_id}/compat-tool") +async def get_game_compat_tool(app_id: str) -> dict[str, str]: + _state.ensure_steam() + if not _state.config_path: + return {"tool": ""} + try: + tool = get_compat_tool(_state.config_path, app_id) + except (OSError, KeyError, ValueError) as exc: + _state.log.warning("get_compat_tool(%s) failed: %s", app_id, exc) + tool = "" + return {"tool": tool} + + +@router.put("/games/{app_id}/compat-tool") +async def update_compat_tool(app_id: str, body: CompatToolRequest) -> StatusResponse: + _state.ensure_steam() + if not _state.config_path: + raise HTTPException(status_code=404, detail="Steam localconfig.vdf not found") + if is_steam_running(): + raise HTTPException( + status_code=409, + detail="Steam is running. Quit Steam fully before changing the compat tool.", + ) + async with _state.vdf_lock: + ok = set_compat_tool(_state.config_path, app_id, body.tool_name) + if not ok: + raise HTTPException(status_code=500, detail="Failed to write localconfig.vdf") + return StatusResponse(success=True) + + +@router.get("/games/{app_id}/proton-tools") +async def list_proton_tools(app_id: str) -> dict[str, Any]: + _state.ensure_steam() + tools = get_available_proton_tools(_state.steam_root) + current = "" + if _state.config_path: + try: + current = get_compat_tool(_state.config_path, app_id) + except (OSError, KeyError, ValueError) as exc: + _state.log.warning("get_compat_tool(%s) failed in proton-tools: %s", app_id, exc) + return {"tools": tools, "current": current} + + +@router.get("/steam/status") +async def steam_status() -> dict[str, Any]: + _state.ensure_steam() + return { + "running": is_steam_running(), + "root": str(_state.steam_root) if _state.steam_root else None, + "config_path": str(_state.config_path) if _state.config_path else None, + } diff --git a/src/game_setup_hub/api/routes/health.py b/src/game_setup_hub/api/routes/health.py new file mode 100644 index 0000000..b3b1180 --- /dev/null +++ b/src/game_setup_hub/api/routes/health.py @@ -0,0 +1,13 @@ +"""Liveness/readiness probe.""" + +from __future__ import annotations + +from fastapi import APIRouter + +router = APIRouter() + + +@router.get("/health") +async def health() -> dict[str, str]: + """Readiness probe. Exempt from auth so Electron can poll on startup.""" + return {"status": "ok"} diff --git a/src/game_setup_hub/api/routes/heroic.py b/src/game_setup_hub/api/routes/heroic.py new file mode 100644 index 0000000..e1d831c --- /dev/null +++ b/src/game_setup_hub/api/routes/heroic.py @@ -0,0 +1,115 @@ +"""Heroic Games Launcher integration endpoints.""" + +from __future__ import annotations + +import subprocess + +from fastapi import APIRouter, HTTPException + +from ...heroic_config import ( + get_heroic_game_config, + list_heroic_wine_versions, + set_heroic_launch_options, + set_heroic_toggles, + set_heroic_wine_version, +) +from .. import _state +from .._models import ( + HeroicGameConfigResponse, + HeroicLaunchOptionsRequest, + HeroicTogglesRequest, + HeroicWineVersionRequest, + HeroicWineVersionResponse, + StatusResponse, +) + +router = APIRouter(prefix="/heroic") + + +@router.get("/games/{app_id}/config") +async def heroic_game_config(app_id: str) -> HeroicGameConfigResponse: + cfg = get_heroic_game_config(app_id) + return HeroicGameConfigResponse( + app_id=cfg.app_id, + exists=cfg.exists, + wine_prefix=cfg.wine_prefix, + wine_version=HeroicWineVersionResponse( + name=cfg.wine_version.name, + bin=cfg.wine_version.bin, + wine_type=cfg.wine_version.wine_type, + ), + other_options=cfg.other_options, + enable_esync=cfg.enable_esync, + enable_fsync=cfg.enable_fsync, + auto_install_dxvk=cfg.auto_install_dxvk, + auto_install_vkd3d=cfg.auto_install_vkd3d, + show_fps=cfg.show_fps, + show_mangohud=cfg.show_mangohud, + use_game_mode=cfg.use_game_mode, + nvidia_prime=cfg.nvidia_prime, + saves_path=cfg.saves_path, + target_exe=cfg.target_exe, + ) + + +@router.put("/games/{app_id}/launch-options") +async def update_heroic_launch_options( + app_id: str, body: HeroicLaunchOptionsRequest +) -> StatusResponse: + async with _state.heroic_lock: + ok = set_heroic_launch_options(app_id, body.options) + if not ok: + raise HTTPException(status_code=500, detail="Failed to write Heroic launch options") + return StatusResponse(success=True) + + +@router.put("/games/{app_id}/wine-version") +async def update_heroic_wine_version( + app_id: str, body: HeroicWineVersionRequest +) -> StatusResponse: + async with _state.heroic_lock: + ok = set_heroic_wine_version(app_id, body.name, body.bin, body.wine_type) + if not ok: + raise HTTPException(status_code=500, detail="Failed to write Heroic wine version") + return StatusResponse(success=True) + + +@router.put("/games/{app_id}/toggles") +async def update_heroic_toggles(app_id: str, body: HeroicTogglesRequest) -> StatusResponse: + async with _state.heroic_lock: + ok = set_heroic_toggles( + app_id, + enable_esync=body.enable_esync, + enable_fsync=body.enable_fsync, + auto_install_dxvk=body.auto_install_dxvk, + auto_install_vkd3d=body.auto_install_vkd3d, + show_mangohud=body.show_mangohud, + use_game_mode=body.use_game_mode, + nvidia_prime=body.nvidia_prime, + ) + if not ok: + raise HTTPException(status_code=500, detail="Failed to write Heroic toggles") + return StatusResponse(success=True) + + +@router.get("/wine-versions") +async def heroic_wine_versions() -> list[HeroicWineVersionResponse]: + versions = list_heroic_wine_versions() + return [ + HeroicWineVersionResponse(name=v.name, bin=v.bin, wine_type=v.wine_type) + for v in versions + ] + + +@router.post("/games/{app_id}/launch") +async def launch_heroic_game(app_id: str) -> StatusResponse: + """Launch a game via heroic:// URI protocol.""" + uri = f"heroic://launch/{app_id}" + try: + subprocess.Popen(["xdg-open", uri], start_new_session=True) + except FileNotFoundError: + try: + subprocess.Popen(["gio", "open", uri], start_new_session=True) + except (FileNotFoundError, OSError) as e: + raise HTTPException(status_code=500, detail=str(e)) from e + return StatusResponse(success=True, message=f"Launching via {uri}") diff --git a/src/game_setup_hub/api/routes/mangohud.py b/src/game_setup_hub/api/routes/mangohud.py new file mode 100644 index 0000000..ee72d39 --- /dev/null +++ b/src/game_setup_hub/api/routes/mangohud.py @@ -0,0 +1,79 @@ +"""MangoHud configuration endpoints (global + per-game).""" + +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, HTTPException + +from ...mangohud import ( + MANGOHUD_PARAMS, + MANGOHUD_PRESETS, + get_per_game_config_path, + is_mangohud_available, + list_per_game_configs, + read_mangohud_config, + write_mangohud_config, +) +from .. import _state +from .._models import MangoHudConfigRequest, StatusResponse + +router = APIRouter(prefix="/mangohud") + + +@router.get("/available") +async def mangohud_available() -> dict[str, bool]: + return {"available": is_mangohud_available()} + + +@router.get("/config") +async def get_mangohud_config() -> dict[str, Any]: + return { + "config": read_mangohud_config(), + "params": MANGOHUD_PARAMS, + } + + +@router.put("/config") +async def update_mangohud_config(body: MangoHudConfigRequest) -> StatusResponse: + async with _state.mangohud_lock: + ok = write_mangohud_config(body.config) + if not ok: + raise HTTPException(status_code=500, detail="Failed to write MangoHud config") + return StatusResponse(success=True) + + +@router.get("/presets") +async def get_mangohud_presets() -> dict[str, dict[str, str]]: + return MANGOHUD_PRESETS + + +@router.get("/per-game") +async def list_mangohud_per_game() -> list[dict[str, str]]: + """List all existing per-game MangoHud config files.""" + return list_per_game_configs() + + +@router.get("/per-game/{game_name}") +async def get_mangohud_per_game_config(game_name: str) -> dict[str, Any]: + """Read a per-game MangoHud config.""" + conf_path = get_per_game_config_path(game_name) + return { + "path": str(conf_path), + "exists": conf_path.exists(), + "config": read_mangohud_config(conf_path) if conf_path.exists() else {}, + "params": MANGOHUD_PARAMS, + } + + +@router.put("/per-game/{game_name}") +async def update_mangohud_per_game_config( + game_name: str, body: MangoHudConfigRequest +) -> StatusResponse: + """Write a per-game MangoHud config.""" + conf_path = get_per_game_config_path(game_name) + async with _state.mangohud_lock: + ok = write_mangohud_config(body.config, conf_path) + if not ok: + raise HTTPException(status_code=500, detail="Failed to write per-game MangoHud config") + return StatusResponse(success=True) diff --git a/src/game_setup_hub/api/routes/profiles.py b/src/game_setup_hub/api/routes/profiles.py new file mode 100644 index 0000000..7d54db4 --- /dev/null +++ b/src/game_setup_hub/api/routes/profiles.py @@ -0,0 +1,61 @@ +"""Application profile CRUD.""" + +from __future__ import annotations + +from fastapi import APIRouter, HTTPException + +from ...profiles_storage import ( + ApplicationProfile, + delete_profile, + list_profiles, + load_profile, + save_profile, +) +from .. import _state +from .._models import ProfileCreateRequest, ProfileResponse, StatusResponse + +router = APIRouter(prefix="/profiles") + + +@router.get("") +async def list_all_profiles() -> list[str]: + return list_profiles() + + +@router.get("/{name}") +async def get_profile(name: str) -> ProfileResponse: + p = load_profile(name) + if not p: + raise HTTPException(status_code=404, detail="Profile not found") + return ProfileResponse( + name=p.name, + launch_options=p.launch_options, + compat_tool=p.compat_tool, + env_vars=p.env_vars, + power_profile=p.power_profile, + ) + + +@router.post("") +async def create_profile(body: ProfileCreateRequest) -> StatusResponse: + profile = ApplicationProfile( + name=body.name, + launch_options=body.launch_options, + compat_tool=body.compat_tool, + env_vars=body.env_vars, + power_profile=body.power_profile, + ) + async with _state.profiles_lock: + ok = save_profile(profile) + if not ok: + raise HTTPException(status_code=500, detail="Failed to save profile") + return StatusResponse(success=True) + + +@router.delete("/{name}") +async def remove_profile(name: str) -> StatusResponse: + async with _state.profiles_lock: + ok = delete_profile(name) + if not ok: + raise HTTPException(status_code=404, detail="Profile not found or could not be deleted") + return StatusResponse(success=ok) diff --git a/src/game_setup_hub/api/routes/saves.py b/src/game_setup_hub/api/routes/saves.py new file mode 100644 index 0000000..a061a1b --- /dev/null +++ b/src/game_setup_hub/api/routes/saves.py @@ -0,0 +1,78 @@ +"""Game save discovery, backup, restore.""" + +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, HTTPException + +from ...fsutil import human_size +from ...paths import PathValidationError, validate_user_path +from ...saves import backup_saves, find_save_paths, list_backups, restore_backup +from ...steam import discover_games +from .. import _state +from .._models import ( + BackupInfoResponse, + BackupRequest, + RestoreRequest, + SaveLocationResponse, + StatusResponse, +) + +router = APIRouter() + + +@router.get("/games/{app_id}/saves") +async def game_saves(app_id: str, prefix_path: str | None = None) -> list[SaveLocationResponse]: + prefix = prefix_path + if not prefix: + _state.ensure_steam() + _, steam_games = discover_games() + game = next((g for g in steam_games if g.app_id == app_id), None) + prefix = str(game.compatdata_path) if game and game.compatdata_path else None + locations = find_save_paths(app_id, prefix) + return [ + SaveLocationResponse( + path=loc.path, + exists=loc.exists, + size_bytes=loc.size_bytes, + size_human=human_size(loc.size_bytes), + label=loc.label, + ) + for loc in locations + ] + + +@router.post("/games/{app_id}/saves/backup") +async def backup_game_saves(app_id: str, body: BackupRequest) -> dict[str, Any]: + result = backup_saves(app_id, body.paths) + if not result: + raise HTTPException(status_code=500, detail="Failed to create backup") + return {"path": result} + + +@router.get("/games/{app_id}/saves/backups") +async def list_game_backups(app_id: str) -> list[BackupInfoResponse]: + backups = list_backups(app_id) + return [ + BackupInfoResponse( + path=b.path, + filename=b.filename, + size_bytes=b.size_bytes, + size_human=human_size(b.size_bytes), + created=b.created, + ) + for b in backups + ] + + +@router.post("/games/{app_id}/saves/restore") +async def restore_game_saves(app_id: str, body: RestoreRequest) -> StatusResponse: + try: + target = validate_user_path(body.target_dir, allow_missing=True) + except PathValidationError as exc: + raise HTTPException(status_code=400, detail=f"target_dir rejected: {exc}") from exc + ok = restore_backup(body.backup_path, str(target)) + if not ok: + raise HTTPException(status_code=500, detail="Failed to restore backup") + return StatusResponse(success=True, message="Backup restored") diff --git a/src/game_setup_hub/api/routes/system.py b/src/game_setup_hub/api/routes/system.py new file mode 100644 index 0000000..d0658a1 --- /dev/null +++ b/src/game_setup_hub/api/routes/system.py @@ -0,0 +1,158 @@ +"""System info: GPU, power profile, env vars, presets, controllers, displays.""" + +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, HTTPException + +from ...controllers import get_controllers, get_sdl_mapping +from ...display import get_monitors, get_session_type, set_resolution +from ...env_vars import ENV_PRESETS, read_gaming_env, write_gaming_env +from ...gpu import ( + get_current_power_profile, + get_gpu_info, + get_power_profiles, + set_power_profile, +) +from ...presets import LAUNCH_PRESETS +from .. import _state +from .._models import ( + ControllerResponse, + EnvVarsRequest, + GPUInfoResponse, + LaunchPresetResponse, + MonitorResponse, + PowerProfileRequest, + SetResolutionRequest, + StatusResponse, + SystemInfoResponse, +) + +router = APIRouter() + + +@router.get("/system") +async def system_info() -> SystemInfoResponse: + try: + gpus = get_gpu_info() + except OSError as exc: + _state.log.warning("get_gpu_info failed: %s", exc) + gpus = [] + try: + profiles = get_power_profiles() + except OSError as exc: + _state.log.warning("get_power_profiles failed: %s", exc) + profiles = [] + try: + current = get_current_power_profile() + except OSError as exc: + _state.log.warning("get_current_power_profile failed: %s", exc) + current = None + return SystemInfoResponse( + gpus=[ + GPUInfoResponse(name=g.name, driver=g.driver, vram_mb=g.vram_mb, temperature=g.temperature) + for g in gpus + ], + power_profiles=profiles, + current_power_profile=current, + ) + + +@router.put("/system/power-profile") +async def update_power_profile(body: PowerProfileRequest) -> StatusResponse: + ok, msg = set_power_profile(body.profile) + return StatusResponse(success=ok, message=msg) + + +@router.get("/env-vars") +async def get_env_vars() -> dict[str, str]: + try: + return read_gaming_env() + except OSError as exc: + _state.log.warning("read_gaming_env failed: %s", exc) + return {} + + +@router.put("/env-vars") +async def update_env_vars(body: EnvVarsRequest) -> StatusResponse: + async with _state.env_lock: + ok = write_gaming_env(body.vars) + if not ok: + raise HTTPException(status_code=500, detail="Failed to write env config") + return StatusResponse(success=True) + + +@router.get("/env-presets") +async def list_env_presets() -> dict[str, dict[str, str]]: + return ENV_PRESETS + + +@router.get("/presets") +async def list_presets() -> list[LaunchPresetResponse]: + return [ + LaunchPresetResponse( + name=p.name, + value=p.value, + description=p.description, + install_command=p.install_command, + install_url=p.install_url, + is_installed=p.is_installed(), + ) + for p in LAUNCH_PRESETS + ] + + +@router.get("/controllers") +async def list_controllers() -> list[ControllerResponse]: + ctrls = get_controllers() + return [ + ControllerResponse( + id=c.id, + name=c.name, + device_path=c.device_path, + controller_type=c.controller_type, + vendor_id=c.vendor_id, + product_id=c.product_id, + bus_type=c.bus_type, + version=c.version, + ) + for c in ctrls + ] + + +@router.get("/controllers/{controller_id}/sdl-mapping") +async def controller_sdl_mapping(controller_id: str) -> dict[str, str]: + ctrls = get_controllers() + ctrl = next((c for c in ctrls if c.id == controller_id), None) + if not ctrl: + raise HTTPException(status_code=404, detail="Controller not found") + return {"mapping": get_sdl_mapping(ctrl)} + + +@router.get("/display/monitors") +async def list_monitors() -> dict[str, Any]: + session = get_session_type() + monitors = get_monitors() + return { + "session_type": session, + "monitors": [ + MonitorResponse( + name=m.name, + connected=m.connected, + resolution=m.resolution, + refresh_rate=m.refresh_rate, + primary=m.primary, + position=m.position, + ) + for m in monitors + ], + } + + +@router.put("/display/resolution") +async def update_resolution(body: SetResolutionRequest) -> StatusResponse: + ok = set_resolution(body.monitor, body.width, body.height, body.refresh) + if not ok: + raise HTTPException(status_code=500, detail="Failed to set resolution") + return StatusResponse(success=True, message=f"Set {body.monitor} to {body.width}x{body.height}") diff --git a/src/game_setup_hub/api/routes/utility.py b/src/game_setup_hub/api/routes/utility.py new file mode 100644 index 0000000..e3558e0 --- /dev/null +++ b/src/game_setup_hub/api/routes/utility.py @@ -0,0 +1,252 @@ +"""Misc one-shot utilities: protontricks, gamescope, fixes, shader cache, prefix, open-path/uri.""" + +from __future__ import annotations + +import subprocess +from typing import Any + +from fastapi import APIRouter, HTTPException + +from ...fixes import add_user_fix, get_fixes +from ...fsutil import human_size +from ...gamescope import ( + GamescopeOptions, + build_gamescope_argv, + build_gamescope_cmd, + is_gamescope_available, +) +from ...paths import PathValidationError, validate_user_path +from ...prefix import delete_prefix, get_prefix_info +from ...protontricks import COMMON_VERBS, is_protontricks_available, run_protontricks +from ...shader_cache import ( + clear_shader_cache, + get_shader_cache_info, + get_total_shader_cache_size, +) +from ...steam import discover_games +from .. import _state +from .._models import ( + GameFixCreateRequest, + GameFixResponse, + GamescopeBuildRequest, + OpenPathRequest, + OpenUriRequest, + PrefixInfoResponse, + ProtontricksRequest, + ShaderCacheResponse, + StatusResponse, +) + +router = APIRouter() + + +# --- protontricks ---------------------------------------------------------- + + +@router.post("/games/{app_id}/protontricks") +async def trigger_protontricks(app_id: str, body: ProtontricksRequest) -> StatusResponse: + if not is_protontricks_available(): + raise HTTPException(status_code=404, detail="Protontricks not installed") + ok, msg = run_protontricks(app_id, body.verb) + if not ok: + raise HTTPException(status_code=500, detail=msg) + return StatusResponse(success=True, message=msg) + + +@router.get("/protontricks/verbs") +async def list_protontricks_verbs() -> dict[str, Any]: + return { + "available": is_protontricks_available(), + "verbs": [{"id": v[0], "label": v[1]} for v in COMMON_VERBS], + } + + +# --- gamescope ------------------------------------------------------------- + + +@router.get("/gamescope/available") +async def gamescope_available() -> dict[str, bool]: + return {"available": is_gamescope_available()} + + +@router.post("/gamescope/build-cmd") +async def gamescope_build(body: GamescopeBuildRequest) -> dict[str, Any]: + opts = GamescopeOptions( + output_width=body.output_width, + output_height=body.output_height, + game_width=body.game_width, + game_height=body.game_height, + fps_limit=body.fps_limit, + fsr=body.fsr, + fsr_sharpness=body.fsr_sharpness, + integer_scale=body.integer_scale, + hdr=body.hdr, + nested=body.nested, + borderless=body.borderless, + fullscreen=body.fullscreen, + extra_args=body.extra_args, + ) + return { + "command": build_gamescope_cmd(opts), + "argv": build_gamescope_argv(opts), + } + + +# --- fixes ------------------------------------------------------------------ + + +@router.get("/games/{app_id}/fixes") +async def list_game_fixes(app_id: str) -> list[GameFixResponse]: + fixes = get_fixes(app_id) + return [ + GameFixResponse( + title=f.title, + description=f.description, + fix_type=f.fix_type, + key=f.key, + value=f.value, + source=f.source, + ) + for f in fixes + ] + + +@router.post("/games/{app_id}/fixes") +async def create_game_fix(app_id: str, body: GameFixCreateRequest) -> StatusResponse: + ok = add_user_fix( + app_id=app_id, + title=body.title, + description=body.description, + fix_type=body.fix_type, + key=body.key, + value=body.value, + ) + if not ok: + raise HTTPException(status_code=500, detail="Failed to save fix") + return StatusResponse(success=True) + + +# --- shader cache ----------------------------------------------------------- + + +@router.get("/games/{app_id}/shader-cache") +async def game_shader_cache(app_id: str) -> ShaderCacheResponse: + _state.ensure_steam() + if not _state.steam_root: + return ShaderCacheResponse(app_id=app_id, path="", exists=False) + info = get_shader_cache_info(_state.steam_root, app_id) + return ShaderCacheResponse( + app_id=app_id, + path=info.path, + exists=info.exists, + size_bytes=info.size_bytes, + size_human=human_size(info.size_bytes), + ) + + +@router.delete("/games/{app_id}/shader-cache") +async def clear_game_shader_cache(app_id: str) -> StatusResponse: + _state.ensure_steam() + if not _state.steam_root: + raise HTTPException(status_code=404, detail="Steam root not found") + ok = clear_shader_cache(_state.steam_root, app_id) + if not ok: + raise HTTPException(status_code=500, detail="Failed to clear shader cache") + return StatusResponse(success=True, message="Shader cache cleared") + + +@router.get("/shader-cache/total") +async def total_shader_cache() -> dict[str, Any]: + _state.ensure_steam() + if not _state.steam_root: + return {"size_bytes": 0, "size_human": "0.0 B"} + total = get_total_shader_cache_size(_state.steam_root) + return {"size_bytes": total, "size_human": human_size(total)} + + +# --- prefix ----------------------------------------------------------------- + + +@router.get("/games/{app_id}/prefix-info") +async def game_prefix_info(app_id: str, prefix_path: str | None = None) -> PrefixInfoResponse: + ppath = prefix_path + if not ppath: + _state.ensure_steam() + _, steam_games = discover_games() + game = next((g for g in steam_games if g.app_id == app_id), None) + ppath = str(game.compatdata_path) if game and game.compatdata_path else None + if not ppath: + return PrefixInfoResponse(path="", exists=False) + info = get_prefix_info(ppath) + return PrefixInfoResponse( + path=info.path, + exists=info.exists, + size_bytes=info.size_bytes, + size_human=human_size(info.size_bytes), + created=info.created, + dxvk_version=info.dxvk_version, + vkd3d_version=info.vkd3d_version, + ) + + +@router.delete("/games/{app_id}/prefix") +async def delete_game_prefix(app_id: str, prefix_path: str | None = None) -> StatusResponse: + ppath = prefix_path + if not ppath: + _state.ensure_steam() + _, steam_games = discover_games() + game = next((g for g in steam_games if g.app_id == app_id), None) + if not game or not game.compatdata_path: + raise HTTPException(status_code=404, detail="No prefix found for this game") + ppath = str(game.compatdata_path) + try: + # Reject any prefix path that doesn't live under a user-writable root. + # Without this check, an attacker who can reach 127.0.0.1 could send + # ``prefix_path=/etc`` and delete the system config tree. + validate_user_path(ppath) + except PathValidationError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + ok = delete_prefix(ppath) + if not ok: + raise HTTPException(status_code=500, detail="Failed to delete prefix") + return StatusResponse( + success=True, + message="Prefix deleted. The launcher will recreate it on next launch.", + ) + + +# --- open-path / open-uri --------------------------------------------------- + + +@router.post("/open-path") +async def open_path(body: OpenPathRequest) -> StatusResponse: + """Open a file or folder in the system file manager. + + Restricted to user-writable roots (``$HOME``, mounts, ``/tmp``) to keep + the localhost API from being a generic system-wide xdg-open proxy. + """ + try: + target = validate_user_path(body.path) + except PathValidationError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + try: + subprocess.Popen(["xdg-open", str(target)], start_new_session=True) + except FileNotFoundError: + try: + subprocess.Popen(["gio", "open", str(target)], start_new_session=True) + except (FileNotFoundError, OSError) as e: + raise HTTPException(status_code=500, detail=str(e)) from e + return StatusResponse(success=True) + + +@router.post("/open-uri") +async def open_uri(body: OpenUriRequest) -> StatusResponse: + """Open a URI (e.g. ``steam://rungameid/...``) with the system handler.""" + try: + subprocess.Popen(["xdg-open", body.uri], start_new_session=True) + except FileNotFoundError: + try: + subprocess.Popen(["gio", "open", body.uri], start_new_session=True) + except (FileNotFoundError, OSError) as e: + raise HTTPException(status_code=500, detail=str(e)) from e + return StatusResponse(success=True) diff --git a/src/game_setup_hub/app.py b/src/game_setup_hub/app.py deleted file mode 100644 index fb79e75..0000000 --- a/src/game_setup_hub/app.py +++ /dev/null @@ -1,1053 +0,0 @@ -"""ProtonShift — GTK4 application.""" - -from __future__ import annotations - -import subprocess -import sys -from pathlib import Path - -# GI must be imported before other modules that use it -import gi # noqa: I001 - -gi.require_version("Gdk", "4.0") -gi.require_version("Gtk", "4.0") -gi.require_version("Adw", "1") - -from gi.repository import Adw, Gdk, Gio, GLib, Gtk, Pango # noqa: E402 - -from .env_vars import ENV_PRESETS, read_gaming_env, write_gaming_env # noqa: E402 -from .gpu import get_current_power_profile, get_gpu_info, get_power_profiles, set_power_profile # noqa: E402 -from .presets import LAUNCH_PRESETS, LaunchPreset # noqa: E402 -from .heroic import HeroicGame, discover_heroic_games # noqa: E402 -from .lutris import LutrisGame, discover_lutris_games # noqa: E402 -from .protontricks import COMMON_VERBS, is_protontricks_available, run_protontricks # noqa: E402 -from .steam import ( # noqa: E402 - SteamGame, - discover_games, - get_available_proton_tools, - get_localconfig_path, - is_steam_running, -) -from .vdf_config import get_compat_tool, get_launch_options, set_compat_tool, set_launch_options # noqa: E402 -from .profiles_storage import ( # noqa: E402 - ApplicationProfile, - list_profiles, - load_profile, - save_profile as save_profile_to_storage, -) - - -class GameDetailView(Gtk.Box): - """Detail panel for a selected game.""" - - def __init__( - self, - game: SteamGame, - config_path: Path | None, - window: Adw.ApplicationWindow | None = None, - steam_root: Path | None = None, - **kwargs, - ): - super().__init__(orientation=Gtk.Orientation.VERTICAL, **kwargs) - self.game = game - self.config_path = config_path - self._window = window - self._steam_root = steam_root - - content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10, margin_top=10, margin_bottom=10, margin_start=10, margin_end=10) - - # App ID + Copy - id_row = Adw.ActionRow(title="App ID", subtitle=game.app_id) - copy_btn = Gtk.Button(icon_name="edit-copy-symbolic") - copy_btn.add_css_class("flat") - copy_btn.set_tooltip_text("Copy App ID") - copy_btn.connect("clicked", self._on_copy_app_id) - id_row.add_suffix(copy_btn) - content.append(id_row) - - # Install path - if game.install_path and game.install_path.exists(): - install_row = Adw.ActionRow(title="Install path", subtitle=str(game.install_path)) - open_install_btn = Gtk.Button(label="Open") - open_install_btn.connect("clicked", self._on_open_install_path) - install_row.add_suffix(open_install_btn) - content.append(install_row) - - # Proton version - if config_path and steam_root: - tools = get_available_proton_tools(steam_root) - proton_row = Adw.ComboRow(title="Proton version", subtitle="Force compatibility tool") - proton_model = Gtk.StringList() - for t in tools: - label = "Default" if not t else t.replace("proton_", "Proton ").replace("_", ".") - proton_model.append(label) - proton_row.set_model(proton_model) - current = get_compat_tool(config_path, game.app_id) - if current in tools: - proton_row.set_selected(tools.index(current)) - else: - proton_row.set_selected(0) - proton_row.connect("notify::selected", self._on_proton_changed, config_path) - self._proton_combo = proton_row - content.append(proton_row) - - # Launch options - opts_frame = Gtk.Frame() - opts_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) - opts_label = Gtk.Label(label="Launch Options", xalign=0, margin_bottom=10) - opts_label.add_css_class("title-4") - opts_box.append(opts_label) - - self.launch_entry = Gtk.Entry(placeholder_text="e.g. MANGOHUD=1 gamemoderun %command%", hexpand=True) - self.launch_entry.set_activates_default(True) - opts_box.append(self.launch_entry) - - hint = Gtk.Label( - label="Environment variables + %command%. Saved to Steam config.", - wrap=True, xalign=0, margin_top=10, - ) - hint.add_css_class("dim-label") - hint.set_wrap_mode(Pango.WrapMode.WORD_CHAR) - opts_box.append(hint) - - # Launch presets (Phase 4) — horizontal row - preset_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) - preset_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) - for preset in LAUNCH_PRESETS: - btn = Gtk.Button(label=preset.name) - btn.add_css_class("pill") - btn.connect("clicked", self._on_launch_preset, preset) - preset_box.append(btn) - preset_box.set_hexpand(True) - preset_row.append(preset_box) - self._preset_info_btn = Gtk.Button(icon_name="help-about-symbolic") - self._preset_info_btn.add_css_class("flat") - self._preset_info_btn.set_tooltip_text("Preset help & install instructions") - self._preset_info_btn.connect("clicked", self._on_preset_help) - preset_row.append(self._preset_info_btn) - opts_box.append(preset_row) - - opts_frame.set_child(opts_box) - content.append(opts_frame) - - # Buttons - btn_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) - launch_btn = Gtk.Button(label="Launch in Steam") - launch_btn.connect("clicked", self._on_launch_in_steam) - btn_box.append(launch_btn) - self.save_btn = Gtk.Button(label="Save", css_classes=["suggested-action"]) - self.save_btn.connect("clicked", self._on_save) - btn_box.append(self.save_btn) - - if game.compatdata_path and game.compatdata_path.exists(): - open_btn = Gtk.Button(label="Open Prefix Folder") - open_btn.connect("clicked", self._on_open_prefix) - btn_box.append(open_btn) - - if is_protontricks_available(): - pt_btn = Gtk.Button(label="Run Protontricks") - pt_btn.connect("clicked", self._on_run_protontricks) - btn_box.append(pt_btn) - - # Quick install menu - pt_menu = Gtk.MenuButton(label="Quick Install") - popover = Gtk.Popover() - box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10, margin_top=10, margin_bottom=10, margin_start=10, margin_end=10) - for verb, desc in COMMON_VERBS: - row = Gtk.Button(label=f"{verb} — {desc}") - row.connect("clicked", self._on_install_verb, verb, popover) - box.append(row) - popover.set_child(box) - pt_menu.set_popover(popover) - btn_box.append(pt_menu) - - profile_menu = Gtk.MenuButton(label="Profiles") - profile_popover = Gtk.Popover() - profile_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6, margin_top=10, margin_bottom=10, margin_start=10, margin_end=10) - save_prof_btn = Gtk.Button(label="Save current as profile") - save_prof_btn.connect("clicked", self._on_save_profile, profile_popover) - load_prof_btn = Gtk.Button(label="Load profile") - load_prof_btn.connect("clicked", self._on_load_profile, profile_popover) - profile_box.append(save_prof_btn) - profile_box.append(load_prof_btn) - profile_popover.set_child(profile_box) - profile_menu.set_popover(profile_popover) - btn_box.append(profile_menu) - - content.append(btn_box) - - scroll = Gtk.ScrolledWindow(vexpand=True, hscrollbar_policy=Gtk.PolicyType.NEVER) - scroll.set_child(content) - self.append(scroll) - self._load_options() - - def _load_options(self): - if self.config_path: - opts = get_launch_options(self.config_path, self.game.app_id) - self.launch_entry.set_text(opts) - - def _on_copy_app_id(self, _btn): - clipboard = Gdk.Display.get_default().get_clipboard() - clipboard.set(self.game.app_id) - self._toast("App ID copied") - - def _on_open_install_path(self, _btn): - if self.game.install_path and self.game.install_path.exists(): - path = str(self.game.install_path) - try: - subprocess.run(["xdg-open", path], check=False) - except FileNotFoundError: - subprocess.run(["gio", "open", path], check=False) - - def _on_launch_in_steam(self, _btn): - try: - subprocess.run(["steam", f"steam://rungameid/{self.game.app_id}"], check=False) - except FileNotFoundError: - self._toast("Steam not found") - - def _on_proton_changed(self, row: Adw.ComboRow, _pspec, config_path: Path): - idx = row.get_selected() - tools = get_available_proton_tools(self._steam_root) - if 0 <= idx < len(tools): - tool = tools[idx] - if set_compat_tool(config_path, self.game.app_id, tool): - self._toast("Proton version saved") - - def _on_save(self, _btn): - if not self.config_path: - self._toast("No Steam config found") - return - if is_steam_running(): - dialog = Adw.MessageDialog( - transient_for=self._window, - heading="Steam is running", - body="Editing localconfig.vdf while Steam is running may cause changes to be lost. Close Steam first for reliable saving, or save anyway.", - ) - dialog.add_response("cancel", "Cancel") - dialog.add_response("save", "Save anyway") - dialog.set_default_response("cancel") - dialog.set_close_response("cancel") - dialog.connect("response", self._on_save_dialog_response) - dialog.present() - return - self._do_save() - - def _on_save_dialog_response(self, dialog: Adw.MessageDialog, response: str): - dialog.destroy() - if response == "save": - self._do_save() - - def _do_save(self): - if not self.config_path: - return - opts = self.launch_entry.get_text().strip() - if set_launch_options(self.config_path, self.game.app_id, opts): - self._toast("Saved") - else: - self._toast("Failed to save") - - def _on_open_prefix(self, _btn): - if self.game.compatdata_path and self.game.compatdata_path.exists(): - path = str(self.game.compatdata_path) - try: - subprocess.run(["xdg-open", path], check=False) - except FileNotFoundError: - subprocess.run(["gio", "open", path], check=False) - - def _on_run_protontricks(self, _btn): - ok, err = run_protontricks(self.game.app_id) - if not ok: - self._toast(err) - - def _on_install_verb(self, _btn, verb: str, popover: Gtk.Popover): - popover.popdown() - ok, err = run_protontricks(self.game.app_id, verb) - if not ok: - self._toast(err) - else: - self._toast(f"Installing {verb}…") - - def _on_launch_preset(self, _btn, preset: LaunchPreset): - """Toggle preset in launch options: add if absent, remove if present.""" - current = self.launch_entry.get_text().strip() - tokens = [t for t in current.split() if t] - # Extract parts before %command% - before_cmd: list[str] = [] - for t in tokens: - if t == "%command%": - break - before_cmd.append(t) - - # Preset identifiers: match by VAR= prefix or exact token - preset_atoms = preset.value.split() - is_present = all( - any( - t.startswith(atom.split("=", 1)[0] + "=") if "=" in atom else t == atom - for t in before_cmd - ) - for atom in preset_atoms - ) - - if is_present: - # Remove all tokens matching the preset - keys_to_remove = { - atom.split("=", 1)[0] + "=" if "=" in atom else atom - for atom in preset_atoms - } - new_tokens = [] - for t in before_cmd: - remove = False - for k in keys_to_remove: - if "=" in k: - if t.startswith(k): - remove = True - break - elif t == k: - remove = True - break - if not remove: - new_tokens.append(t) - new_base = " ".join(new_tokens) - else: - # Adding preset — show install hint if tool not installed - if not preset.is_installed() and preset.install_command: - self._show_preset_install_hint(preset) - # Add preset (dedupe: don't add if any atom already there) - existing_keys = {t.split("=", 1)[0] + "=" for t in before_cmd if "=" in t} - existing_words = {t for t in before_cmd if "=" not in t} - to_add = [] - for atom in preset_atoms: - if "=" in atom: - key = atom.split("=", 1)[0] + "=" - if key not in existing_keys: - to_add.append(atom) - existing_keys.add(key) - else: - if atom not in existing_words: - to_add.append(atom) - existing_words.add(atom) - new_base = " ".join(before_cmd + to_add) if to_add else " ".join(before_cmd) - - result = f"{new_base} %command%".strip() if new_base else "%command%" - self.launch_entry.set_text(result) - - def _show_preset_install_hint(self, preset: LaunchPreset): - """Show install instructions for a preset that isn't installed.""" - msg = f"{preset.name} is not installed.\n\nInstall with:\n{preset.install_command}" - if preset.install_url: - msg += f"\n\nMore info: {preset.install_url}" - dialog = Adw.MessageDialog( - transient_for=self._window, - heading="Install required", - body=msg, - ) - dialog.add_response("copy", "Copy command") - dialog.add_response("close", "Close") - dialog.set_default_response("close") - dialog.set_close_response("close") - dialog.connect("response", lambda d, r: self._on_install_dialog_response(d, r, preset)) - dialog.present() - - def _on_install_dialog_response(self, dialog: Adw.MessageDialog, response: str, preset: LaunchPreset): - if response == "copy" and preset.install_command: - clipboard = Gdk.Display.get_default().get_clipboard() - clipboard.set(preset.install_command) - if self._window and hasattr(self._window, "add_toast"): - self._window.add_toast(Adw.Toast.new("Command copied to clipboard")) - dialog.destroy() - - def _get_current_compat_tool(self) -> str: - if hasattr(self, "_proton_combo") and self._steam_root: - tools = get_available_proton_tools(self._steam_root) - idx = self._proton_combo.get_selected() - if 0 <= idx < len(tools): - return tools[idx] - return "" - - def _on_save_profile(self, _btn, popover: Gtk.Popover): - popover.popdown() - dialog = Adw.MessageDialog( - transient_for=self._window, - heading="Save profile", - body="Enter a name for this profile:", - ) - entry = Gtk.Entry(placeholder_text="Profile name") - entry.set_hexpand(True) - box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) - box.append(entry) - dialog.set_extra_child(box) - dialog.add_response("cancel", "Cancel") - dialog.add_response("save", "Save") - dialog.set_default_response("save") - dialog.set_close_response("cancel") - - def on_response(d, response: str): - if response == "save": - name = entry.get_text().strip() - if not name: - self._toast("Enter a profile name") - d.destroy() - return - profile = ApplicationProfile( - name=name, - launch_options=self.launch_entry.get_text().strip(), - compat_tool=self._get_current_compat_tool(), - env_vars=read_gaming_env(), - power_profile=get_current_power_profile() or "", - ) - if save_profile_to_storage(profile): - self._toast("Profile saved") - else: - self._toast("Failed to save profile") - d.destroy() - - dialog.connect("response", on_response) - dialog.present() - - def _on_load_profile(self, _btn, popover: Gtk.Popover): - popover.popdown() - names = list_profiles() - if not names: - self._toast("No profiles saved") - return - dialog = Adw.MessageDialog( - transient_for=self._window, - heading="Load profile", - body="Select a profile to apply:", - ) - listbox = Gtk.ListBox(selection_mode=Gtk.SelectionMode.SINGLE) - for n in names: - row = Adw.ActionRow(title=n) - listbox.append(row) - if names: - listbox.select_row(listbox.get_row_at_index(0)) - scrolled = Gtk.ScrolledWindow(max_content_height=200) - scrolled.set_child(listbox) - dialog.set_extra_child(scrolled) - dialog.add_response("cancel", "Cancel") - dialog.add_response("load", "Load") - dialog.set_default_response("load") - dialog.set_close_response("cancel") - - def on_response(d, response: str): - if response == "load": - row = listbox.get_selected_row() - if row and isinstance(row.get_child(), Adw.ActionRow): - name = row.get_child().get_title() - profile = load_profile(name) - if profile: - self.launch_entry.set_text(profile.launch_options) - if profile.compat_tool and hasattr(self, "_proton_combo") and self.config_path and self._steam_root: - tools = get_available_proton_tools(self._steam_root) - if profile.compat_tool in tools: - self._proton_combo.set_selected(tools.index(profile.compat_tool)) - set_compat_tool(self.config_path, self.game.app_id, profile.compat_tool) - if profile.env_vars: - write_gaming_env(profile.env_vars) - if profile.power_profile: - set_power_profile(profile.power_profile) - self._toast("Profile applied. Save to persist launch options.") - else: - self._toast("Failed to load profile") - d.destroy() - - dialog.connect("response", on_response) - dialog.present() - - def _on_preset_help(self, _btn): - """Show preset help popover with descriptions and install instructions.""" - popover = Gtk.Popover() - popover.set_has_arrow(True) - popover.set_autohide(True) - popover.set_parent(getattr(self, "_preset_info_btn", self)) - popover.add_css_class("preset-help-popover") - box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16, margin_top=16, margin_bottom=16, margin_start=20, margin_end=20) - box.set_size_request(440, 380) - title = Gtk.Label(label="Launch preset help") - title.add_css_class("title-3") - title.set_xalign(0) - box.append(title) - for preset in LAUNCH_PRESETS: - frame = Gtk.Frame() - frame.add_css_class("preset-help-card") - inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6, margin_top=10, margin_bottom=10, margin_start=12, margin_end=12) - preset_title = Gtk.Label(label=preset.name) - preset_title.add_css_class("title-4") - preset_title.set_xalign(0) - inner.append(preset_title) - desc = Gtk.Label(label=preset.description or "No description.") - desc.set_wrap(True) - desc.set_wrap_mode(Pango.WrapMode.WORD_CHAR) - desc.add_css_class("dim-label") - desc.set_xalign(0) - inner.append(desc) - if preset.install_command: - installed = preset.is_installed() - hint = Gtk.Label( - label="✓ Installed" if installed else f"Install: {preset.install_command}", - ) - hint.add_css_class("caption") - hint.set_xalign(0) - if not installed: - hint.add_css_class("dim-label") - inner.append(hint) - frame.set_child(inner) - box.append(frame) - scrolled = Gtk.ScrolledWindow(vexpand=True, min_content_height=320, max_content_height=480, hscrollbar_policy=Gtk.PolicyType.NEVER) - scrolled.set_child(box) - popover.set_child(scrolled) - popover.popup() - - def _toast(self, msg: str): - toast = Adw.Toast.new(msg) - toast.set_timeout(2) - if self._window and hasattr(self._window, "add_toast"): - self._window.add_toast(toast) - - -class EnvVarsView(Gtk.Box): - """Phase 3: Environment variables editor.""" - - def __init__(self, window: Adw.ApplicationWindow | None = None, **kwargs): - super().__init__(orientation=Gtk.Orientation.VERTICAL, **kwargs) - self._window = window - - content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10, margin_top=10, margin_bottom=10, margin_start=10, margin_end=10) - content.set_hexpand(True) - content.add_css_class("env-view-content") - - title = Gtk.Label(label="Global Environment Variables") - title.add_css_class("title-1") - title.set_halign(Gtk.Align.CENTER) - content.append(title) - - hint = Gtk.Label( - label="Stored in ~/.config/environment.d/70-protonshift.conf. Logout and login for changes.", - wrap=True, - ) - hint.add_css_class("env-hint") - hint.set_halign(Gtk.Align.CENTER) - hint.set_wrap_mode(Pango.WrapMode.WORD_CHAR) - hint.set_justify(Gtk.Justification.CENTER) - content.append(hint) - - # Presets: all 3 on same line, centered - preset_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) - preset_box.set_halign(Gtk.Align.CENTER) - for name, vars_dict in ENV_PRESETS.items(): - btn = Gtk.Button(label=name) - btn.add_css_class("pill") - btn.connect("clicked", self._on_preset, vars_dict) - preset_box.append(btn) - content.append(preset_box) - - # List of vars: full width - self.listbox = Gtk.ListBox(selection_mode=Gtk.SelectionMode.SINGLE) - current_label = Gtk.Label(label="Current variables:") - current_label.set_halign(Gtk.Align.CENTER) - content.append(current_label) - self._list_frame = Gtk.ScrolledWindow(vexpand=True, hexpand=True, max_content_height=200) - self._list_frame.set_child(self.listbox) - content.append(self._list_frame) - - # Add new + Delete selected - add_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) - add_box.set_halign(Gtk.Align.CENTER) - self.key_entry = Gtk.Entry(placeholder_text="KEY") - self.val_entry = Gtk.Entry(placeholder_text="value") - add_btn = Gtk.Button(label="Add") - add_btn.connect("clicked", self._on_add) - del_btn = Gtk.Button(label="Delete selected") - del_btn.connect("clicked", self._on_delete_selected) - add_box.append(self.key_entry) - add_box.append(self.val_entry) - add_box.append(add_btn) - add_box.append(del_btn) - content.append(add_box) - - scroll = Gtk.ScrolledWindow(vexpand=True, hscrollbar_policy=Gtk.PolicyType.NEVER) - scroll.set_child(content) - self.append(scroll) - self._refresh_list() - - def _refresh_list(self): - while child := self.listbox.get_first_child(): - self.listbox.remove(child) - for k, v in read_gaming_env().items(): - row = Adw.ActionRow(title=k, subtitle=v) - self.listbox.append(row) - - def _on_preset(self, _btn, vars_dict: dict): - current = read_gaming_env() - current.update(vars_dict) - if write_gaming_env(current): - self._refresh_list() - self._toast("Preset applied") - else: - self._toast("Failed to save") - - def _on_add(self, _btn): - k = self.key_entry.get_text().strip() - v = self.val_entry.get_text().strip() - if not k: - return - current = read_gaming_env() - current[k] = v - if write_gaming_env(current): - self.key_entry.set_text("") - self.val_entry.set_text("") - self._refresh_list() - self._toast("Added. Logout/login to apply.") - - def _on_delete_selected(self, _btn): - row = self.listbox.get_selected_row() - if not row or not isinstance(row.get_child(), Adw.ActionRow): - self._toast("Select a variable to delete") - return - key = row.get_child().get_title() - current = read_gaming_env() - if key in current: - del current[key] - if write_gaming_env(current): - self._refresh_list() - self._toast("Deleted. Logout/login to apply.") - else: - self._toast("Failed to save") - - def _toast(self, msg: str): - if self._window and hasattr(self._window, "add_toast"): - self._window.add_toast(Adw.Toast.new(msg)) - - -class SystemView(Gtk.Box): - """Phase 4: GPU info and power profile.""" - - def __init__(self, window: Adw.ApplicationWindow | None = None, **kwargs): - super().__init__(orientation=Gtk.Orientation.VERTICAL, **kwargs) - self._window = window - - content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=24, margin_top=24, margin_bottom=24, margin_start=24, margin_end=24) - content.add_css_class("system-view-content") - - title = Gtk.Label(label="System") - title.add_css_class("title-1") - title.set_halign(Gtk.Align.CENTER) - content.append(title) - - # GPU card - gpu_frame = Gtk.Frame() - gpu_frame.add_css_class("system-card") - gpu_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8, margin_top=12, margin_bottom=12, margin_start=16, margin_end=16) - gpu_title = Gtk.Label(label="GPU", css_classes=["title-4"]) - gpu_title.set_halign(Gtk.Align.CENTER) - gpu_box.append(gpu_title) - gpus = get_gpu_info() - if gpus: - for gpu in gpus: - desc = f"{gpu.name}" - if gpu.driver: - desc += f" (driver {gpu.driver})" - if gpu.vram_mb: - desc += f" — {gpu.vram_mb} MiB" - lbl = Gtk.Label(label=desc, wrap=True, justify=Gtk.Justification.CENTER) - lbl.set_wrap_mode(Pango.WrapMode.WORD_CHAR) - lbl.set_halign(Gtk.Align.CENTER) - gpu_box.append(lbl) - else: - gpu_box.append(Gtk.Label(label="No GPU info available", css_classes=["dim-label"])) - gpu_frame.set_child(gpu_box) - content.append(gpu_frame) - - # Power profile card - profile_frame = Gtk.Frame() - profile_frame.add_css_class("system-card") - profile_inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16, margin_top=16, margin_bottom=16, margin_start=20, margin_end=20) - profile_label = Gtk.Label(label="Power Profile", css_classes=["title-4"]) - profile_label.set_halign(Gtk.Align.CENTER) - profile_inner.append(profile_label) - current = get_current_power_profile() - profiles = get_power_profiles() - if not profiles: - profiles = ["performance", "balanced", "power-saver"] - profile_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) - profile_box.set_halign(Gtk.Align.CENTER) - for p in profiles: - btn = Gtk.Button(label=p.replace("-", " ").title()) - btn.add_css_class("pill") - if current and p.lower() in current.lower(): - btn.add_css_class("suggested-action") - btn.connect("clicked", self._on_power_profile, p) - profile_box.append(btn) - profile_inner.append(profile_box) - profile_frame.set_child(profile_inner) - content.append(profile_frame) - - # MangoHud config - mangohud_btn = Gtk.Button(label="Open MangoHud config folder") - mangohud_btn.connect("clicked", self._on_open_mangohud_config) - mangohud_btn.set_halign(Gtk.Align.CENTER) - content.append(mangohud_btn) - - center_box = Gtk.CenterBox(orientation=Gtk.Orientation.HORIZONTAL) - center_box.set_hexpand(True) - center_box.set_vexpand(True) - center_box.set_center_widget(content) - - scroll = Gtk.ScrolledWindow(vexpand=True, hscrollbar_policy=Gtk.PolicyType.NEVER) - scroll.set_child(center_box) - self.append(scroll) - - def _on_power_profile(self, _btn, profile: str): - ok, msg = set_power_profile(profile) - if self._window and hasattr(self._window, "add_toast"): - self._window.add_toast(Adw.Toast.new(msg if ok else f"Error: {msg}")) - - def _on_open_mangohud_config(self, _btn): - path = Path.home() / ".config" / "MangoHud" - path.mkdir(parents=True, exist_ok=True) - try: - subprocess.run(["xdg-open", str(path)], check=False) - except FileNotFoundError: - subprocess.run(["gio", "open", str(path)], check=False) - - -class HeroicDetailView(Gtk.Box): - """Detail panel for a Heroic game (Epic/GOG).""" - - def __init__(self, game: HeroicGame, window: Adw.ApplicationWindow | None = None, **kwargs): - super().__init__(orientation=Gtk.Orientation.VERTICAL, **kwargs) - self.game = game - self._window = window - - content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10, margin_top=10, margin_bottom=10, margin_start=10, margin_end=10) - - content.append(Adw.ActionRow(title="Store", subtitle=game.store.upper())) - content.append(Adw.ActionRow(title="App ID", subtitle=game.app_id)) - - if game.prefix_path and game.prefix_path.exists(): - open_btn = Gtk.Button(label="Open Prefix Folder", css_classes=["suggested-action"]) - open_btn.connect("clicked", self._on_open_prefix) - content.append(open_btn) - - hint = Gtk.Label( - label="Launch and configure this game from Heroic Games Launcher.", - wrap=True, xalign=0, margin_top=10, - ) - hint.add_css_class("dim-label") - hint.set_wrap_mode(Pango.WrapMode.WORD_CHAR) - content.append(hint) - - scroll = Gtk.ScrolledWindow(vexpand=True, hscrollbar_policy=Gtk.PolicyType.NEVER) - scroll.set_child(content) - self.append(scroll) - - def _on_open_prefix(self, _btn): - if self.game.prefix_path and self.game.prefix_path.exists(): - path = str(self.game.prefix_path) - try: - subprocess.run(["xdg-open", path], check=False) - except FileNotFoundError: - subprocess.run(["gio", "open", path], check=False) - - -class LutrisDetailView(Gtk.Box): - """Detail panel for a Lutris game.""" - - def __init__(self, game: LutrisGame, window: Adw.ApplicationWindow | None = None, **kwargs): - super().__init__(orientation=Gtk.Orientation.VERTICAL, **kwargs) - self.game = game - self._window = window - - content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10, margin_top=10, margin_bottom=10, margin_start=10, margin_end=10) - - content.append(Adw.ActionRow(title="Source", subtitle="Lutris")) - content.append(Adw.ActionRow(title="Slug", subtitle=game.app_id)) - - if game.install_path and game.install_path.exists(): - row = Adw.ActionRow(title="Install path", subtitle=str(game.install_path)) - open_btn = Gtk.Button(label="Open") - open_btn.connect("clicked", self._on_open_install_path) - row.add_suffix(open_btn) - content.append(row) - - if game.prefix_path and game.prefix_path.exists(): - open_btn = Gtk.Button(label="Open Prefix Folder", css_classes=["suggested-action"]) - open_btn.connect("clicked", self._on_open_prefix) - content.append(open_btn) - - hint = Gtk.Label( - label="Launch and configure this game from Lutris.", - wrap=True, xalign=0, margin_top=10, - ) - hint.add_css_class("dim-label") - hint.set_wrap_mode(Pango.WrapMode.WORD_CHAR) - content.append(hint) - - scroll = Gtk.ScrolledWindow(vexpand=True, hscrollbar_policy=Gtk.PolicyType.NEVER) - scroll.set_child(content) - self.append(scroll) - - def _on_open_install_path(self, _btn): - if self.game.install_path and self.game.install_path.exists(): - path = str(self.game.install_path) - try: - subprocess.run(["xdg-open", path], check=False) - except FileNotFoundError: - subprocess.run(["gio", "open", path], check=False) - - def _on_open_prefix(self, _btn): - if self.game.prefix_path and self.game.prefix_path.exists(): - path = str(self.game.prefix_path) - try: - subprocess.run(["xdg-open", path], check=False) - except FileNotFoundError: - subprocess.run(["gio", "open", path], check=False) - - -class GameRow(Gtk.ListBoxRow): - """Row for game list.""" - - def __init__(self, game: SteamGame | HeroicGame | LutrisGame, **kwargs): - super().__init__(**kwargs) - self.game = game - if isinstance(game, HeroicGame): - subtitle = f"{game.store.upper()} · {game.app_id}" - elif isinstance(game, LutrisGame): - subtitle = f"Lutris · {game.app_id}" - else: - subtitle = f"Steam · App ID: {game.app_id}" - row = Adw.ActionRow(title=game.name, subtitle=subtitle) - row.set_activatable(True) - self.set_child(row) - - -class GameSetupHubWindow(Adw.ApplicationWindow): - """Main application window.""" - - def __init__(self, app: Adw.Application, **kwargs): - super().__init__(application=app, **kwargs) - self.set_default_size(700, 500) - self.set_title("ProtonShift") - self.steam_root: Path | None = None - self.config_path: Path | None = None - self.games: list[SteamGame | HeroicGame | LutrisGame] = [] - self.toast_overlay = Adw.ToastOverlay() - - self._build_ui() - - def _build_ui(self): - self.toast_overlay.set_child(self._build_main()) - self.set_content(self.toast_overlay) - - def _build_main(self) -> Gtk.Widget: - self.top_stack = Gtk.Stack(vexpand=True) - - # Loading - loading = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER) - sp = Gtk.Spinner(spinning=True) - sp.set_size_request(48, 48) - loading.append(sp) - load_label = Gtk.Label(label="Scanning libraries…") - load_label.add_css_class("title-4") - loading.append(load_label) - self.top_stack.add_named(loading, "loading") - - self.view_stack = Adw.ViewStack(vexpand=True) - - # Games view - self.main_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) - self.main_box.set_hexpand(True) - self.main_box.set_vexpand(True) - self.main_box.set_margin_top(10) - self.main_box.set_margin_bottom(10) - self.main_box.set_margin_start(10) - self.main_box.set_margin_end(10) - - list_panel = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - list_panel.set_size_request(280, -1) - list_frame = Gtk.Frame() - list_frame.add_css_class("game-list-frame") - list_inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) - search = Gtk.SearchEntry(placeholder_text="Search games…") - search.add_css_class("game-list-search") - search.connect("search-changed", self._on_search_changed) - list_inner.append(search) - list_scroll = Gtk.ScrolledWindow(vexpand=True, hscrollbar_policy=Gtk.PolicyType.NEVER) - self.listbox = Gtk.ListBox(selection_mode=Gtk.SelectionMode.SINGLE, vexpand=True) - self.listbox.connect("row-activated", self._on_game_selected) - list_scroll.set_child(self.listbox) - list_inner.append(list_scroll) - list_frame.set_child(list_inner) - list_panel.append(list_frame) - - self.main_box.append(list_panel) - - self.detail_stack = Gtk.Stack() - placeholder = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER) - placeholder.set_hexpand(True) - placeholder.set_vexpand(True) - ph_label = Gtk.Label(label="Select a game") - ph_label.add_css_class("placeholder-title") - placeholder.append(ph_label) - self.detail_stack.add_named(placeholder, "placeholder") - self.main_box.append(self.detail_stack) - - self.view_stack.add_titled_with_icon( - self.main_box, "games", "Games", "applications-games-symbolic" - ) - - # Environment view (Phase 3) - env_view = EnvVarsView(window=self) - self.view_stack.add_titled_with_icon( - env_view, "env", "Environment", "applications-science-symbolic" - ) - - # System view (Phase 4) - sys_view = SystemView(window=self) - self.view_stack.add_titled_with_icon( - sys_view, "system", "System", "computer-symbolic" - ) - - # Toolbar with view switcher and menu - toolbar = Adw.ToolbarView() - header = Adw.HeaderBar() - switcher = Adw.ViewSwitcher(stack=self.view_stack, policy=Adw.ViewSwitcherPolicy.WIDE) - header.set_title_widget(switcher) - menu_btn = Gtk.MenuButton(icon_name="open-menu-symbolic") - menu_btn.set_tooltip_text("Menu") - menu = Gio.Menu.new() - menu.append("About", "app.about") - menu.append("Keyboard Shortcuts", "win.shortcuts") - popover = Gtk.PopoverMenu(menu_model=menu) - menu_btn.set_popover(popover) - header.pack_end(menu_btn) - toolbar.add_top_bar(header) - toolbar.set_content(self.view_stack) - - self.top_stack.add_named(toolbar, "main") - - GLib.idle_add(self._load_games) - return self.top_stack - - def _load_games(self): - steam_root, steam_games = discover_games() - self.steam_root = steam_root - if steam_root: - self.config_path = get_localconfig_path(steam_root) - heroic_games = discover_heroic_games() - lutris_games = discover_lutris_games() - # Steam by last_played; Heroic and Lutris by name - heroic_sorted = sorted(heroic_games, key=lambda g: g.name.lower()) - lutris_sorted = sorted(lutris_games, key=lambda g: g.name.lower()) - self.games = list(steam_games) + heroic_sorted + lutris_sorted - self._populate_list(self.games) - self.top_stack.set_visible_child_name("main") - - def _populate_list(self, games: list[SteamGame | HeroicGame | LutrisGame]): - # Clear - while child := self.listbox.get_first_child(): - self.listbox.remove(child) - for game in games: - self.listbox.append(GameRow(game)) - - def _on_search_changed(self, entry: Gtk.SearchEntry): - query = entry.get_text().strip().lower() - if not query: - self._populate_list(self.games) - else: - filtered = [g for g in self.games if query in g.name.lower() or query in g.app_id] - self._populate_list(filtered) - - def _on_game_selected(self, _listbox: Gtk.ListBox, row: Gtk.ListBoxRow): - if not isinstance(row, GameRow): - return - game = row.game - if isinstance(game, HeroicGame): - detail = HeroicDetailView(game, window=self) - key = f"heroic-{game.app_id}" - elif isinstance(game, LutrisGame): - detail = LutrisDetailView(game, window=self) - key = f"lutris-{game.app_id}" - else: - detail = GameDetailView( - game, self.config_path, window=self, steam_root=self.steam_root - ) - key = f"steam-{game.app_id}" - if old := self.detail_stack.get_child_by_name(key): - self.detail_stack.remove(old) - self.detail_stack.add_named(detail, key) - self.detail_stack.set_visible_child_name(key) - - def add_toast(self, toast: Adw.Toast): - self.toast_overlay.add_toast(toast) - - def add_shortcuts(self): - shortcut_action = Gio.SimpleAction.new("shortcuts", None) - shortcut_action.connect("activate", self._on_shortcuts) - self.add_action(shortcut_action) - self.get_application().set_accels_for_action("win.shortcuts", ["question"]) - self.get_application().set_accels_for_action("app.quit", ["q"]) - - def _on_shortcuts(self, _action, _param): - try: - sw = Adw.ShortcutsDialog(transient_for=self) - section = Adw.ShortcutsSection(title="General") - section.add(Adw.ShortcutsItem(title="Keyboard Shortcuts", accelerator="question")) - section.add(Adw.ShortcutsItem(title="Quit", accelerator="q")) - sw.add(section) - sw.present() - except (AttributeError, TypeError): - dialog = Adw.MessageDialog( - transient_for=self, - heading="Keyboard Shortcuts", - body="Ctrl+? — Keyboard Shortcuts\nCtrl+Q — Quit", - ) - dialog.add_response("ok", "OK") - dialog.connect("response", lambda d, _: d.destroy()) - dialog.present() - - -def _load_theme(): - """Load futuristic theme CSS.""" - css_path = Path(__file__).resolve().parent / "theme.css" - if not css_path.exists(): - return - try: - provider = Gtk.CssProvider() - provider.load_from_path(str(css_path)) - display = Gdk.Display.get_default() - if display: - Gtk.StyleContext.add_provider_for_display( - display, provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION - ) - except Exception: - pass - - -def main(): - app = Adw.Application(application_id="io.github.protonshift", flags=Gio.ApplicationFlags.FLAGS_NONE) - - def on_about(_action, _param): - win = app.get_active_window() - about = Adw.AboutWindow( - transient_for=win, - application_name="ProtonShift", - version="0.8.8", - developer_name="ProtonShift", - website="https://github.com/protonshift/protonshift", - application_icon="io.github.protonshift", - ) - about.present() - - def on_activate(a): - _load_theme() - Adw.StyleManager.get_default().set_color_scheme(Adw.ColorScheme.FORCE_DARK) - win = GameSetupHubWindow(a) - win.add_shortcuts() - win.present() - - about_action = Gio.SimpleAction.new("about", None) - about_action.connect("activate", on_about) - app.add_action(about_action) - app.connect("activate", on_activate) - return app.run(sys.argv) diff --git a/src/game_setup_hub/controllers.py b/src/game_setup_hub/controllers.py index 3910535..3de3a28 100644 --- a/src/game_setup_hub/controllers.py +++ b/src/game_setup_hub/controllers.py @@ -3,6 +3,7 @@ from __future__ import annotations import re +import struct from dataclasses import dataclass from pathlib import Path @@ -12,9 +13,11 @@ class ControllerInfo: id: str name: str device_path: str - controller_type: str # "xbox", "playstation", "nintendo", "generic" + controller_type: str # "xbox", "playstation", "nintendo", "8bitdo", "steam", "generic" vendor_id: str = "" product_id: str = "" + bus_type: str = "" + version: str = "" def _classify_controller(name: str) -> str: @@ -52,8 +55,10 @@ def get_controllers() -> list[ControllerInfo]: name = "" handlers = "" + bus = "" vendor = "" product = "" + version = "" is_joystick = False for line in block.splitlines(): @@ -64,12 +69,14 @@ def get_controllers() -> list[ControllerInfo]: if "js" in handlers: is_joystick = True elif line.startswith("I:"): - v_match = re.search(r"Vendor=([0-9a-fA-F]+)", line) - p_match = re.search(r"Product=([0-9a-fA-F]+)", line) - if v_match: - vendor = v_match.group(1) - if p_match: - product = p_match.group(1) + if (m := re.search(r"Bus=([0-9a-fA-F]+)", line)): + bus = m.group(1) + if (m := re.search(r"Vendor=([0-9a-fA-F]+)", line)): + vendor = m.group(1) + if (m := re.search(r"Product=([0-9a-fA-F]+)", line)): + product = m.group(1) + if (m := re.search(r"Version=([0-9a-fA-F]+)", line)): + version = m.group(1) if not is_joystick or not name: continue @@ -85,13 +92,26 @@ def get_controllers() -> list[ControllerInfo]: controller_type=_classify_controller(name), vendor_id=vendor, product_id=product, + bus_type=bus, + version=version, )) return controllers +def _read_sysfs_id(js_name: str, key: str) -> str: + """Read a hex id from /sys/class/input//device/id/.""" + p = Path(f"/sys/class/input/{js_name}/device/id/{key}") + if not p.exists(): + return "" + try: + return p.read_text(encoding="utf-8").strip() + except OSError: + return "" + + def _get_controllers_from_js() -> list[ControllerInfo]: - """Fallback: detect from /dev/input/js* devices.""" + """Fallback: detect from /dev/input/js* devices via sysfs.""" controllers: list[ControllerInfo] = [] input_dir = Path("/dev/input") if not input_dir.exists(): @@ -111,25 +131,62 @@ def _get_controllers_from_js() -> list[ControllerInfo]: name=name, device_path=str(js_path), controller_type=_classify_controller(name), + vendor_id=_read_sysfs_id(js_path.name, "vendor"), + product_id=_read_sysfs_id(js_path.name, "product"), + bus_type=_read_sysfs_id(js_path.name, "bustype"), + version=_read_sysfs_id(js_path.name, "version"), )) return controllers -def get_sdl_mapping(controller: ControllerInfo) -> str: +def _build_sdl_guid(bus: str, vendor: str, product: str, version: str) -> str: + """Build a 32-char SDL2 joystick GUID for a Linux device. + + Format (little-endian, 16 bytes): bus(2) | crc(2)=0 | vendor(2) | 0000 | + product(2) | 0000 | version(2) | 0000 + + Returns 32 lowercase hex chars. Empty/invalid inputs default to ``0``. """ - Generate an SDL_GAMECONTROLLERCONFIG entry for a controller. - This is a basic GUID-based stub; full calibration requires user input. + def _hex16(value: str) -> int: + try: + return int(value, 16) & 0xFFFF + except ValueError: + return 0 + + blob = struct.pack( + " str: + """Generate an SDL_GAMECONTROLLERCONFIG entry for a controller. + + Uses the canonical 16-byte little-endian SDL2 GUID layout so the entry + actually matches the device when SDL parses it. The button mapping body + is a generic Xbox-style starting point; users should re-calibrate via a + tool like ``sdl2-jstest`` for accuracy. """ - guid = f"{controller.vendor_id:>04s}{controller.product_id:>04s}".replace(" ", "0") - if len(guid) < 8: - guid = guid.ljust(32, "0") - else: - guid = guid.ljust(32, "0") - - # Standard Xbox-style mapping as a starting point - mapping = ( - f"{guid},{controller.name}," + guid = _build_sdl_guid( + controller.bus_type or "0003", # USB if unknown + controller.vendor_id, + controller.product_id, + controller.version, + ) + + name = controller.name.replace(",", " ") # SDL mapping fields are comma-delimited + + return ( + f"{guid},{name}," "a:b0,b:b1,x:b2,y:b3," "back:b6,start:b7,guide:b8," "leftshoulder:b4,rightshoulder:b5," @@ -139,4 +196,3 @@ def get_sdl_mapping(controller: ControllerInfo) -> str: "lefttrigger:a2,righttrigger:a5," "platform:Linux," ) - return mapping diff --git a/src/game_setup_hub/display.py b/src/game_setup_hub/display.py index 3901a5f..6121c23 100644 --- a/src/game_setup_hub/display.py +++ b/src/game_setup_hub/display.py @@ -159,14 +159,34 @@ def get_monitors() -> list[MonitorInfo]: return monitors +# Output names emitted by xrandr/wlr-randr are short identifiers like +# ``HDMI-1`` or ``DP-2``. Anything outside this shape is rejected before +# ever reaching the xrandr command line so an attacker who can call the +# /display/resolution endpoint cannot inject extra args. +_MONITOR_NAME_RE = re.compile(r"^[A-Za-z0-9._\-]{1,32}$") +_MAX_DIMENSION = 16384 # 16k — comfortably above any real display + + def set_resolution(monitor: str, width: int, height: int, refresh: float = 0) -> bool: """Set resolution for a monitor using xrandr. Returns True on success.""" + if not _MONITOR_NAME_RE.match(monitor): + return False + # Whitelist against actually-detected monitors. Even if the regex is wrong + # this stops xrandr being driven against a name that isn't on the system. + known_names = {m.name for m in get_monitors()} + if known_names and monitor not in known_names: + return False + if not (0 < width <= _MAX_DIMENSION and 0 < height <= _MAX_DIMENSION): + return False + if not (0 <= refresh <= 1000): + return False + xrandr = find_tool("xrandr") if not xrandr: return False cmd = [xrandr, "--output", monitor, "--mode", f"{width}x{height}"] if refresh > 0: - cmd.extend(["--rate", str(refresh)]) + cmd.extend(["--rate", f"{refresh:g}"]) try: result = subprocess.run(cmd, capture_output=True, timeout=5) return result.returncode == 0 diff --git a/src/game_setup_hub/env_vars.py b/src/game_setup_hub/env_vars.py index bafd922..9aaae31 100644 --- a/src/game_setup_hub/env_vars.py +++ b/src/game_setup_hub/env_vars.py @@ -5,6 +5,8 @@ import re from pathlib import Path +from .fsutil import atomic_write_text + ENV_D_DIR = Path.home() / ".config" / "environment.d" GAMING_CONF = "70-protonshift.conf" @@ -47,9 +49,8 @@ def read_conf(path: Path) -> dict[str, str]: def write_conf(path: Path, vars_dict: dict[str, str], header: str = "") -> bool: - """Write KEY=value pairs to a .conf file.""" - path.parent.mkdir(parents=True, exist_ok=True) - lines = [] + """Write KEY=value pairs to a .conf file atomically.""" + lines: list[str] = [] if header: for h in header.strip().split("\n"): lines.append(f"# {h}" if not h.startswith("#") else h) @@ -60,8 +61,7 @@ def write_conf(path: Path, vars_dict: dict[str, str], header: str = "") -> bool: escaped = str(v).replace("\\", "\\\\").replace('"', '\\"') lines.append(f'{k}="{escaped}"') try: - with open(path, "w", encoding="utf-8", newline="\n") as f: - f.write("\n".join(lines) + "\n") + atomic_write_text(path, "\n".join(lines) + "\n") except OSError: return False return True diff --git a/src/game_setup_hub/fixes.py b/src/game_setup_hub/fixes.py index 74d7e18..ab10ba9 100644 --- a/src/game_setup_hub/fixes.py +++ b/src/game_setup_hub/fixes.py @@ -7,10 +7,17 @@ from pathlib import Path from typing import Any +from .fsutil import atomic_write_text +from .paths import sanitize_filename + _DATA_DIR = Path(__file__).parent / "data" _USER_FIXES_DIR = Path.home() / ".config" / "protonshift" / "fixes" +def _user_fixes_path(app_id: str) -> Path: + return _USER_FIXES_DIR / f"{sanitize_filename(app_id, fallback='unknown')}.json" + + @dataclass class GameFix: title: str @@ -32,7 +39,7 @@ def _load_builtin_fixes() -> dict[str, list[dict[str, Any]]]: def _load_user_fixes(app_id: str) -> list[dict[str, Any]]: - path = _USER_FIXES_DIR / f"{app_id}.json" + path = _user_fixes_path(app_id) if not path.exists(): return [] try: @@ -80,7 +87,7 @@ def add_user_fix( ) -> bool: """Add a user-contributed fix for a game. Returns True on success.""" _USER_FIXES_DIR.mkdir(parents=True, exist_ok=True) - path = _USER_FIXES_DIR / f"{app_id}.json" + path = _user_fixes_path(app_id) existing = _load_user_fixes(app_id) existing.append({ @@ -92,7 +99,7 @@ def add_user_fix( }) try: - path.write_text(json.dumps(existing, indent=2), encoding="utf-8") + atomic_write_text(path, json.dumps(existing, indent=2)) return True except OSError: return False diff --git a/src/game_setup_hub/fsutil.py b/src/game_setup_hub/fsutil.py new file mode 100644 index 0000000..0db9f45 --- /dev/null +++ b/src/game_setup_hub/fsutil.py @@ -0,0 +1,104 @@ +"""Shared filesystem helpers — directory sizing, human-readable sizes, atomic writes.""" + +from __future__ import annotations + +import os +import tempfile +from pathlib import Path + + +def dir_size(path: Path, *, follow_symlinks: bool = False) -> int: + """Recursively compute directory size in bytes. + + Symlinks are skipped by default to avoid infinite loops and double-counting. + Errors on individual files are silently ignored — a partial size is more + useful than an exception in UI-facing code. + """ + total = 0 + try: + for entry in path.rglob("*"): + if not follow_symlinks and entry.is_symlink(): + continue + if entry.is_file(): + try: + total += entry.stat().st_size if follow_symlinks else entry.lstat().st_size + except OSError: + continue + except OSError: + return total + return total + + +def human_size(size_bytes: float) -> str: + """Format a byte count as a 1-decimal-place human-readable string.""" + size = float(size_bytes) + for unit in ("B", "KB", "MB", "GB", "TB"): + if abs(size) < 1024: + return f"{size:.1f} {unit}" + size /= 1024 + return f"{size:.1f} PB" + + +def atomic_write_text( + path: Path, + data: str, + *, + encoding: str = "utf-8", + newline: str = "\n", +) -> None: + """Atomically replace ``path`` with ``data`` via a tempfile in the same dir. + + Crash-safety: writes to ``..tmp`` in the same directory, fsyncs, + then ``os.replace``\\s onto the target. The same-directory requirement is + what makes ``rename`` atomic on POSIX. Parent directories are created. + """ + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp( + prefix=f".{path.name}.", + suffix=".tmp", + dir=str(path.parent), + ) + tmp_path = Path(tmp_name) + try: + with os.fdopen(fd, "w", encoding=encoding, newline=newline) as f: + f.write(data) + f.flush() + try: + os.fsync(f.fileno()) + except OSError: + # Some filesystems (tmpfs, network mounts) don't support fsync; + # the write is still durable enough for our config files. + pass + os.replace(tmp_path, path) + except Exception: + try: + tmp_path.unlink(missing_ok=True) + except OSError: + pass + raise + + +def atomic_write_bytes(path: Path, data: bytes) -> None: + """Atomic binary equivalent of :func:`atomic_write_text`.""" + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp( + prefix=f".{path.name}.", + suffix=".tmp", + dir=str(path.parent), + ) + tmp_path = Path(tmp_name) + try: + with os.fdopen(fd, "wb") as f: + f.write(data) + f.flush() + try: + os.fsync(f.fileno()) + except OSError: + pass + os.replace(tmp_path, path) + except Exception: + try: + tmp_path.unlink(missing_ok=True) + except OSError: + pass + raise diff --git a/src/game_setup_hub/gamescope.py b/src/game_setup_hub/gamescope.py index bcf31b8..dbf32c1 100644 --- a/src/game_setup_hub/gamescope.py +++ b/src/game_setup_hub/gamescope.py @@ -2,6 +2,7 @@ from __future__ import annotations +import shlex from dataclasses import dataclass from game_setup_hub.tool_check import is_tool_available @@ -29,9 +30,14 @@ def is_gamescope_available() -> bool: return is_tool_available("gamescope") -def build_gamescope_cmd(opts: GamescopeOptions) -> str: - """Build a gamescope command string from options.""" - parts = ["gamescope"] +def build_gamescope_argv(opts: GamescopeOptions) -> list[str]: + """Build a gamescope argv list from options. + + `extra_args` is parsed via :func:`shlex.split` so quoted segments and + escapes are preserved correctly. The trailing ``--`` separator is always + appended so the caller can append the wrapped command directly. + """ + parts: list[str] = ["gamescope"] if opts.output_width > 0 and opts.output_height > 0: parts.extend(["-w", str(opts.output_width), "-h", str(opts.output_height)]) @@ -43,8 +49,7 @@ def build_gamescope_cmd(opts: GamescopeOptions) -> str: parts.extend(["-r", str(opts.fps_limit)]) if opts.fsr: - parts.append("--fsr-sharpness") - parts.append(str(max(0, min(20, opts.fsr_sharpness)))) + parts.extend(["--fsr-sharpness", str(max(0, min(20, opts.fsr_sharpness)))]) if opts.integer_scale: parts.append("--integer-scale") @@ -58,9 +63,22 @@ def build_gamescope_cmd(opts: GamescopeOptions) -> str: if opts.borderless: parts.append("-b") - if opts.extra_args: - parts.append(opts.extra_args.strip()) + if opts.extra_args.strip(): + try: + parts.extend(shlex.split(opts.extra_args)) + except ValueError: + # unbalanced quotes — fall back to whitespace split so the user + # at least sees something instead of silent loss + parts.extend(opts.extra_args.split()) parts.append("--") + return parts + + +def build_gamescope_cmd(opts: GamescopeOptions) -> str: + """Build a shell-safe gamescope command string from options. - return " ".join(parts) + Returned value is suitable for copy/paste into a launch options field. + Use :func:`build_gamescope_argv` when actually exec-ing the command. + """ + return shlex.join(build_gamescope_argv(opts)) diff --git a/src/game_setup_hub/gpu.py b/src/game_setup_hub/gpu.py index 283b908..a25c09c 100644 --- a/src/game_setup_hub/gpu.py +++ b/src/game_setup_hub/gpu.py @@ -108,18 +108,20 @@ def get_power_profiles() -> list[str]: if r is not None and r.returncode == 0 and r.stdout.strip(): return ["battery", "balanced", "performance"] - # power-profiles-daemon (Ubuntu) + # power-profiles-daemon (Ubuntu/Fedora). `powerprofilesctl list` prints + # one block per profile; the active one is prefixed with `*`. We want + # every profile name, not just the active one. Each block starts with + # `[* ]:` at column 0. try: r = subprocess.run(["powerprofilesctl", "list"], capture_output=True, text=True, timeout=3) if r.returncode == 0: - profiles = [] - for line in r.stdout.split("\n"): - if "*" in line: - m = re.search(r"[\*]\s*([\w-]+)", line) - if m: - profiles.append(m.group(1)) + profiles = [ + m.group(1) + for line in r.stdout.splitlines() + if (m := re.match(r"^\s*\*?\s*([\w-]+):\s*$", line)) + ] if profiles: - return ["performance", "balanced", "power-saver"] + return profiles except FileNotFoundError: pass diff --git a/src/game_setup_hub/heroic.py b/src/game_setup_hub/heroic.py index 483ec91..4ad70ce 100644 --- a/src/game_setup_hub/heroic.py +++ b/src/game_setup_hub/heroic.py @@ -6,7 +6,6 @@ from dataclasses import dataclass from pathlib import Path - HEROIC_ROOTS = [ Path.home() / ".config" / "heroic", Path.home() / ".var" / "app" / "com.heroicgameslauncher.hgl" / "config" / "heroic", @@ -27,13 +26,18 @@ def compatdata_path(self) -> Path | None: return self.prefix_path -def _resolve_heroic_root() -> Path | None: +def resolve_heroic_root() -> Path | None: + """Return the first existing Heroic config root (native or Flatpak).""" for root in HEROIC_ROOTS: if root.exists(): return root return None +# Backwards compatibility alias for older imports. +_resolve_heroic_root = resolve_heroic_root + + def _discover_epic_games(heroic_root: Path) -> list[HeroicGame]: """Discover Epic (Legendary) installed games.""" installed = heroic_root / "legendaryConfig" / "legendary" / "installed.json" @@ -171,7 +175,7 @@ def _discover_gog_games(heroic_root: Path) -> list[HeroicGame]: def discover_heroic_games() -> list[HeroicGame]: """Discover all Heroic installed games (Epic + GOG).""" - root = _resolve_heroic_root() + root = resolve_heroic_root() if not root: return [] games = _discover_epic_games(root) + _discover_gog_games(root) diff --git a/src/game_setup_hub/heroic_config.py b/src/game_setup_hub/heroic_config.py index cea17f9..4c77552 100644 --- a/src/game_setup_hub/heroic_config.py +++ b/src/game_setup_hub/heroic_config.py @@ -7,7 +7,9 @@ from pathlib import Path from typing import Any -from .heroic import _resolve_heroic_root +from .fsutil import atomic_write_text +from .heroic import resolve_heroic_root +from .paths import sanitize_filename @dataclass @@ -37,17 +39,21 @@ class HeroicGameConfig: def _get_games_config_dir() -> Path | None: - root = _resolve_heroic_root() + root = resolve_heroic_root() if not root: return None return root / "GamesConfig" +def _config_file_for(app_id: str, cfg_dir: Path) -> Path: + return cfg_dir / f"{sanitize_filename(app_id, fallback='unknown')}.json" + + def _read_config_file(app_id: str) -> dict[str, Any] | None: cfg_dir = _get_games_config_dir() if not cfg_dir or not cfg_dir.exists(): return None - cfg_file = cfg_dir / f"{app_id}.json" + cfg_file = _config_file_for(app_id, cfg_dir) if not cfg_file.exists(): return None try: @@ -67,7 +73,7 @@ def _write_config_file(app_id: str, config: dict[str, Any]) -> bool: if not cfg_dir: return False cfg_dir.mkdir(parents=True, exist_ok=True) - cfg_file = cfg_dir / f"{app_id}.json" + cfg_file = _config_file_for(app_id, cfg_dir) existing: dict[str, Any] = {} if cfg_file.exists(): @@ -83,8 +89,7 @@ def _write_config_file(app_id: str, config: dict[str, Any]) -> bool: existing[app_id] = config try: - with open(cfg_file, "w", encoding="utf-8") as f: - json.dump(existing, f, indent=2) + atomic_write_text(cfg_file, json.dumps(existing, indent=2)) return True except OSError: return False @@ -169,7 +174,7 @@ class HeroicWineVersionInfo: def list_heroic_wine_versions() -> list[HeroicWineVersionInfo]: - root = _resolve_heroic_root() + root = resolve_heroic_root() if not root: return [] diff --git a/src/game_setup_hub/logging_setup.py b/src/game_setup_hub/logging_setup.py new file mode 100644 index 0000000..3a53194 --- /dev/null +++ b/src/game_setup_hub/logging_setup.py @@ -0,0 +1,40 @@ +"""Logging configuration for the ProtonShift backend. + +Centralised so every entry point (api server, tests, ad-hoc scripts) gets +the same format. Honors ``PROTONSHIFT_LOG_LEVEL`` (default ``INFO``). +""" + +from __future__ import annotations + +import logging +import os +import sys + + +def configure(level: str | None = None) -> None: + """Configure root logging once. Idempotent.""" + chosen = (level or os.environ.get("PROTONSHIFT_LOG_LEVEL") or "INFO").upper() + numeric = getattr(logging, chosen, logging.INFO) + + root = logging.getLogger() + if getattr(configure, "_done", False): + root.setLevel(numeric) + return + + handler = logging.StreamHandler(stream=sys.stderr) + handler.setFormatter( + logging.Formatter("%(asctime)s %(levelname)-7s %(name)s: %(message)s") + ) + root.addHandler(handler) + root.setLevel(numeric) + + # Tame noisy 3rd-party loggers. + for noisy in ("uvicorn.access", "uvicorn.error"): + logging.getLogger(noisy).setLevel(logging.WARNING) + + configure._done = True # type: ignore[attr-defined] + + +def get_logger(name: str) -> logging.Logger: + """Module-level helper so callers don't need to import :mod:`logging`.""" + return logging.getLogger(name) diff --git a/src/game_setup_hub/lutris.py b/src/game_setup_hub/lutris.py index 66d901b..c1bd01f 100644 --- a/src/game_setup_hub/lutris.py +++ b/src/game_setup_hub/lutris.py @@ -7,7 +7,6 @@ from dataclasses import dataclass from pathlib import Path - LUTRIS_ROOTS = [ Path.home() / ".local" / "share" / "lutris", Path.home() / ".var" / "app" / "net.lutris.Lutris" / "data" / "lutris", diff --git a/src/game_setup_hub/mangohud.py b/src/game_setup_hub/mangohud.py index df411bd..5bb8673 100644 --- a/src/game_setup_hub/mangohud.py +++ b/src/game_setup_hub/mangohud.py @@ -4,6 +4,8 @@ from pathlib import Path +from game_setup_hub.fsutil import atomic_write_text +from game_setup_hub.paths import sanitize_filename from game_setup_hub.tool_check import is_tool_available MANGOHUD_CONFIG_DIR = Path.home() / ".config" / "MangoHud" @@ -195,10 +197,9 @@ def read_mangohud_config(path: Path | None = None) -> dict[str, str]: def write_mangohud_config(config: dict[str, str], path: Path | None = None) -> bool: - """Write a MangoHud config file from key-value pairs.""" + """Write a MangoHud config file from key-value pairs (atomic).""" if path is None: path = MANGOHUD_GLOBAL_CONF - path.parent.mkdir(parents=True, exist_ok=True) lines: list[str] = [] for key, value in config.items(): if value: @@ -206,15 +207,19 @@ def write_mangohud_config(config: dict[str, str], path: Path | None = None) -> b else: lines.append(key) try: - path.write_text("\n".join(lines) + "\n", encoding="utf-8") + atomic_write_text(path, "\n".join(lines) + "\n") return True except OSError: return False def get_per_game_config_path(game_name: str) -> Path: - """Get the per-game config path for MangoHud.""" - safe_name = game_name.replace(" ", "_").replace("/", "_").replace("\\", "_") + """Get the per-game config path for MangoHud. + + The supplied ``game_name`` is sanitised — ``..`` and slashes are stripped + so callers cannot escape :data:`MANGOHUD_CONFIG_DIR`. + """ + safe_name = sanitize_filename(game_name.replace(" ", "_"), fallback="game") return MANGOHUD_CONFIG_DIR / f"wine-{safe_name}.conf" diff --git a/src/game_setup_hub/paths.py b/src/game_setup_hub/paths.py new file mode 100644 index 0000000..aa7a0c1 --- /dev/null +++ b/src/game_setup_hub/paths.py @@ -0,0 +1,109 @@ +"""Path validation helpers — keep API inputs from escaping their sandbox. + +Centralised so :mod:`api` (and any future caller) sanitizes the same way and +``..``/absolute/symlink-escape attacks fail at one boundary instead of being +re-implemented per endpoint. +""" + +from __future__ import annotations + +import re +from pathlib import Path + + +class PathValidationError(ValueError): + """Raised when an untrusted path or filename is rejected.""" + + +_FILENAME_BLOCKED = re.compile(r"[^A-Za-z0-9._\- ]") +_LEADING_DOTS = re.compile(r"^\.+") + + +def sanitize_filename(name: str, *, fallback: str = "untitled") -> str: + """Reduce ``name`` to a safe single-segment filename. + + - Strips path separators and any non-alphanumeric/whitespace/.-_ chars. + - Collapses leading ``.`` so we never silently create dotfiles. + - Trims to 200 chars to stay under common filesystem limits. + - Returns ``fallback`` if the result is empty. + """ + if not name: + return fallback + cleaned = _FILENAME_BLOCKED.sub("_", name) + cleaned = _LEADING_DOTS.sub("", cleaned).strip().rstrip(".") + cleaned = cleaned[:200] + return cleaned or fallback + + +def safe_join(base: Path, *parts: str) -> Path: + """Join ``parts`` onto ``base`` and verify the result stays under ``base``. + + ``base`` must already exist (or at least its parent must); the joined + components may not yet exist. Symlinks are not followed by ``resolve`` + when targets are missing, which is fine — we only need the lexical + parent check to reject ``..`` escape and absolute overrides. + + Raises :class:`PathValidationError` on escape, absolute overrides, or + null-byte injection. + """ + base_resolved = base.resolve(strict=False) + for part in parts: + if "\x00" in part: + raise PathValidationError("Null byte in path component") + if Path(part).is_absolute(): + raise PathValidationError(f"Absolute path component not allowed: {part!r}") + + candidate = (base_resolved.joinpath(*parts)).resolve(strict=False) + try: + candidate.relative_to(base_resolved) + except ValueError as exc: + raise PathValidationError( + f"Path {candidate} escapes base {base_resolved}" + ) from exc + return candidate + + +def validate_within(base: Path, candidate: Path) -> Path: + """Verify ``candidate`` resolves under ``base``. Returns the resolved path.""" + base_resolved = base.resolve(strict=False) + candidate_resolved = candidate.resolve(strict=False) + try: + candidate_resolved.relative_to(base_resolved) + except ValueError as exc: + raise PathValidationError( + f"Path {candidate_resolved} is not under {base_resolved}" + ) from exc + return candidate_resolved + + +# Roots a localhost API caller may interact with. Anything outside these is +# rejected — keeps a malicious or buggy client from poking at /etc, /usr, etc. +_USER_PATH_ROOTS: tuple[Path, ...] = ( + Path.home(), + Path("/run/media"), + Path("/media"), + Path("/mnt"), + Path("/tmp"), # noqa: S108 — explicit allow, the API never *creates* in /tmp +) + + +def validate_user_path(path: str | Path, *, allow_missing: bool = False) -> Path: + """Return a resolved :class:`Path` if it lives under a user-writable root. + + Used at API boundaries. Rejects null bytes, ``..`` escape, and anything + outside :data:`_USER_PATH_ROOTS`. Set ``allow_missing=False`` (default) to + require the target to exist — typical for "open this folder" actions. + """ + raw = str(path) + if "\x00" in raw: + raise PathValidationError("Null byte in path") + p = Path(raw).expanduser().resolve(strict=False) + if not allow_missing and not p.exists(): + raise PathValidationError(f"Path does not exist: {p}") + for root in _USER_PATH_ROOTS: + try: + p.relative_to(root.resolve(strict=False)) + return p + except ValueError: + continue + raise PathValidationError(f"Path {p} is outside permitted roots") diff --git a/src/game_setup_hub/prefix.py b/src/game_setup_hub/prefix.py index 591dce6..fdcd935 100644 --- a/src/game_setup_hub/prefix.py +++ b/src/game_setup_hub/prefix.py @@ -5,8 +5,12 @@ import shutil import struct from dataclasses import dataclass +from datetime import UTC from pathlib import Path +from .fsutil import dir_size as _dir_size +from .paths import PathValidationError, validate_user_path + @dataclass class PrefixInfo: @@ -18,21 +22,6 @@ class PrefixInfo: vkd3d_version: str = "" -def _dir_size(path: Path) -> int: - """Recursively compute directory size in bytes.""" - total = 0 - try: - for entry in path.rglob("*"): - if not entry.is_symlink() and entry.is_file(): - try: - total += entry.lstat().st_size - except OSError: - pass - except OSError: - pass - return total - - def _read_pe_file_version(dll_path: Path) -> str: """ Read the VS_FIXEDFILEINFO version from a PE file's resource section. @@ -92,8 +81,18 @@ def _detect_vkd3d_version(prefix: Path) -> str: def get_prefix_info(path: str) -> PrefixInfo: - """Get information about a Wine/Proton prefix directory.""" - prefix = Path(path) + """Get information about a Wine/Proton prefix directory. + + The path is re-validated through :func:`validate_user_path` even though + callers (the API layer) are expected to validate first. This makes the + function safe to call directly from tests / scripts and gives CodeQL a + sanitizer it can recognise on the data-flow path into ``rglob`` / + ``read_bytes``. + """ + try: + prefix = validate_user_path(path, allow_missing=True) + except PathValidationError: + return PrefixInfo(path=path, exists=False) if not prefix.exists(): return PrefixInfo(path=path, exists=False) @@ -102,8 +101,8 @@ def get_prefix_info(path: str) -> PrefixInfo: created = "" try: stat = prefix.stat() - from datetime import datetime, timezone - created = datetime.fromtimestamp(stat.st_ctime, tz=timezone.utc).isoformat() + from datetime import datetime + created = datetime.fromtimestamp(stat.st_ctime, tz=UTC).isoformat() except OSError: pass @@ -121,9 +120,18 @@ def get_prefix_info(path: str) -> PrefixInfo: def delete_prefix(path: str) -> bool: - """Delete a Wine/Proton prefix directory. Returns True on success.""" - prefix = Path(path) - if not prefix.exists(): + """Delete a Wine/Proton prefix directory. Returns True on success. + + Defensive validation: the path must resolve under one of the user-writable + roots (home, /run/media, /media, /mnt, /tmp). This stops a bug or stolen + auth token from triggering ``rmtree("/etc")`` even though the API layer + is expected to validate first. + """ + try: + prefix = validate_user_path(path, allow_missing=False) + except PathValidationError: + return False + if not prefix.exists() or not prefix.is_dir(): return False try: shutil.rmtree(prefix) diff --git a/src/game_setup_hub/profiles_storage.py b/src/game_setup_hub/profiles_storage.py index f0b240d..a8c6435 100644 --- a/src/game_setup_hub/profiles_storage.py +++ b/src/game_setup_hub/profiles_storage.py @@ -6,6 +6,9 @@ from dataclasses import asdict, dataclass from pathlib import Path +from .fsutil import atomic_write_text +from .paths import sanitize_filename + PROFILES_DIR = Path.home() / ".config" / "protonshift" / "profiles" @@ -33,22 +36,21 @@ def list_profiles() -> list[str]: return sorted(names) -def _safe_filename(name: str) -> str: - return "".join(c if c.isalnum() or c in "._- " else "_" for c in name).strip() or "profile" +def _profile_path(name: str) -> Path: + return ensure_profiles_dir() / f"{sanitize_filename(name, fallback='profile')}.json" def save_profile(profile: ApplicationProfile) -> bool: - path = ensure_profiles_dir() / f"{_safe_filename(profile.name)}.json" + path = _profile_path(profile.name) try: - with open(path, "w", encoding="utf-8") as f: - json.dump(asdict(profile), f, indent=2) + atomic_write_text(path, json.dumps(asdict(profile), indent=2)) return True except OSError: return False def load_profile(name: str) -> ApplicationProfile | None: - path = PROFILES_DIR / f"{_safe_filename(name)}.json" + path = _profile_path(name) if not path.exists(): return None try: @@ -66,7 +68,7 @@ def load_profile(name: str) -> ApplicationProfile | None: def delete_profile(name: str) -> bool: - path = PROFILES_DIR / f"{_safe_filename(name)}.json" + path = _profile_path(name) if path.exists(): try: path.unlink() diff --git a/src/game_setup_hub/protontricks.py b/src/game_setup_hub/protontricks.py index ab9d6fc..cb499e4 100644 --- a/src/game_setup_hub/protontricks.py +++ b/src/game_setup_hub/protontricks.py @@ -2,11 +2,11 @@ from __future__ import annotations +import re import subprocess from game_setup_hub.tool_check import find_tool - PROTONTRICKS_FLATPAK = "com.github.Matoking.protontricks" # Common Winetricks verbs users might want @@ -18,6 +18,32 @@ ("arial", "Arial font"), ] +# Steam app IDs are decimal integers. Anything else is rejected so a malicious +# payload like "440; rm -rf ~" cannot reach the protontricks command line. +_APP_ID_RE = re.compile(r"^\d{1,12}$") +# Winetricks verbs are short alphanumeric tokens with optional `_-`. We do +# NOT trust the COMMON_VERBS list as the whitelist because users can pass +# arbitrary verbs from the UI; instead we constrain by *shape*. +_VERB_RE = re.compile(r"^[A-Za-z0-9._\-]{1,64}$") + + +class ProtontricksValidationError(ValueError): + """Raised when an app_id or verb fails validation before subprocess use.""" + + +def _validate_app_id(app_id: str) -> str: + if not isinstance(app_id, str) or not _APP_ID_RE.match(app_id): + raise ProtontricksValidationError(f"Invalid Steam app id: {app_id!r}") + return app_id + + +def _validate_verb(verb: str | None) -> str | None: + if verb is None: + return None + if not isinstance(verb, str) or not _VERB_RE.match(verb): + raise ProtontricksValidationError(f"Invalid winetricks verb: {verb!r}") + return verb + def is_protontricks_available() -> bool: """Check if Protontricks is available (native or Flatpak).""" @@ -39,14 +65,20 @@ def get_protontricks_cmd(app_id: str, verb: str | None = None) -> list[str] | No Get command to run Protontricks for a game. Returns [cmd, ...args] or None if not available. If verb is None, opens GUI (--gui). + + Raises :class:`ProtontricksValidationError` if ``app_id`` or ``verb`` would + inject anything other than a Steam app id / winetricks verb token. We + enforce the whitelist *before* assembling the argv to keep the subprocess + boundary clean even though :func:`subprocess.Popen` with a list arg does + not invoke a shell. """ + safe_app_id = _validate_app_id(app_id) + safe_verb = _validate_verb(verb) + pt = find_tool("protontricks") if pt: - cmd = [pt, app_id] - if verb: - cmd.append(verb) - else: - cmd.append("--gui") + cmd = [pt, safe_app_id] + cmd.append(safe_verb if safe_verb else "--gui") return cmd try: @@ -60,11 +92,8 @@ def get_protontricks_cmd(app_id: str, verb: str | None = None) -> list[str] | No except FileNotFoundError: return None - cmd = ["flatpak", "run", PROTONTRICKS_FLATPAK, app_id] - if verb: - cmd.append(verb) - else: - cmd.append("--gui") + cmd = ["flatpak", "run", PROTONTRICKS_FLATPAK, safe_app_id] + cmd.append(safe_verb if safe_verb else "--gui") return cmd @@ -74,7 +103,10 @@ def run_protontricks(app_id: str, verb: str | None = None) -> tuple[bool, str]: If verb is None, opens the GUI. Returns (success, error_message). """ - cmd = get_protontricks_cmd(app_id, verb) + try: + cmd = get_protontricks_cmd(app_id, verb) + except ProtontricksValidationError as exc: + return False, str(exc) if not cmd: return False, "Protontricks not found. Install from Flathub: com.github.Matoking.protontricks" try: diff --git a/src/game_setup_hub/saves.py b/src/game_setup_hub/saves.py index 541326b..e67204d 100644 --- a/src/game_setup_hub/saves.py +++ b/src/game_setup_hub/saves.py @@ -4,11 +4,25 @@ import zipfile from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path +from .fsutil import dir_size as _dir_size +from .paths import ( + PathValidationError, + sanitize_filename, + validate_user_path, + validate_within, +) + _BACKUP_ROOT = Path.home() / ".config" / "protonshift" / "backups" +# Reject backups larger than 4 GB raw or with > 50k files. A genuine save set +# never approaches this; it is a tripwire for accidental backups of full +# install dirs and a soft DoS guard on disk usage. +_MAX_BACKUP_BYTES = 4 * 1024 * 1024 * 1024 +_MAX_BACKUP_FILES = 50_000 + @dataclass class SaveLocation: @@ -26,26 +40,22 @@ class BackupInfo: created: str -def _dir_size(path: Path) -> int: - total = 0 - try: - for entry in path.rglob("*"): - if not entry.is_symlink() and entry.is_file(): - try: - total += entry.stat().st_size - except OSError: - pass - except OSError: - pass - return total - - def find_save_paths(app_id: str, prefix_path: str | None) -> list[SaveLocation]: - """Detect possible save locations for a game.""" + """Detect possible save locations for a game. + + ``prefix_path`` is validated through :func:`validate_user_path`; if it + falls outside a user-writable root we skip the prefix portion rather + than crawling arbitrary directories. + """ locations: list[SaveLocation] = [] + prefix: Path | None = None if prefix_path: - prefix = Path(prefix_path) + try: + prefix = validate_user_path(prefix_path, allow_missing=True) + except PathValidationError: + prefix = None + if prefix is not None: # Proton saves: various common save paths inside prefix save_candidates = [ (prefix / "pfx" / "drive_c" / "users" / "steamuser" / "Saved Games", "Proton Saved Games"), @@ -65,13 +75,15 @@ def find_save_paths(app_id: str, prefix_path: str | None) -> list[SaveLocation]: label=label, )) - # Native Linux save dirs + # Native Linux save dirs. ``app_id`` is sanitized to a single segment so a + # malicious value like ``../../etc`` cannot escape ``userdata/``. + safe_app_id = sanitize_filename(app_id, fallback="unknown") home = Path.home() native_candidates = [ (home / ".local" / "share" / "Steam" / "userdata", "Steam Cloud / Userdata"), ] for path, label in native_candidates: - app_subdir = path / app_id + app_subdir = path / safe_app_id if app_subdir.exists(): size = _dir_size(app_subdir) if size > 0: @@ -86,31 +98,66 @@ def find_save_paths(app_id: str, prefix_path: str | None) -> list[SaveLocation]: def backup_saves(app_id: str, paths: list[str]) -> str | None: - """Backup save directories to a timestamped zip. Returns backup path or None.""" - backup_dir = _BACKUP_ROOT / app_id + """Backup save directories to a timestamped zip. Returns backup path or None. + + Each input path's tree is added under ``/`` inside the archive, + so identical leaf names from different roots cannot overwrite each other. + Aborts (and removes the partial zip) if size or file-count tripwires fire. + """ + safe_app_id = sanitize_filename(app_id, fallback="unknown") + backup_dir = _BACKUP_ROOT / safe_app_id backup_dir.mkdir(parents=True, exist_ok=True) - timestamp = datetime.now(tz=timezone.utc).strftime("%Y%m%d_%H%M%S") + timestamp = datetime.now(tz=UTC).strftime("%Y%m%d_%H%M%S") zip_path = backup_dir / f"backup_{timestamp}.zip" + total_bytes = 0 + total_files = 0 + used_basenames: dict[str, int] = {} + try: with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: for save_path_str in paths: - save_path = Path(save_path_str) + # Validate every input path lives under a user-writable root + # before touching the filesystem. This both stops the API + # being abused to enumerate /etc and gives CodeQL a clean + # sanitizer it can recognise on the data-flow path. + try: + save_path = validate_user_path(save_path_str, allow_missing=True) + except PathValidationError: + continue if not save_path.exists(): continue + base_name = sanitize_filename(save_path.name, fallback="root") + count = used_basenames.get(base_name, 0) + used_basenames[base_name] = count + 1 + arc_root = base_name if count == 0 else f"{base_name}_{count}" for fpath in save_path.rglob("*"): - if fpath.is_file(): - arcname = str(fpath.relative_to(save_path.parent)) - zf.write(fpath, arcname) + if not fpath.is_file() or fpath.is_symlink(): + continue + try: + size = fpath.lstat().st_size + except OSError: + continue + total_bytes += size + total_files += 1 + if total_bytes > _MAX_BACKUP_BYTES or total_files > _MAX_BACKUP_FILES: + raise OSError("Backup exceeded size or file-count limit") + rel = fpath.relative_to(save_path) + zf.write(fpath, f"{arc_root}/{rel.as_posix()}") return str(zip_path) except OSError: + try: + zip_path.unlink(missing_ok=True) + except OSError: + pass return None def list_backups(app_id: str) -> list[BackupInfo]: """List all backups for a game.""" - backup_dir = _BACKUP_ROOT / app_id + safe_app_id = sanitize_filename(app_id, fallback="unknown") + backup_dir = _BACKUP_ROOT / safe_app_id if not backup_dir.exists(): return [] @@ -118,7 +165,7 @@ def list_backups(app_id: str) -> list[BackupInfo]: for zf in sorted(backup_dir.glob("backup_*.zip"), reverse=True): try: stat = zf.stat() - created = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat() + created = datetime.fromtimestamp(stat.st_mtime, tz=UTC).isoformat() backups.append(BackupInfo( path=str(zf), filename=zf.name, @@ -131,14 +178,42 @@ def list_backups(app_id: str) -> list[BackupInfo]: def restore_backup(backup_path: str, target_dir: str) -> bool: - """Restore a backup zip to the target directory. Returns True on success.""" - zip_path = Path(backup_path) - target = Path(target_dir) + """Restore a backup zip to the target directory. + + Hardened against zip-slip: each archive member is verified to land under + ``target_dir`` before extraction. Backups must originate from + :data:`_BACKUP_ROOT`; arbitrary zip paths are rejected. The target dir + must live under one of the user-writable roots. + """ + zip_path = Path(backup_path).resolve(strict=False) + + try: + validate_within(_BACKUP_ROOT, zip_path) + except PathValidationError: + return False + try: + target = validate_user_path(target_dir, allow_missing=True) + except PathValidationError: + return False + if not zip_path.exists(): return False + target.mkdir(parents=True, exist_ok=True) + try: with zipfile.ZipFile(zip_path, "r") as zf: + for member in zf.infolist(): + # zipfile member names use forward slashes; reject absolute + # paths and parent-traversal before extraction. + member_name = member.filename + if member_name.startswith("/") or "\x00" in member_name: + return False + resolved = (target / member_name).resolve(strict=False) + try: + validate_within(target, resolved) + except PathValidationError: + return False zf.extractall(target) return True except (zipfile.BadZipFile, OSError): diff --git a/src/game_setup_hub/shader_cache.py b/src/game_setup_hub/shader_cache.py index 3a018bb..320f647 100644 --- a/src/game_setup_hub/shader_cache.py +++ b/src/game_setup_hub/shader_cache.py @@ -6,6 +6,8 @@ from dataclasses import dataclass from pathlib import Path +from .fsutil import dir_size as _dir_size + @dataclass class ShaderCacheInfo: @@ -15,20 +17,6 @@ class ShaderCacheInfo: size_bytes: int = 0 -def _dir_size(path: Path) -> int: - total = 0 - try: - for entry in path.rglob("*"): - if not entry.is_symlink() and entry.is_file(): - try: - total += entry.lstat().st_size - except OSError: - pass - except OSError: - pass - return total - - def _shader_cache_dir(steam_root: Path, app_id: str) -> Path: return steam_root / "steamapps" / "shadercache" / app_id diff --git a/src/game_setup_hub/steam.py b/src/game_setup_hub/steam.py index 0756a97..7302685 100644 --- a/src/game_setup_hub/steam.py +++ b/src/game_setup_hub/steam.py @@ -3,6 +3,8 @@ from __future__ import annotations import subprocess +import threading +import time from dataclasses import dataclass from pathlib import Path @@ -67,7 +69,7 @@ def _resolve_steam_root() -> Path | None: def _find_libraryfolders(steam_root: Path) -> list[Path]: """Get all library paths from libraryfolders.vdf.""" - paths = [] + paths: list[Path] = [] for loc in [ steam_root / "steamapps" / "libraryfolders.vdf", steam_root / "config" / "libraryfolders.vdf", @@ -77,16 +79,17 @@ def _find_libraryfolders(steam_root: Path) -> list[Path]: try: with open(loc, encoding="utf-8", errors="replace") as f: data = vdf.load(f) - folders = data.get("libraryfolders", data) - if isinstance(folders, dict): - for _key, folder in folders.items(): - if isinstance(folder, dict) and "path" in folder: - p = Path(folder["path"]) - if p.exists(): - paths.append(p) except (SyntaxError, ValueError, OSError): - pass - break # Use first found + continue + folders = data.get("libraryfolders", data) + if isinstance(folders, dict): + for _key, folder in folders.items(): + if isinstance(folder, dict) and "path" in folder: + p = Path(folder["path"]) + if p.exists(): + paths.append(p) + if paths: + break # Stop at the first manifest that yielded usable paths if not paths and steam_root.exists(): paths.append(steam_root) return paths @@ -101,13 +104,39 @@ def _parse_acf(path: Path) -> dict | None: return None +# Library walks add up: every API request to /games, /games/{app_id}/*, +# /open-path etc. used to re-stat hundreds of acf files. A 5s TTL keeps the +# UI snappy without going stale long enough to confuse users who just +# installed something. +_DISCOVERY_TTL_SECONDS = 5.0 +_discovery_lock = threading.Lock() +_discovery_cache: tuple[float, Path | None, list[SteamGame]] | None = None + + +def invalidate_discovery_cache() -> None: + """Drop the cached game list (call after install/uninstall events).""" + global _discovery_cache + with _discovery_lock: + _discovery_cache = None + + def discover_games() -> tuple[Path | None, list[SteamGame]]: + """Discover Steam root and all installed games. Cached briefly per process. + + Returns ``(steam_root, games)``. Disk walks are skipped if a fresh enough + result is already cached. """ - Discover Steam root and all installed games. - Returns (steam_root, games). - """ + global _discovery_cache + now = time.monotonic() + with _discovery_lock: + cached = _discovery_cache + if cached and (now - cached[0]) < _DISCOVERY_TTL_SECONDS: + return cached[1], cached[2] + steam_root = _resolve_steam_root() if not steam_root: + with _discovery_lock: + _discovery_cache = (now, None, []) return None, [] libraries = _find_libraryfolders(steam_root) @@ -151,6 +180,8 @@ def discover_games() -> tuple[Path | None, list[SteamGame]]: ) games.sort(key=lambda g: (-g.last_played, g.name.lower())) + with _discovery_lock: + _discovery_cache = (now, steam_root, games) return steam_root, games @@ -185,13 +216,21 @@ def get_compattools_dir(steam_root: Path | None) -> Path | None: return None -# Built-in Steam Proton tool IDs (approximate — Steam may use different keys) -BUILTIN_PROTON = ["proton_experimental", "proton_9_0", "proton_8_0", "proton_7_0", "proton_6_3", ""] +# Built-in Steam Proton tool IDs. Source of truth is Steam itself; this list is +# best-effort for the dropdown and may lag a release. Newer Proton tools are +# discovered via `compatibilitytools.d` so users always see GE-Proton/etc. +_BUILTIN_PROTON: tuple[str, ...] = ( + "", + "proton_experimental", + "proton_9_0", + "proton_8_0", + "proton_7_0", +) def get_available_proton_tools(steam_root: Path | None) -> list[str]: """List Proton/GE tools: built-in first, then compatibilitytools.d.""" - tools: list[str] = ["", "proton_experimental", "proton_9_0", "proton_8_0", "proton_7_0"] + tools: list[str] = list(_BUILTIN_PROTON) compat_dir = get_compattools_dir(steam_root) if compat_dir and compat_dir.exists(): for item in sorted(compat_dir.iterdir()): diff --git a/src/game_setup_hub/theme.css b/src/game_setup_hub/theme.css deleted file mode 100644 index b81a061..0000000 --- a/src/game_setup_hub/theme.css +++ /dev/null @@ -1,257 +0,0 @@ -/* ProtonShift — Futuristic / Cyberpunk theme */ - -/* Neon accent palette */ -@define-color neon-cyan #00d4ff; -@define-color neon-blue #3b82f6; -@define-color neon-purple #a855f7; -@define-color neon-pink #ec4899; -@define-color glow-cyan rgba(0, 212, 255, 0.4); -@define-color glow-purple rgba(168, 85, 247, 0.3); -@define-color surface-deep #0f0f14; -@define-color surface-mid #16161e; -@define-color surface-elevated #1e1e28; - -/* Window: dark gradient background */ -window.background, -window.background .background { - background: linear-gradient(180deg, @surface-deep 0%, #0a0a0f 100%); -} - -/* Header bar: dark surface + neon top accent */ -headerbar { - background-color: @surface-elevated; - border-top: 2px solid @neon-cyan; - box-shadow: 0 0 20px @glow-cyan; -} - -/* Window controls (minimize, maximize, close): neon-styled */ -windowcontrols { - margin: 0 4px; -} -windowcontrols > button { - border-radius: 8px; - margin: 0 2px; - min-width: 32px; - min-height: 28px; - color: rgba(255, 255, 255, 0.7); - transition: all 150ms ease; -} -windowcontrols > button:hover { - background: rgba(0, 212, 255, 0.15); - color: @neon-cyan; - box-shadow: 0 0 8px @glow-cyan; -} -windowcontrols > button:active { - background: rgba(0, 212, 255, 0.25); -} - -/* View switcher: neon pills, 10px spacing */ -viewswitcher { - margin: 0 10px; -} -viewswitcher, -viewswitcher button { - border-radius: 9999px; -} -viewswitcher button { - margin: 0 6px; - padding: 10px 14px; - border: 1px solid rgba(0, 212, 255, 0.3); - transition: all 200ms ease; -} -viewswitcher button:hover { - background: rgba(0, 212, 255, 0.1); - border-color: @neon-cyan; - box-shadow: 0 0 12px @glow-cyan; -} -viewswitcher button:checked { - background: rgba(0, 212, 255, 0.2); - border-color: @neon-cyan; - color: @neon-cyan; - box-shadow: 0 0 16px @glow-cyan; -} - -/* Game list frame: search + list flow together */ -.game-list-frame { - background: @surface-elevated; - border: 1px solid rgba(0, 212, 255, 0.2); - border-radius: 12px; - padding: 10px; - box-shadow: 0 0 24px rgba(0, 212, 255, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.03); -} - -.game-list-search { - margin-bottom: 4px; -} - -/* List rows: single clean selector, no double box */ -list row, -listbox row { - border-radius: 8px; - margin: 2px 6px; - min-height: 40px; - padding: 10px; - transition: background 150ms ease; - border: none; - box-shadow: none; -} -list row:hover, -listbox row:hover { - background: rgba(0, 212, 255, 0.08); -} -list row:selected, -listbox row:selected { - background: rgba(0, 212, 255, 0.18); -} - -/* Pill buttons: neon outline, 10px padding */ -button.pill { - padding: 10px 14px; - border: 1px solid rgba(0, 212, 255, 0.4); - border-radius: 9999px; - background: transparent; - transition: all 200ms ease; -} -button.pill:hover { - background: rgba(0, 212, 255, 0.12); - border-color: @neon-cyan; - box-shadow: 0 0 14px @glow-cyan; -} -button.pill:active { - box-shadow: 0 0 8px @glow-cyan; -} - -/* Suggested-action pills: filled neon */ -button.pill.suggested-action { - background: rgba(0, 212, 255, 0.2); - border-color: @neon-cyan; - color: @neon-cyan; -} -button.pill.suggested-action:hover { - background: rgba(0, 212, 255, 0.3); - box-shadow: 0 0 18px @glow-cyan; -} - -/* Entry: neon focus ring, 10px padding */ -entry { - padding: 10px 12px; - border-radius: 8px; - border: 1px solid rgba(255, 255, 255, 0.1); - transition: all 200ms ease; -} -entry:focus-within { - border-color: @neon-cyan; - box-shadow: 0 0 0 2px @glow-cyan; -} - -/* Search entry */ -searchbar entry, -.search { - border-radius: 9999px; -} - -/* Frame: card-like with subtle glow, 10px padding */ -frame { - padding: 10px; - border: 1px solid rgba(0, 212, 255, 0.15); - border-radius: 12px; - background: @surface-elevated; - box-shadow: 0 0 16px rgba(0, 0, 0, 0.3), 0 1px 0 rgba(255, 255, 255, 0.02); -} - -/* Titles: subtle neon tint */ -.title-1 { - color: @neon-cyan; - font-weight: 700; - text-shadow: 0 0 20px @glow-cyan; -} -.title-4 { - color: rgba(255, 255, 255, 0.9); -} - -/* Dim labels: muted cyan */ -.dim-label { - color: rgba(0, 212, 255, 0.6); -} - -/* Environment hint: prominent storage path info */ -.env-hint { - font-size: 14px; - color: @neon-cyan; - opacity: 0.95; - text-shadow: 0 0 12px @glow-cyan; -} - -/* Placeholder: centered, large, prominent */ -.placeholder-title { - font-size: 28px; - font-weight: 600; - color: @neon-cyan; - text-shadow: 0 0 24px @glow-cyan; -} - -/* Action rows: 10px padding */ -actionrow { - border-radius: 8px; - margin: 5px 10px; - padding: 10px; -} - -/* FlowBox (preset pills): 10px between items */ -flowbox flowboxchild { - margin: 10px; -} - -/* System view: card styling */ -.system-card { - min-width: 340px; - background: @surface-elevated; - border: 1px solid rgba(0, 212, 255, 0.25); - border-radius: 16px; - box-shadow: 0 0 20px rgba(0, 212, 255, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.04); - transition: all 200ms ease; -} -.system-card:hover { - border-color: rgba(0, 212, 255, 0.4); - box-shadow: 0 0 28px rgba(0, 212, 255, 0.12); -} - -/* Preset help popover: polished wrapper */ -popover.preset-help-popover { - min-width: 420px; - min-height: 340px; - background: @surface-elevated; - border: 1px solid rgba(0, 212, 255, 0.3); - border-radius: 16px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), 0 0 24px rgba(0, 212, 255, 0.1); -} - -popover.preset-help-popover > contents { - border-radius: 16px; -} - -/* Preset help cards inside popover */ -.preset-help-card { - background: @surface-mid; - border: 1px solid rgba(0, 212, 255, 0.15); - border-radius: 12px; - box-shadow: none; -} - -.preset-help-card:hover { - border-color: rgba(0, 212, 255, 0.25); -} - -/* Loading spinner: cyan */ -spinner { - color: @neon-cyan; -} - -/* Scrollbar: subtle neon */ -scrollbar slider { - background: rgba(0, 212, 255, 0.3); - border-radius: 10px; -} -scrollbar slider:hover { - background: rgba(0, 212, 255, 0.5); -} diff --git a/src/game_setup_hub/vdf_config.py b/src/game_setup_hub/vdf_config.py index d748196..b5420ef 100644 --- a/src/game_setup_hub/vdf_config.py +++ b/src/game_setup_hub/vdf_config.py @@ -1,11 +1,14 @@ -"""Read/write Steam localconfig.vdf for LaunchOptions.""" +"""Read/write Steam localconfig.vdf for LaunchOptions and CompatToolMapping.""" from __future__ import annotations +import io from pathlib import Path import vdf +from .fsutil import atomic_write_text + def _get_apps_node(data: dict) -> dict | None: """Navigate to UserLocalConfigStore.Software.Valve.Steam.apps (lowercase).""" @@ -19,15 +22,39 @@ def _get_apps_node(data: dict) -> dict | None: return None -def get_launch_options(config_path: Path, app_id: str) -> str: - """Get LaunchOptions for a game. Returns empty string if not set.""" - if not config_path.exists(): - return "" +def _load_vdf(path: Path) -> dict: + if not path.exists(): + return {} try: - with open(config_path, encoding="utf-8", errors="replace") as f: + with open(path, encoding="utf-8", errors="replace") as f: data = vdf.load(f) + return data if isinstance(data, dict) else {} except (SyntaxError, ValueError, OSError): - return "" + return {} + + +def _dump_vdf_atomic(path: Path, data: dict) -> bool: + """Serialise ``data`` and atomically replace ``path``. + + Atomicity matters here because partial writes to ``localconfig.vdf`` can + nuke every launch option / compat tool mapping for the user. Steam itself + holds the file open while running, so a torn write is more than a theory. + """ + buf = io.StringIO() + try: + vdf.dump(data, buf, pretty=True) + except (TypeError, ValueError): + return False + try: + atomic_write_text(path, buf.getvalue()) + except OSError: + return False + return True + + +def get_launch_options(config_path: Path, app_id: str) -> str: + """Get LaunchOptions for a game. Returns empty string if not set.""" + data = _load_vdf(config_path) apps = _get_apps_node(data) if not apps: return "" @@ -39,13 +66,7 @@ def get_launch_options(config_path: Path, app_id: str) -> str: def get_compat_tool(config_path: Path, app_id: str) -> str: """Get CompatToolMapping for a game. Returns empty if not set.""" - if not config_path.exists(): - return "" - try: - with open(config_path, encoding="utf-8", errors="replace") as f: - data = vdf.load(f) - except (SyntaxError, ValueError, OSError): - return "" + data = _load_vdf(config_path) try: store = data["UserLocalConfigStore"] software = store["Software"] @@ -53,7 +74,10 @@ def get_compat_tool(config_path: Path, app_id: str) -> str: steam = valve["Steam"] mapping = steam.get("CompatToolMapping") or steam.get("compat_tool_mapping") if isinstance(mapping, dict): - return mapping.get(app_id, "") + entry = mapping.get(app_id, "") + if isinstance(entry, dict): + return entry.get("name", "") + return entry or "" except (KeyError, TypeError): pass return "" @@ -61,13 +85,7 @@ def get_compat_tool(config_path: Path, app_id: str) -> str: def set_compat_tool(config_path: Path, app_id: str, tool_name: str) -> bool: """Set CompatToolMapping for a game. Use empty string to clear.""" - data: dict = {} - if config_path.exists(): - try: - with open(config_path, encoding="utf-8", errors="replace") as f: - data = vdf.load(f) - except (SyntaxError, ValueError, OSError): - return False + data = _load_vdf(config_path) if config_path.exists() else {} store = data.setdefault("UserLocalConfigStore", {}) software = store.setdefault("Software", {}) @@ -83,26 +101,13 @@ def set_compat_tool(config_path: Path, app_id: str, tool_name: str) -> bool: elif app_id in mapping: del mapping[app_id] - config_path.parent.mkdir(parents=True, exist_ok=True) - try: - with open(config_path, "w", encoding="utf-8", newline="\n") as f: - vdf.dump(data, f, pretty=True) - except OSError: - return False - return True + return _dump_vdf_atomic(config_path, data) def set_launch_options(config_path: Path, app_id: str, options: str) -> bool: """Set LaunchOptions for a game. Creates nodes if needed.""" - data: dict = {} - if config_path.exists(): - try: - with open(config_path, encoding="utf-8", errors="replace") as f: - data = vdf.load(f) - except (SyntaxError, ValueError, OSError): - return False - - # Ensure full structure exists + data = _load_vdf(config_path) if config_path.exists() else {} + store = data.setdefault("UserLocalConfigStore", {}) software = store.setdefault("Software", {}) valve = software.setdefault("Valve", {}) @@ -113,10 +118,4 @@ def set_launch_options(config_path: Path, app_id: str, options: str) -> bool: apps[app_id] = {} apps[app_id]["LaunchOptions"] = options - config_path.parent.mkdir(parents=True, exist_ok=True) - try: - with open(config_path, "w", encoding="utf-8", newline="\n") as f: - vdf.dump(data, f, pretty=True) - except OSError: - return False - return True + return _dump_vdf_atomic(config_path, data) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..444c757 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,67 @@ +"""Shared fixtures. + +Tests must never write outside the workspace and must not call out to a real +Steam install. We pin ``HOME`` to a temp dir for every test so any code that +resolves ``Path.home()`` lands in an isolated sandbox. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + + +@pytest.fixture(autouse=True) +def isolated_home(tmp_path, monkeypatch) -> Path: + """Redirect HOME so module-level ``Path.home()`` constants stay safe. + + Several modules cache ``Path.home() / ...`` at import time. We reload them + after the patch so their constants point at the per-test sandbox. + """ + fake_home = tmp_path / "home" + fake_home.mkdir() + monkeypatch.setenv("HOME", str(fake_home)) + monkeypatch.setattr(Path, "home", classmethod(lambda cls: fake_home)) + + import importlib + for mod_name in ( + "game_setup_hub.env_vars", + "game_setup_hub.profiles_storage", + "game_setup_hub.fixes", + "game_setup_hub.saves", + "game_setup_hub.mangohud", + ): + try: + importlib.reload(__import__(mod_name, fromlist=["_"])) + except ModuleNotFoundError: + pass + return fake_home + + +@pytest.fixture +def api_token(monkeypatch) -> str: + """Set a known token and pin it on the api state module.""" + token = "test-token-abc123" + monkeypatch.setenv("PROTONSHIFT_API_TOKEN", token) + + from game_setup_hub.api import _state as api_state + + monkeypatch.setattr(api_state, "API_TOKEN", token, raising=False) + return token + + +@pytest.fixture +def auth_headers(api_token) -> dict[str, str]: + return {"Authorization": f"Bearer {api_token}"} + + +def _ensure_pythonpath() -> None: + """Make `src/` importable when pytest is run from the repo root.""" + src = Path(__file__).resolve().parents[1] / "src" + if str(src) not in sys.path: + sys.path.insert(0, str(src)) + + +_ensure_pythonpath() diff --git a/tests/test_api_smoke.py b/tests/test_api_smoke.py new file mode 100644 index 0000000..afed6ec --- /dev/null +++ b/tests/test_api_smoke.py @@ -0,0 +1,94 @@ +"""End-to-end FastAPI smoke + auth tests via TestClient. + +Network calls are the wrong layer to exercise here; we just want to confirm +the app boots, auth enforcement works, CORS is locked down, and the +public-ish endpoints don't raise. +""" + +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture +def client(api_token): + from game_setup_hub.api import _state as api_state + from game_setup_hub.api import app + + api_state.API_TOKEN = api_token + return TestClient(app) + + +def test_health_works_without_auth(client) -> None: + resp = client.get("/health") + assert resp.status_code == 200 + assert resp.json() == {"status": "ok"} + + +def test_protected_endpoint_rejects_missing_token(client) -> None: + resp = client.get("/system") + assert resp.status_code == 401 + assert resp.headers.get("www-authenticate") == "Bearer" + + +def test_protected_endpoint_rejects_wrong_token(client) -> None: + resp = client.get("/system", headers={"Authorization": "Bearer nope"}) + assert resp.status_code == 401 + + +def test_protected_endpoint_accepts_correct_token(client, auth_headers) -> None: + resp = client.get("/system", headers=auth_headers) + assert resp.status_code == 200 + body = resp.json() + assert "gpus" in body + assert "power_profiles" in body + + +def test_steam_running_guard_on_launch_options(client, auth_headers, monkeypatch) -> None: + """If Steam is running, edits to localconfig.vdf must be rejected.""" + from game_setup_hub.api import _state as api_state + from game_setup_hub.api.routes import games as games_route + + monkeypatch.setattr(games_route, "is_steam_running", lambda: True) + monkeypatch.setattr(api_state, "config_path", "fake.vdf", raising=False) + monkeypatch.setattr(api_state, "steam_discovered", True, raising=False) + + resp = client.put( + "/games/440/launch-options", + headers={**auth_headers, "Content-Type": "application/json"}, + json={"options": "-novid"}, + ) + assert resp.status_code == 409 + assert "Steam is running" in resp.json()["detail"] + + +def test_open_path_rejects_outside_home(client, auth_headers) -> None: + resp = client.post( + "/open-path", + headers={**auth_headers, "Content-Type": "application/json"}, + json={"path": "/etc/passwd"}, + ) + assert resp.status_code == 400 + + +def test_delete_prefix_rejects_outside_home(client, auth_headers) -> None: + resp = client.delete( + "/games/12345/prefix", + headers=auth_headers, + params={"prefix_path": "/etc"}, + ) + assert resp.status_code == 400 + + +def test_gamescope_build_returns_argv(client, auth_headers) -> None: + resp = client.post( + "/gamescope/build-cmd", + headers={**auth_headers, "Content-Type": "application/json"}, + json={"output_width": 1920, "output_height": 1080}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["argv"][0] == "gamescope" + assert body["argv"][-1] == "--" + assert "1920" in body["command"] diff --git a/tests/test_controllers.py b/tests/test_controllers.py new file mode 100644 index 0000000..f33011e --- /dev/null +++ b/tests/test_controllers.py @@ -0,0 +1,76 @@ +"""SDL GUID + classification logic for controllers.""" + +from __future__ import annotations + +from game_setup_hub.controllers import ( + ControllerInfo, + _build_sdl_guid, + _classify_controller, + get_sdl_mapping, +) + + +def test_classify_xbox() -> None: + assert _classify_controller("Microsoft Xbox Series X Controller") == "xbox" + + +def test_classify_playstation() -> None: + assert _classify_controller("Sony Interactive Entertainment DualSense") == "playstation" + + +def test_classify_nintendo() -> None: + assert _classify_controller("Nintendo Switch Pro Controller") == "nintendo" + + +def test_classify_unknown_is_generic() -> None: + assert _classify_controller("Random USB Joystick") == "generic" + + +def test_build_sdl_guid_is_32_hex_chars() -> None: + guid = _build_sdl_guid("0003", "045e", "02ea", "0301") + assert len(guid) == 32 + assert all(c in "0123456789abcdef" for c in guid) + + +def test_build_sdl_guid_endianness() -> None: + """Verify the little-endian layout: bus, crc=0, vendor, 0, product, 0, version, 0.""" + guid = _build_sdl_guid("0003", "045e", "02ea", "0301") + # bus 0x0003 -> "0300"; crc 0x0000 -> "0000"; vendor 0x045e -> "5e04"; ... + assert guid.startswith("03000000") + assert guid[8:16] == "5e040000" + assert guid[16:24] == "ea020000" + assert guid[24:32] == "01030000" + + +def test_build_sdl_guid_handles_invalid_hex() -> None: + guid = _build_sdl_guid("zzzz", "", "", "") + assert guid == "0" * 32 + + +def test_get_sdl_mapping_includes_guid_and_platform() -> None: + ctrl = ControllerInfo( + id="045e:02ea", + name="Xbox Wireless Controller", + device_path="/dev/input/js0", + controller_type="xbox", + vendor_id="045e", + product_id="02ea", + bus_type="0003", + version="0301", + ) + mapping = get_sdl_mapping(ctrl) + parts = mapping.split(",") + assert len(parts[0]) == 32 # GUID + assert parts[1] == "Xbox Wireless Controller" + assert "platform:Linux" in mapping + + +def test_get_sdl_mapping_strips_commas_from_name() -> None: + ctrl = ControllerInfo( + id="x", name="Brand, Model, X", device_path="/dev/input/js0", + controller_type="xbox", vendor_id="0", product_id="0", bus_type="0003", + ) + mapping = get_sdl_mapping(ctrl) + # Name field (index 1) must not contain commas, so the mapping fields parse correctly + parts = mapping.split(",") + assert parts[1] == "Brand Model X" diff --git a/tests/test_display.py b/tests/test_display.py new file mode 100644 index 0000000..52a0fe8 --- /dev/null +++ b/tests/test_display.py @@ -0,0 +1,49 @@ +"""Whitelist tests for the display.set_resolution input validators.""" + +from __future__ import annotations + +from unittest.mock import patch + +from game_setup_hub.display import MonitorInfo, set_resolution + + +def _fake_monitor(name: str = "HDMI-1") -> MonitorInfo: + return MonitorInfo( + name=name, + connected=True, + resolution="1920x1080", + refresh_rate="60", + primary=True, + position="+0+0", + ) + + +def test_set_resolution_rejects_shell_metachars() -> None: + with patch("game_setup_hub.display.get_monitors", return_value=[_fake_monitor()]): + assert set_resolution("HDMI-1; rm -rf ~", 1920, 1080) is False + assert set_resolution("$(id)", 1920, 1080) is False + assert set_resolution("HDMI 1", 1920, 1080) is False + + +def test_set_resolution_rejects_unknown_monitor() -> None: + with patch("game_setup_hub.display.get_monitors", return_value=[_fake_monitor("HDMI-1")]): + assert set_resolution("DP-99", 1920, 1080) is False + + +def test_set_resolution_rejects_oversize_dimensions() -> None: + with patch("game_setup_hub.display.get_monitors", return_value=[_fake_monitor()]): + assert set_resolution("HDMI-1", 99999, 1080) is False + assert set_resolution("HDMI-1", 1920, 0) is False + assert set_resolution("HDMI-1", -1, 1080) is False + + +def test_set_resolution_rejects_silly_refresh() -> None: + with patch("game_setup_hub.display.get_monitors", return_value=[_fake_monitor()]): + assert set_resolution("HDMI-1", 1920, 1080, refresh=10000) is False + assert set_resolution("HDMI-1", 1920, 1080, refresh=-1) is False + + +def test_set_resolution_returns_false_when_xrandr_missing() -> None: + with patch("game_setup_hub.display.get_monitors", return_value=[_fake_monitor()]), \ + patch("game_setup_hub.display.find_tool", return_value=None): + assert set_resolution("HDMI-1", 1920, 1080) is False diff --git a/tests/test_env_vars.py b/tests/test_env_vars.py new file mode 100644 index 0000000..cc53dde --- /dev/null +++ b/tests/test_env_vars.py @@ -0,0 +1,53 @@ +"""Round-trip tests for the environment.d writer.""" + +from __future__ import annotations + +from pathlib import Path + +from game_setup_hub.env_vars import ( + GAMING_CONF, + read_gaming_env, + write_conf, + write_gaming_env, +) + + +def test_write_and_read_roundtrip(tmp_path: Path) -> None: + target = tmp_path / "70-test.conf" + payload = {"FOO": "bar", "WITH_SPACES": "hello world", "EMPTY": ""} + assert write_conf(target, payload, header="generated") is True + text = target.read_text(encoding="utf-8") + assert "FOO=\"bar\"" in text + assert "WITH_SPACES=\"hello world\"" in text + assert text.startswith("# generated") + + +def test_write_conf_quotes_special_chars(tmp_path: Path) -> None: + target = tmp_path / "special.conf" + write_conf(target, {"SHELL_INJ": 'val with "quotes" and \\backslash'}) + text = target.read_text(encoding="utf-8") + assert 'SHELL_INJ="val with \\"quotes\\" and \\\\backslash"' in text + + +def test_write_conf_skips_invalid_keys(tmp_path: Path) -> None: + target = tmp_path / "bad.conf" + write_conf(target, {"GOOD": "1", "BAD-KEY": "2", "1NUMERIC": "3"}) + text = target.read_text(encoding="utf-8") + assert "GOOD=\"1\"" in text + assert "BAD-KEY" not in text + assert "1NUMERIC" not in text + + +def test_gaming_env_roundtrip() -> None: + original = {"PROTON_LOG": "1", "DXVK_HUD": "fps"} + assert write_gaming_env(original) is True + assert read_gaming_env() == original + + target = Path.home() / ".config" / "environment.d" / GAMING_CONF + assert target.exists() + + +def test_gaming_env_overwrite_replaces_atomically() -> None: + write_gaming_env({"A": "1"}) + write_gaming_env({"B": "2"}) + assert read_gaming_env() == {"B": "2"} diff --git a/tests/test_fsutil.py b/tests/test_fsutil.py new file mode 100644 index 0000000..1bb766a --- /dev/null +++ b/tests/test_fsutil.py @@ -0,0 +1,78 @@ +"""Cover the parts of fsutil whose bugs would corrupt user config files.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from game_setup_hub.fsutil import ( + atomic_write_bytes, + atomic_write_text, + dir_size, + human_size, +) + + +def test_dir_size_sums_files_recursively(tmp_path: Path) -> None: + (tmp_path / "a.txt").write_bytes(b"hello") + sub = tmp_path / "sub" + sub.mkdir() + (sub / "b.txt").write_bytes(b"world!") + assert dir_size(tmp_path) == len(b"hello") + len(b"world!") + + +def test_dir_size_skips_symlinks_by_default(tmp_path: Path) -> None: + real = tmp_path / "real" + real.mkdir() + (real / "a").write_bytes(b"x" * 10) + link = tmp_path / "link" + link.symlink_to(real) + # 10 bytes for the file, plus the symlink itself contributes nothing + assert dir_size(tmp_path) == 10 + + +def test_dir_size_returns_zero_for_missing(tmp_path: Path) -> None: + assert dir_size(tmp_path / "nope") == 0 + + +@pytest.mark.parametrize( + "raw,expected_unit", + [ + (0, "B"), + (1023, "B"), + (1024, "KB"), + (1024 * 1024, "MB"), + (1024 ** 3, "GB"), + (1024 ** 4, "TB"), + ], +) +def test_human_size_unit_progression(raw: int, expected_unit: str) -> None: + assert human_size(raw).endswith(expected_unit) + + +def test_atomic_write_text_replaces_existing(tmp_path: Path) -> None: + target = tmp_path / "config.conf" + target.write_text("old contents", encoding="utf-8") + atomic_write_text(target, "new contents") + assert target.read_text(encoding="utf-8") == "new contents" + + +def test_atomic_write_text_creates_parents(tmp_path: Path) -> None: + target = tmp_path / "deep" / "nested" / "file.txt" + atomic_write_text(target, "ok") + assert target.read_text(encoding="utf-8") == "ok" + + +def test_atomic_write_text_leaves_no_temp_files(tmp_path: Path) -> None: + target = tmp_path / "file.txt" + atomic_write_text(target, "x") + leftovers = [p for p in tmp_path.iterdir() if p.name.startswith(".file.txt.")] + assert leftovers == [] + + +def test_atomic_write_bytes_roundtrips(tmp_path: Path) -> None: + target = tmp_path / "blob.bin" + payload = b"\x00\x01\x02binary" + atomic_write_bytes(target, payload) + assert target.read_bytes() == payload diff --git a/tests/test_gamescope.py b/tests/test_gamescope.py new file mode 100644 index 0000000..578a89c --- /dev/null +++ b/tests/test_gamescope.py @@ -0,0 +1,48 @@ +"""Gamescope cmd builder.""" + +from __future__ import annotations + +import shlex + +from game_setup_hub.gamescope import ( + GamescopeOptions, + build_gamescope_argv, + build_gamescope_cmd, +) + + +def test_argv_starts_with_gamescope_and_ends_with_separator() -> None: + argv = build_gamescope_argv(GamescopeOptions()) + assert argv[0] == "gamescope" + assert argv[-1] == "--" + + +def test_resolution_args_emitted() -> None: + opts = GamescopeOptions(output_width=1920, output_height=1080, game_width=1280, game_height=720) + argv = build_gamescope_argv(opts) + assert "-w" in argv and "1920" in argv + assert "-h" in argv and "1080" in argv + assert "-W" in argv and "1280" in argv + assert "-H" in argv and "720" in argv + + +def test_extra_args_are_shell_split() -> None: + opts = GamescopeOptions(extra_args='--prefer-vk-device "1234:5678"') + argv = build_gamescope_argv(opts) + assert "--prefer-vk-device" in argv + assert "1234:5678" in argv + + +def test_cmd_string_is_quoted() -> None: + opts = GamescopeOptions(extra_args="--mango 'hello world'") + cmd = build_gamescope_cmd(opts) + parts = shlex.split(cmd) + assert parts[0] == "gamescope" + assert "hello world" in parts + + +def test_fsr_sharpness_clamped() -> None: + opts = GamescopeOptions(fsr=True, fsr_sharpness=999) + argv = build_gamescope_argv(opts) + sharpness_idx = argv.index("--fsr-sharpness") + assert argv[sharpness_idx + 1] == "20" diff --git a/tests/test_paths.py b/tests/test_paths.py new file mode 100644 index 0000000..987aae5 --- /dev/null +++ b/tests/test_paths.py @@ -0,0 +1,82 @@ +"""Sanitization tests — these are the wall between the API and the filesystem.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from game_setup_hub.paths import ( + PathValidationError, + safe_join, + sanitize_filename, + validate_user_path, + validate_within, +) + + +@pytest.mark.parametrize( + "raw,expected", + [ + ("simple.json", "simple.json"), + ("My Game (2024)", "My Game _2024_"), + ("../escape", "_escape"), + ("../../etc/passwd", "_.._etc_passwd"), + ("...hidden", "hidden"), + ("", "untitled"), + ("a" * 500, "a" * 200), + ], +) +def test_sanitize_filename(raw: str, expected: str) -> None: + assert sanitize_filename(raw) == expected + + +def test_safe_join_blocks_traversal(tmp_path: Path) -> None: + with pytest.raises(PathValidationError): + safe_join(tmp_path, "..", "etc") + + +def test_safe_join_blocks_absolute(tmp_path: Path) -> None: + with pytest.raises(PathValidationError): + safe_join(tmp_path, "/etc/passwd") + + +def test_safe_join_blocks_null_byte(tmp_path: Path) -> None: + with pytest.raises(PathValidationError): + safe_join(tmp_path, "a\x00b") + + +def test_safe_join_allows_subdir(tmp_path: Path) -> None: + result = safe_join(tmp_path, "sub", "file.txt") + assert result == (tmp_path / "sub" / "file.txt").resolve() + + +def test_validate_within_rejects_outside(tmp_path: Path) -> None: + other = tmp_path.parent / "outside" + with pytest.raises(PathValidationError): + validate_within(tmp_path, other) + + +def test_validate_user_path_accepts_home(tmp_path: Path) -> None: + # `isolated_home` fixture pins HOME to tmp_path/home. + home = Path.home() + target = home / "stuff" + target.mkdir() + assert validate_user_path(target) == target.resolve() + + +def test_validate_user_path_rejects_etc() -> None: + with pytest.raises(PathValidationError): + validate_user_path("/etc/passwd") + + +def test_validate_user_path_requires_existence_by_default() -> None: + home = Path.home() + with pytest.raises(PathValidationError): + validate_user_path(home / "does-not-exist") + + +def test_validate_user_path_allow_missing() -> None: + home = Path.home() + candidate = home / "future" + assert validate_user_path(candidate, allow_missing=True) == candidate.resolve() diff --git a/tests/test_protontricks.py b/tests/test_protontricks.py new file mode 100644 index 0000000..e55f4ec --- /dev/null +++ b/tests/test_protontricks.py @@ -0,0 +1,57 @@ +"""Whitelist tests for the new subprocess input validators.""" + +from __future__ import annotations + +import pytest + +from game_setup_hub.protontricks import ( + ProtontricksValidationError, + _validate_app_id, + _validate_verb, +) + + +@pytest.mark.parametrize("good", ["1", "440", "1234567890"]) +def test_validate_app_id_accepts_digits(good: str) -> None: + assert _validate_app_id(good) == good + + +@pytest.mark.parametrize( + "bad", + [ + "", + "abc", + "440; rm -rf ~", + "440 1", + "../440", + "1234567890123", # 13 digits — over our cap + ], +) +def test_validate_app_id_rejects_garbage(bad: str) -> None: + with pytest.raises(ProtontricksValidationError): + _validate_app_id(bad) + + +def test_validate_verb_accepts_none() -> None: + assert _validate_verb(None) is None + + +@pytest.mark.parametrize("good", ["vcrun2022", "dotnet48", "d3dx9", "core-fonts", "a.b.c"]) +def test_validate_verb_accepts_alnum(good: str) -> None: + assert _validate_verb(good) == good + + +@pytest.mark.parametrize( + "bad", + [ + "", + "vcrun;rm", + "vcrun 2022", + "$(whoami)", + "../escape", + "x" * 65, + ], +) +def test_validate_verb_rejects_garbage(bad: str) -> None: + with pytest.raises(ProtontricksValidationError): + _validate_verb(bad) diff --git a/tests/test_tool_check.py b/tests/test_tool_check.py new file mode 100644 index 0000000..6fda7e2 --- /dev/null +++ b/tests/test_tool_check.py @@ -0,0 +1,23 @@ +"""Smoke tests for tool_check fallback paths. + +We can't assume any specific tool exists in CI, so the assertions are about +behaviour (boolean, no crash) rather than concrete results. +""" + +from __future__ import annotations + +from game_setup_hub.tool_check import find_tool, is_tool_available + + +def test_is_tool_available_returns_bool() -> None: + assert isinstance(is_tool_available("python3"), bool) + + +def test_find_tool_returns_path_or_none() -> None: + result = find_tool("python3") + assert result is None or isinstance(result, str) + + +def test_unknown_tool_is_unavailable() -> None: + assert is_tool_available("no-such-tool-zzzzz") is False + assert find_tool("no-such-tool-zzzzz") is None diff --git a/tests/test_vdf_config.py b/tests/test_vdf_config.py new file mode 100644 index 0000000..daa2176 --- /dev/null +++ b/tests/test_vdf_config.py @@ -0,0 +1,79 @@ +"""Round-trip + atomicity tests for vdf_config.""" + +from __future__ import annotations + +from pathlib import Path + +import vdf + +from game_setup_hub.vdf_config import ( + get_compat_tool, + get_launch_options, + set_compat_tool, + set_launch_options, +) + + +def _seed_localconfig(path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + data = { + "UserLocalConfigStore": { + "Software": { + "Valve": { + "Steam": { + "apps": { + "440": {"LaunchOptions": "-novid"}, + }, + "CompatToolMapping": { + "440": {"name": "proton_8_0", "config": "", "priority": "250"}, + }, + } + } + } + } + } + with open(path, "w", encoding="utf-8") as f: + vdf.dump(data, f, pretty=True) + + +def test_get_existing_launch_options(tmp_path: Path) -> None: + cfg = tmp_path / "localconfig.vdf" + _seed_localconfig(cfg) + assert get_launch_options(cfg, "440") == "-novid" + + +def test_get_existing_compat_tool(tmp_path: Path) -> None: + cfg = tmp_path / "localconfig.vdf" + _seed_localconfig(cfg) + assert get_compat_tool(cfg, "440") == "proton_8_0" + + +def test_set_launch_options_creates_nodes(tmp_path: Path) -> None: + cfg = tmp_path / "fresh.vdf" + assert set_launch_options(cfg, "12345", "MANGOHUD=1 %command%") is True + assert get_launch_options(cfg, "12345") == "MANGOHUD=1 %command%" + + +def test_set_launch_options_preserves_other_apps(tmp_path: Path) -> None: + cfg = tmp_path / "localconfig.vdf" + _seed_localconfig(cfg) + set_launch_options(cfg, "10", "-fullscreen") + assert get_launch_options(cfg, "10") == "-fullscreen" + # Existing app must still be intact + assert get_launch_options(cfg, "440") == "-novid" + assert get_compat_tool(cfg, "440") == "proton_8_0" + + +def test_set_compat_tool_clears_when_empty(tmp_path: Path) -> None: + cfg = tmp_path / "localconfig.vdf" + _seed_localconfig(cfg) + set_compat_tool(cfg, "440", "") + assert get_compat_tool(cfg, "440") == "" + + +def test_writes_are_atomic_no_temp_files(tmp_path: Path) -> None: + cfg = tmp_path / "localconfig.vdf" + _seed_localconfig(cfg) + set_launch_options(cfg, "440", "-newopt") + leftovers = [p.name for p in tmp_path.iterdir() if p.name.startswith(".localconfig.vdf.")] + assert leftovers == [] diff --git a/tests/test_vendor_compat.py b/tests/test_vendor_compat.py new file mode 100644 index 0000000..1a158a4 --- /dev/null +++ b/tests/test_vendor_compat.py @@ -0,0 +1,106 @@ +"""Regression tests for ``_vendor_compat.fixup_vendor_path``. + +Goal: prove the read-only-AppImage scenario actually works. The previous +implementation tried to ``shutil.rmtree`` the offending vendor dir, which +silently no-op'd on squashfs. The current implementation must: + +* Detect an ABI-mismatched vendored ``.so`` without writing to the FS. +* Prepend system site-packages to ``sys.path`` so the system copy wins + import resolution. +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from unittest import mock + +import pytest + +from game_setup_hub import _vendor_compat + + +@pytest.fixture +def vendor_dir(tmp_path: Path) -> Path: + """Build a fake AppImage-style vendor dir with an ABI-incompatible .so.""" + vd = tmp_path / "vendor" + vd.mkdir() + pkg = vd / "pydantic_core" + pkg.mkdir() + (pkg / "__init__.py").write_text("") + # Tag the .so with an obviously wrong SOABI so the compat check fails. + (pkg / "_pydantic_core.cpython-999-totally-wrong.so").write_text("") + return vd + + +@pytest.fixture +def system_site(tmp_path: Path) -> Path: + sp = tmp_path / "system-site-packages" + sp.mkdir() + return sp + + +def test_no_vendor_dir_is_noop(monkeypatch) -> None: + monkeypatch.setattr(sys, "path", ["/some/random/dir"]) + _vendor_compat.fixup_vendor_path() + assert sys.path == ["/some/random/dir"] + + +def test_compatible_so_leaves_path_alone(tmp_path: Path, monkeypatch) -> None: + vd = tmp_path / "vendor" + vd.mkdir() + pkg = vd / "pydantic_core" + pkg.mkdir() + (pkg / "__init__.py").write_text("") + soabi = _vendor_compat._SOABI or "cpython-312-x86_64-linux-gnu" + monkeypatch.setattr(_vendor_compat, "_SOABI", soabi) + (pkg / f"_pydantic_core.{soabi}.so").write_text("") + + original = [str(vd), "/usr/lib/python3/dist-packages"] + monkeypatch.setattr(sys, "path", list(original)) + _vendor_compat.fixup_vendor_path() + assert sys.path == original, "compat .so must not trigger reordering" + + +def test_incompatible_so_prepends_system_site_packages( + vendor_dir: Path, system_site: Path, monkeypatch +) -> None: + monkeypatch.setattr(_vendor_compat, "_SOABI", "cpython-312-x86_64-linux-gnu") + monkeypatch.setattr(sys, "path", [str(vendor_dir), "/usr/lib/python3.12"]) + monkeypatch.setattr(_vendor_compat, "_system_site_dirs", lambda: [str(system_site)]) + + _vendor_compat.fixup_vendor_path() + + assert sys.path[0] == str(system_site), "system site-packages must win resolution" + assert str(vendor_dir) in sys.path, "vendor dir stays for non-conflicting deps" + + +def test_does_not_touch_filesystem(vendor_dir: Path, system_site: Path, monkeypatch) -> None: + """The whole point of the rewrite: never mutate the vendored dir.""" + monkeypatch.setattr(_vendor_compat, "_SOABI", "cpython-312-x86_64-linux-gnu") + monkeypatch.setattr(sys, "path", [str(vendor_dir)]) + monkeypatch.setattr(_vendor_compat, "_system_site_dirs", lambda: [str(system_site)]) + + pkg_dir = vendor_dir / "pydantic_core" + files_before = sorted(p.name for p in pkg_dir.iterdir()) + + with mock.patch("shutil.rmtree") as m_rmtree: + _vendor_compat.fixup_vendor_path() + + assert m_rmtree.call_count == 0, "must not rmtree on read-only FS" + files_after = sorted(p.name for p in pkg_dir.iterdir()) + assert files_before == files_after + + +def test_no_system_site_packages_is_warning_not_crash( + vendor_dir: Path, monkeypatch, caplog +) -> None: + """Worst case: AppImage on a system without pydantic — log loudly, don't crash.""" + monkeypatch.setattr(_vendor_compat, "_SOABI", "cpython-312-x86_64-linux-gnu") + monkeypatch.setattr(sys, "path", [str(vendor_dir)]) + monkeypatch.setattr(_vendor_compat, "_system_site_dirs", lambda: []) + + with caplog.at_level("WARNING", logger="protonshift.vendor_compat"): + _vendor_compat.fixup_vendor_path() + + assert any("ABI mismatch" in r.message for r in caplog.records)