| null
+ >(null);
+
+ useEffect(() => {
+ const api = window.electron;
+ if (!api) return;
+ queueMicrotask(() => setElectron(api));
+ }, []);
+
+ if (!electron) {
return null;
}
- const { minimizeWindow, toggleMaximize, closeWindow } = window.electron;
+ const { minimizeWindow, toggleMaximize, closeWindow } = electron;
return (
diff --git a/electron/renderer/src/lib/api.ts b/electron/renderer/src/lib/api.ts
index 9eb3a20..d97a485 100644
--- a/electron/renderer/src/lib/api.ts
+++ b/electron/renderer/src/lib/api.ts
@@ -84,9 +84,10 @@ export const api = {
}),
getProtontricksVerbs: () =>
- apiFetch<{ available: boolean; verbs: { id: string; label: string }[] }>(
- "/protontricks/verbs"
- ),
+ apiFetch<{
+ available: boolean;
+ verbs: { id: string; label: string; category?: string }[];
+ }>("/protontricks/verbs"),
getPresets: () => apiFetch("/presets"),
@@ -150,7 +151,61 @@ export const api = {
apiFetch<{ available: boolean }>("/gamescope/available"),
buildGamescopeCmd: (opts: GamescopeOptions) =>
- apiFetch<{ command: string }>("/gamescope/build-cmd", { method: "POST", body: opts }),
+ apiFetch<{ command: string; argv?: string[] }>("/gamescope/build-cmd", { method: "POST", body: opts }),
+
+ getScopeBuddyAvailable: () =>
+ apiFetch("/scopebuddy/available"),
+
+ getScopeBuddyAutoCaps: () =>
+ apiFetch("/scopebuddy/auto-capabilities"),
+
+ getScopeBuddyConfig: () =>
+ apiFetch<{ path: string; exists: boolean; config: Record }>("/scopebuddy/config"),
+
+ setScopeBuddyConfig: (config: Record) =>
+ apiFetch("/scopebuddy/config", { method: "PUT", body: { config } }),
+
+ getScopeBuddyPresets: () =>
+ apiFetch>>("/scopebuddy/presets"),
+
+ listScopeBuddyPerApp: () =>
+ apiFetch<{ key: string; path: string }[]>("/scopebuddy/per-app"),
+
+ getScopeBuddyPerApp: (key: string) =>
+ apiFetch(
+ `/scopebuddy/per-app/${encodeURIComponent(key)}`,
+ ),
+
+ setScopeBuddyPerApp: (key: string, config: Record) =>
+ apiFetch(`/scopebuddy/per-app/${encodeURIComponent(key)}`, {
+ method: "PUT",
+ body: { config },
+ }),
+
+ deleteScopeBuddyPerApp: (key: string) =>
+ apiFetch(`/scopebuddy/per-app/${encodeURIComponent(key)}`, {
+ method: "DELETE",
+ }),
+
+ rescanScopeBuddy: () =>
+ apiFetch("/scopebuddy/rescan", { method: "POST" }),
+
+ listScopeBuddyEnvVars: () =>
+ apiFetch<{ name: string; path: string }[]>("/scopebuddy/envvars"),
+
+ getScopeBuddyEnvVars: (name: string) =>
+ apiFetch(`/scopebuddy/envvars/${encodeURIComponent(name)}`),
+
+ setScopeBuddyEnvVars: (name: string, config: Record) =>
+ apiFetch(`/scopebuddy/envvars/${encodeURIComponent(name)}`, {
+ method: "PUT",
+ body: { config },
+ }),
+
+ deleteScopeBuddyEnvVars: (name: string) =>
+ apiFetch(`/scopebuddy/envvars/${encodeURIComponent(name)}`, {
+ method: "DELETE",
+ }),
getGameFixes: (appId: string) =>
apiFetch(`/games/${appId}/fixes`),
@@ -332,6 +387,8 @@ export interface ControllerData {
controller_type: string;
vendor_id: string;
product_id: string;
+ bus_type?: string;
+ version?: string;
}
export interface MonitorData {
@@ -406,6 +463,43 @@ export interface GamescopeOptions {
borderless: boolean;
fullscreen: boolean;
extra_args: string;
+ wrap_with_scopebuddy: boolean;
+ scb_auto_res: boolean;
+ scb_auto_hdr: boolean;
+ scb_auto_vrr: boolean;
+ scb_auto_refresh: boolean;
+ scb_auto_frame_limit: boolean;
+ scb_noscope: boolean;
+}
+
+export interface ScopeBuddyAvailable {
+ available: boolean;
+ binary: string;
+ path: string;
+ version: string;
+ config_dir: string;
+}
+
+export interface ScopeBuddyAutoCaps {
+ kde: boolean;
+ gnome_gdctl: boolean;
+ gnome_randr: boolean;
+ wlroots: boolean;
+ jq: boolean;
+}
+
+export interface ScopeBuddyPerAppResponse {
+ key: string;
+ path: string;
+ exists: boolean;
+ config: Record;
+}
+
+export interface ScopeBuddyEnvVarsResponse {
+ name: string;
+ path: string;
+ exists: boolean;
+ config: Record;
}
export interface HeroicWineVersionData {
diff --git a/electron/renderer/src/lib/command-palette-events.ts b/electron/renderer/src/lib/command-palette-events.ts
new file mode 100644
index 0000000..4fcc92a
--- /dev/null
+++ b/electron/renderer/src/lib/command-palette-events.ts
@@ -0,0 +1,5 @@
+/** Dispatched by the command palette to focus the games sidebar search field. */
+export const FOCUS_GAME_SEARCH_EVENT = "protonshift:focus-game-search";
+
+/** Dispatched by the navbar (or other UI) to open the command palette. */
+export const OPEN_COMMAND_PALETTE_EVENT = "protonshift:open-command-palette";
diff --git a/electron/renderer/src/types/electron-shell.d.ts b/electron/renderer/src/types/electron-shell.d.ts
index 3031ae3..0755c2d 100644
--- a/electron/renderer/src/types/electron-shell.d.ts
+++ b/electron/renderer/src/types/electron-shell.d.ts
@@ -7,6 +7,8 @@ declare global {
closeWindow: () => Promise;
minimizeWindow: () => Promise;
toggleMaximize: () => Promise;
+ /** Read the packaged Electron app version (== electron/package.json#version). */
+ getVersion: () => Promise;
};
}
}
diff --git a/electron/scripts/fetch-python.sh b/electron/scripts/fetch-python.sh
new file mode 100755
index 0000000..956fa62
--- /dev/null
+++ b/electron/scripts/fetch-python.sh
@@ -0,0 +1,77 @@
+#!/usr/bin/env bash
+# Fetch a portable, fully self-contained CPython runtime for bundling.
+#
+# Source: astral-sh/python-build-standalone — these tarballs are linked
+# against glibc 2.17+, ship a complete stdlib + working pip, and run on
+# essentially every modern Linux distro without touching system Python.
+#
+# Output: ./python-runtime/ (relative to this repo's electron/ dir)
+#
+# Override version pin via env vars:
+# PBS_RELEASE=20260510
+# PBS_PYTHON=3.12.13
+# PBS_SHA256=
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+TARGET="${ROOT}/python-runtime"
+
+# Pinned upstream — bump together. SHA from the release's SHA256SUMS.
+PBS_RELEASE="${PBS_RELEASE:-20260510}"
+PBS_PYTHON="${PBS_PYTHON:-3.12.13}"
+PBS_SHA256="${PBS_SHA256:-d480f5d5878910ecbae212bf23bd7c25d7b209eb8cf5e98823c977384d272e88}"
+
+ARCH="x86_64-unknown-linux-gnu"
+FLAVOR="install_only_stripped"
+ASSET="cpython-${PBS_PYTHON}+${PBS_RELEASE}-${ARCH}-${FLAVOR}.tar.gz"
+URL="https://github.com/astral-sh/python-build-standalone/releases/download/${PBS_RELEASE}/${ASSET//+/%2B}"
+
+VERSION_STAMP="${TARGET}/.protonshift-version"
+WANT_STAMP="${PBS_RELEASE}-${PBS_PYTHON}-${PBS_SHA256}"
+
+if [[ -f "${VERSION_STAMP}" ]] && [[ "$(cat "${VERSION_STAMP}")" == "${WANT_STAMP}" ]]; then
+ echo "python-runtime already at ${PBS_PYTHON}+${PBS_RELEASE}; skipping fetch."
+ exit 0
+fi
+
+TMPDIR="$(mktemp -d)"
+trap 'rm -rf "${TMPDIR}"' EXIT
+TARBALL="${TMPDIR}/${ASSET}"
+
+echo "Downloading ${ASSET}"
+curl -fSL --retry 3 --retry-delay 2 -o "${TARBALL}" "${URL}"
+
+echo "Verifying sha256"
+echo "${PBS_SHA256} ${TARBALL}" | sha256sum -c -
+
+rm -rf "${TARGET}"
+mkdir -p "${TARGET}"
+echo "Extracting into ${TARGET}"
+tar -xzf "${TARBALL}" -C "${TMPDIR}"
+# Tarball top-level is "python/", flatten one level.
+mv "${TMPDIR}/python/"* "${TARGET}/"
+
+# Sanity check: the bundled interpreter must be runnable as-is.
+"${TARGET}/bin/python3" -c "import sys; print('bundled python:', sys.version.split()[0])"
+
+# Trim things we will never use at runtime. Roughly halves the bundle size.
+PY_LIB="${TARGET}/lib/python$(echo "${PBS_PYTHON}" | cut -d. -f1-2)"
+if [[ -d "${PY_LIB}" ]]; then
+ rm -rf "${PY_LIB}/test" "${PY_LIB}/idlelib" "${PY_LIB}/tkinter" \
+ "${PY_LIB}/turtledemo" "${PY_LIB}/ensurepip" 2>/dev/null || true
+ find "${PY_LIB}" -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
+fi
+# Tk / Tcl: Electron renders the UI; nothing in our backend uses them.
+rm -rf "${TARGET}/lib/tcl9"* "${TARGET}/lib/tk9"* "${TARGET}/lib/itcl"* \
+ "${TARGET}/lib/thread"* "${TARGET}/lib/libtcl"* "${TARGET}/lib/libtk"* 2>/dev/null || true
+# terminfo / man pages: not used by a daemon process.
+rm -rf "${TARGET}/share/terminfo" "${TARGET}/share/man" 2>/dev/null || true
+# C headers are only useful for building extensions; we ship prebuilt wheels.
+rm -rf "${TARGET}/include" 2>/dev/null || true
+# Drop config-only python3-config helpers; not used at runtime.
+rm -f "${TARGET}/bin/python3.12-config" "${TARGET}/bin/2to3"* 2>/dev/null || true
+
+echo "${WANT_STAMP}" > "${VERSION_STAMP}"
+
+SIZE="$(du -sh "${TARGET}" | cut -f1)"
+echo "python-runtime ready: ${PBS_PYTHON}+${PBS_RELEASE} (${SIZE})"
diff --git a/electron/scripts/vendor-python-deps.sh b/electron/scripts/vendor-python-deps.sh
index e6b7cc9..66f77a8 100755
--- a/electron/scripts/vendor-python-deps.sh
+++ b/electron/scripts/vendor-python-deps.sh
@@ -1,31 +1,47 @@
#!/usr/bin/env bash
-# Vendor Python runtime dependencies for AppImage/deb/rpm packaging.
+# Vendor Python runtime dependencies into ./python-vendor/ using the
+# bundled CPython interpreter from ./python-runtime/.
#
-# Native extensions (.so) are version-locked to the build Python.
-# The Electron main process appends system site-packages as a fallback
-# so users on a different Python minor version still get native deps
-# from their system packages.
+# Vendoring with the SAME interpreter we ship guarantees that native
+# extensions (.so files, currently only pydantic_core) match the runtime's
+# ABI. That removes the ABI-fallback dance _vendor_compat.py used to do
+# and lets us drop the python3 / python3-pydantic deb/rpm depends.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TARGET="${ROOT}/python-vendor"
REQ="${ROOT}/python-runtime-requirements.txt"
+RUNTIME="${ROOT}/python-runtime"
+PY="${RUNTIME}/bin/python3"
+
+if [[ ! -x "${PY}" ]]; then
+ echo "Bundled python not found at ${PY}." >&2
+ echo "Run scripts/fetch-python.sh first (or 'pnpm run vendor-python')." >&2
+ exit 1
+fi
rm -rf "${TARGET}"
-python3 -m pip install \
+mkdir -p "${TARGET}"
+
+"${PY}" -m pip install \
-r "${REQ}" \
-t "${TARGET}" \
--upgrade \
- --no-cache-dir
+ --no-cache-dir \
+ --disable-pip-version-check
-# Remove unnecessary bloat from vendor dir
+# Trim packaging metadata bloat — keep the bits pip/import need.
find "${TARGET}" -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
find "${TARGET}" -type d -name "*.dist-info" -exec sh -c '
for d; do
- # Keep METADATA and top_level.txt, delete the rest
- find "$d" -maxdepth 1 -type f ! -name METADATA ! -name top_level.txt ! -name RECORD -delete 2>/dev/null
+ find "$d" -maxdepth 1 -type f \
+ ! -name METADATA ! -name top_level.txt ! -name RECORD -delete 2>/dev/null
done
' _ {} + 2>/dev/null || true
-PY_VER="$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')"
+# pip stays in the on-disk runtime so re-running vendor is idempotent.
+# It is excluded from the shipped bundle by electron-builder's `extraResources`
+# filter, which trims pip + dev cruft from the AppImage / deb / rpm payload.
+PY_VER="$("${PY}" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')"
SO_COUNT="$(find "${TARGET}" -name '*.so' | wc -l)"
-echo "Vendored Python deps into ${TARGET} (Python ${PY_VER}, ${SO_COUNT} native extensions)"
+SIZE="$(du -sh "${TARGET}" | cut -f1)"
+echo "Vendored Python deps into ${TARGET} (Python ${PY_VER}, ${SO_COUNT} native ext, ${SIZE})"
diff --git a/scripts/ci/linux-matrix.sh b/scripts/ci/linux-matrix.sh
new file mode 100755
index 0000000..87b2877
--- /dev/null
+++ b/scripts/ci/linux-matrix.sh
@@ -0,0 +1,105 @@
+#!/usr/bin/env bash
+# Run Python lint + tests inside Linux distro containers (Podman or Docker).
+# Use for a quick "does the backend behave on Debian vs musl vs Ubuntu" check.
+#
+# Usage:
+# ./scripts/ci/linux-matrix.sh # all targets
+# LINUX_MATRIX_IMAGE=python:3.12-alpine ./scripts/ci/linux-matrix.sh
+#
+# Override engine: CONTAINER_ENGINE=docker ./scripts/ci/linux-matrix.sh
+
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+ENGINE="${CONTAINER_ENGINE:-}"
+if [[ -z "$ENGINE" ]]; then
+ if command -v podman >/dev/null 2>&1; then
+ ENGINE=podman
+ elif command -v docker >/dev/null 2>&1; then
+ ENGINE=docker
+ else
+ echo "Install podman or docker, or set CONTAINER_ENGINE." >&2
+ exit 1
+ fi
+fi
+
+# label|image (official python images ship pip; same install path everywhere)
+declare -a OFFICIAL_PYTHON=(
+ "debian-12-glibc|python:3.12-slim-bookworm"
+ "alpine-musl|python:3.12-alpine"
+)
+
+run_in_python_image() {
+ local label=$1 image=$2
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo "▶ ${label} (${image})"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ $ENGINE run --pull=missing --rm \
+ -v "${ROOT}:/work:ro" \
+ "${image}" \
+ sh -euc '
+ if command -v apt-get >/dev/null 2>&1; then
+ export DEBIAN_FRONTEND=noninteractive
+ apt-get update -qq && apt-get install -y -qq libatomic1
+ fi
+ rm -rf /tmp/psrc
+ cp -a /work /tmp/psrc
+ cd /tmp/psrc
+ pip install --root-user-action ignore -q -U pip
+ pip install --root-user-action ignore -q -e ".[dev]"
+ ruff check src/
+ if [ -f /etc/alpine-release ]; then
+ echo "Note: skipping pyright on Alpine (Pyright prebuilt Node is glibc-only)."
+ else
+ pyright
+ fi
+ pytest -q
+ '
+}
+
+run_in_ubuntu2404() {
+ local image="ubuntu:24.04"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo "▶ ubuntu-24.04 (${image})"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ $ENGINE run --pull=missing --rm \
+ -e DEBIAN_FRONTEND=noninteractive \
+ -v "${ROOT}:/work:ro" \
+ "${image}" \
+ bash -euo pipefail -c '
+ apt-get update -qq
+ apt-get install -y -qq python3.12 python3.12-venv ca-certificates libatomic1
+ python3.12 -m venv /tmp/venv
+ /tmp/venv/bin/pip install --root-user-action ignore -q -U pip
+ rm -rf /tmp/psrc
+ cp -a /work /tmp/psrc
+ cd /tmp/psrc
+ /tmp/venv/bin/pip install --root-user-action ignore -q -e ".[dev]"
+ export PATH="/tmp/venv/bin:${PATH}"
+ ruff check src/
+ pyright
+ pytest -q
+ '
+}
+
+if [[ -n "${LINUX_MATRIX_IMAGE:-}" ]]; then
+ case "${LINUX_MATRIX_IMAGE}" in
+ ubuntu:24.04)
+ run_in_ubuntu2404
+ ;;
+ *)
+ run_in_python_image "custom" "${LINUX_MATRIX_IMAGE}"
+ ;;
+ esac
+ exit 0
+fi
+
+for entry in "${OFFICIAL_PYTHON[@]}"; do
+ label="${entry%%|*}"
+ image="${entry#*|}"
+ run_in_python_image "${label}" "${image}"
+done
+
+run_in_ubuntu2404
+
+echo "All matrix targets passed."
diff --git a/src/game_setup_hub/__init__.py b/src/game_setup_hub/__init__.py
index 52c81e8..1cd0d18 100644
--- a/src/game_setup_hub/__init__.py
+++ b/src/game_setup_hub/__init__.py
@@ -1,3 +1,3 @@
"""ProtonShift — Linux game configuration toolkit."""
-__version__ = "0.9.0"
+__version__ = "0.9.5"
diff --git a/src/game_setup_hub/_vendor_compat.py b/src/game_setup_hub/_vendor_compat.py
index bdd8435..8f28472 100644
--- a/src/game_setup_hub/_vendor_compat.py
+++ b/src/game_setup_hub/_vendor_compat.py
@@ -102,17 +102,33 @@ def _system_site_dirs() -> list[str]:
return out
+def _running_under_bundled_runtime() -> bool:
+ """True if launched from the python-build-standalone runtime we ship.
+
+ The Electron main process exec's ``/python/runtime/bin/python3``
+ when packaged. In that case, the vendored wheels were installed *with*
+ this exact interpreter, so the ABI always matches and the fallback below
+ is dead weight — short-circuit it.
+ """
+ exe = Path(sys.executable).resolve()
+ return any(part == "runtime" for part in exe.parts) and "python" in exe.parts
+
+
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.
+ On match (the common case, and always the case under our bundled
+ interpreter), this is a no-op. On mismatch — only possible when running
+ under the host Python as a defensive fallback — 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 _running_under_bundled_runtime():
+ return
if not _has_incompatible_native_pkg(vendor_dirs):
return
diff --git a/src/game_setup_hub/api/_models.py b/src/game_setup_hub/api/_models.py
index 2963efc..f84eb2f 100644
--- a/src/game_setup_hub/api/_models.py
+++ b/src/game_setup_hub/api/_models.py
@@ -189,6 +189,17 @@ class GamescopeBuildRequest(BaseModel):
borderless: bool = True
fullscreen: bool = True
extra_args: str = ""
+ wrap_with_scopebuddy: bool = False
+ scb_auto_res: bool = False
+ scb_auto_hdr: bool = False
+ scb_auto_vrr: bool = False
+ scb_auto_refresh: bool = False
+ scb_auto_frame_limit: bool = False
+ scb_noscope: bool = False
+
+
+class ScopeBuddyConfigRequest(BaseModel):
+ config: dict[str, str]
class ShaderCacheResponse(BaseModel):
@@ -254,3 +265,11 @@ class HeroicTogglesRequest(BaseModel):
class StatusResponse(BaseModel):
success: bool
message: str = ""
+
+
+class ScopeBuddyPerAppRequest(BaseModel):
+ config: dict[str, str]
+
+
+class ScopeBuddyEnvVarsRequest(BaseModel):
+ config: dict[str, str]
diff --git a/src/game_setup_hub/api/_state.py b/src/game_setup_hub/api/_state.py
index 9ce8cf8..184fb61 100644
--- a/src/game_setup_hub/api/_state.py
+++ b/src/game_setup_hub/api/_state.py
@@ -35,6 +35,7 @@
env_lock = asyncio.Lock()
profiles_lock = asyncio.Lock()
mangohud_lock = asyncio.Lock()
+scopebuddy_lock = asyncio.Lock()
heroic_lock = asyncio.Lock()
# Cached Steam discovery (resolved once per process).
diff --git a/src/game_setup_hub/api/routes/__init__.py b/src/game_setup_hub/api/routes/__init__.py
index 2995703..6d226b3 100644
--- a/src/game_setup_hub/api/routes/__init__.py
+++ b/src/game_setup_hub/api/routes/__init__.py
@@ -4,7 +4,7 @@
from fastapi import APIRouter
-from . import games, health, heroic, mangohud, profiles, saves, system, utility
+from . import games, health, heroic, mangohud, profiles, saves, scopebuddy, system, utility
all_routers: list[APIRouter] = [
health.router,
@@ -12,6 +12,7 @@
system.router,
saves.router,
mangohud.router,
+ scopebuddy.router,
heroic.router,
profiles.router,
utility.router,
diff --git a/src/game_setup_hub/api/routes/scopebuddy.py b/src/game_setup_hub/api/routes/scopebuddy.py
new file mode 100644
index 0000000..7accdd9
--- /dev/null
+++ b/src/game_setup_hub/api/routes/scopebuddy.py
@@ -0,0 +1,155 @@
+"""ScopeBuddy availability, auto-detect capabilities, global and per-app config."""
+
+from __future__ import annotations
+
+from typing import Any
+from urllib.parse import unquote
+
+from fastapi import APIRouter, HTTPException
+
+from ...scopebuddy import (
+ SCOPEBUDDY_GLOBAL_CONF,
+ SCOPEBUDDY_PRESETS,
+ delete_envvars,
+ delete_per_app_config,
+ detect_auto_capabilities,
+ list_envvars,
+ list_per_app_overrides,
+ read_envvars,
+ read_global_config,
+ read_per_app_config,
+ rescan_tools,
+ scopebuddy_available_info,
+ write_envvars,
+ write_global_config,
+ write_per_app_config,
+)
+from .. import _state
+from .._models import (
+ ScopeBuddyConfigRequest,
+ ScopeBuddyEnvVarsRequest,
+ ScopeBuddyPerAppRequest,
+ StatusResponse,
+)
+
+router = APIRouter(prefix="/scopebuddy")
+
+
+def _decode_key(key: str) -> str:
+ return unquote(key, errors="replace").strip()
+
+
+@router.get("/available")
+async def scopebuddy_available() -> dict[str, Any]:
+ return scopebuddy_available_info()
+
+
+@router.get("/auto-capabilities")
+async def scopebuddy_auto_caps() -> dict[str, bool]:
+ return detect_auto_capabilities()
+
+
+@router.get("/config")
+async def get_scopebuddy_global_config() -> dict[str, Any]:
+ return {
+ "path": str(SCOPEBUDDY_GLOBAL_CONF),
+ "exists": SCOPEBUDDY_GLOBAL_CONF.is_file(),
+ "config": read_global_config(),
+ }
+
+
+@router.put("/config")
+async def put_scopebuddy_global_config(body: ScopeBuddyConfigRequest) -> StatusResponse:
+ async with _state.scopebuddy_lock:
+ ok = write_global_config(body.config)
+ if not ok:
+ raise HTTPException(status_code=500, detail="Failed to write ScopeBuddy global config")
+ return StatusResponse(success=True)
+
+
+@router.get("/presets")
+async def get_scopebuddy_presets() -> dict[str, dict[str, str]]:
+ return SCOPEBUDDY_PRESETS
+
+
+@router.get("/per-app")
+async def list_scopebuddy_per_app() -> list[dict[str, str]]:
+ return list_per_app_overrides()
+
+
+@router.get("/per-app/{key}")
+async def get_scopebuddy_per_app(key: str) -> dict[str, Any]:
+ app_key = _decode_key(key)
+ if not app_key:
+ raise HTTPException(status_code=400, detail="Empty app key")
+ path, exists, cfg = read_per_app_config(app_key)
+ return {"key": app_key, "path": str(path), "exists": exists, "config": cfg}
+
+
+@router.put("/per-app/{key}")
+async def put_scopebuddy_per_app(key: str, body: ScopeBuddyPerAppRequest) -> StatusResponse:
+ app_key = _decode_key(key)
+ if not app_key:
+ raise HTTPException(status_code=400, detail="Empty app key")
+ async with _state.scopebuddy_lock:
+ ok = write_per_app_config(app_key, body.config)
+ if not ok:
+ raise HTTPException(status_code=500, detail="Failed to write per-app ScopeBuddy config")
+ return StatusResponse(success=True)
+
+
+@router.delete("/per-app/{key}")
+async def delete_scopebuddy_per_app(key: str) -> StatusResponse:
+ app_key = _decode_key(key)
+ if not app_key:
+ raise HTTPException(status_code=400, detail="Empty app key")
+ async with _state.scopebuddy_lock:
+ ok = delete_per_app_config(app_key)
+ if not ok:
+ raise HTTPException(status_code=500, detail="Failed to delete per-app ScopeBuddy config")
+ return StatusResponse(success=True, message="Override removed")
+
+
+@router.post("/rescan")
+async def scopebuddy_rescan() -> dict[str, Any]:
+ """Re-detect ``scb`` / ``scopebuddy`` after the user installs it without restarting."""
+ async with _state.scopebuddy_lock:
+ return rescan_tools()
+
+
+@router.get("/envvars")
+async def list_scopebuddy_envvars() -> list[dict[str, str]]:
+ return list_envvars()
+
+
+@router.get("/envvars/{name}")
+async def get_scopebuddy_envvars(name: str) -> dict[str, Any]:
+ decoded = _decode_key(name)
+ if not decoded:
+ raise HTTPException(status_code=400, detail="Empty env profile name")
+ path, exists, cfg = read_envvars(decoded)
+ return {"name": decoded, "path": str(path), "exists": exists, "config": cfg}
+
+
+@router.put("/envvars/{name}")
+async def put_scopebuddy_envvars(name: str, body: ScopeBuddyEnvVarsRequest) -> StatusResponse:
+ decoded = _decode_key(name)
+ if not decoded:
+ raise HTTPException(status_code=400, detail="Empty env profile name")
+ async with _state.scopebuddy_lock:
+ ok = write_envvars(decoded, body.config)
+ if not ok:
+ raise HTTPException(status_code=500, detail="Failed to write envvars file")
+ return StatusResponse(success=True)
+
+
+@router.delete("/envvars/{name}")
+async def delete_scopebuddy_envvars(name: str) -> StatusResponse:
+ decoded = _decode_key(name)
+ if not decoded:
+ raise HTTPException(status_code=400, detail="Empty env profile name")
+ async with _state.scopebuddy_lock:
+ ok = delete_envvars(decoded)
+ if not ok:
+ raise HTTPException(status_code=500, detail="Failed to delete envvars file")
+ return StatusResponse(success=True, message="Envvars file removed")
diff --git a/src/game_setup_hub/api/routes/utility.py b/src/game_setup_hub/api/routes/utility.py
index e3558e0..4a66ac0 100644
--- a/src/game_setup_hub/api/routes/utility.py
+++ b/src/game_setup_hub/api/routes/utility.py
@@ -17,7 +17,11 @@
)
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 ...protontricks import (
+ COMMON_VERBS_GROUPED,
+ is_protontricks_available,
+ run_protontricks,
+)
from ...shader_cache import (
clear_shader_cache,
get_shader_cache_info,
@@ -57,7 +61,11 @@ async def trigger_protontricks(app_id: str, body: ProtontricksRequest) -> Status
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],
+ "verbs": [
+ {"id": verb_id, "label": label, "category": category}
+ for category, verbs in COMMON_VERBS_GROUPED
+ for verb_id, label in verbs
+ ],
}
@@ -85,6 +93,13 @@ async def gamescope_build(body: GamescopeBuildRequest) -> dict[str, Any]:
borderless=body.borderless,
fullscreen=body.fullscreen,
extra_args=body.extra_args,
+ wrap_with_scopebuddy=body.wrap_with_scopebuddy,
+ scb_auto_res=body.scb_auto_res,
+ scb_auto_hdr=body.scb_auto_hdr,
+ scb_auto_vrr=body.scb_auto_vrr,
+ scb_auto_refresh=body.scb_auto_refresh,
+ scb_auto_frame_limit=body.scb_auto_frame_limit,
+ scb_noscope=body.scb_noscope,
)
return {
"command": build_gamescope_cmd(opts),
diff --git a/src/game_setup_hub/gamescope.py b/src/game_setup_hub/gamescope.py
index dbf32c1..af03fea 100644
--- a/src/game_setup_hub/gamescope.py
+++ b/src/game_setup_hub/gamescope.py
@@ -5,7 +5,7 @@
import shlex
from dataclasses import dataclass
-from game_setup_hub.tool_check import is_tool_available
+from game_setup_hub.tool_check import find_scopebuddy, is_tool_available
@dataclass
@@ -23,6 +23,13 @@ class GamescopeOptions:
borderless: bool = True
fullscreen: bool = True
extra_args: str = ""
+ wrap_with_scopebuddy: bool = False
+ scb_auto_res: bool = False
+ scb_auto_hdr: bool = False
+ scb_auto_vrr: bool = False
+ scb_auto_refresh: bool = False
+ scb_auto_frame_limit: bool = False
+ scb_noscope: bool = False
def is_gamescope_available() -> bool:
@@ -30,13 +37,44 @@ def is_gamescope_available() -> bool:
return is_tool_available("gamescope")
+def build_scopebuddy_wrap_cmd(opts: GamescopeOptions) -> str:
+ """Shell prefix: ``SCB_AUTO_*=1 … scb --`` for Steam-style launch options."""
+ bits: list[str] = []
+ if opts.scb_auto_res:
+ bits.append("SCB_AUTO_RES=1")
+ if opts.scb_auto_hdr:
+ bits.append("SCB_AUTO_HDR=1")
+ if opts.scb_auto_vrr:
+ bits.append("SCB_AUTO_VRR=1")
+ if opts.scb_auto_refresh:
+ bits.append("SCB_AUTO_REFRESH=1")
+ if opts.scb_auto_frame_limit:
+ bits.append("SCB_AUTO_FRAME_LIMIT=1")
+ if opts.scb_noscope:
+ bits.append("SCB_NOSCOPE=1")
+ found = find_scopebuddy()
+ invoke = found[0] if found else "scb"
+ bits.append(invoke)
+ bits.append("--")
+ return " ".join(bits)
+
+
def build_gamescope_argv(opts: GamescopeOptions) -> list[str]:
"""Build a gamescope argv list from options.
+ When ``wrap_with_scopebuddy`` is true, returns a token list suitable for
+ display only (prepend env assignments are not a single POSIX argv).
+
`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.
"""
+ if opts.wrap_with_scopebuddy:
+ try:
+ return shlex.split(build_scopebuddy_wrap_cmd(opts), posix=True)
+ except ValueError:
+ return ["scb", "--"]
+
parts: list[str] = ["gamescope"]
if opts.output_width > 0 and opts.output_height > 0:
@@ -76,9 +114,11 @@ def build_gamescope_argv(opts: GamescopeOptions) -> list[str]:
def build_gamescope_cmd(opts: GamescopeOptions) -> str:
- """Build a shell-safe gamescope command string from options.
+ """Build a shell-safe prefix for launch options.
- Returned value is suitable for copy/paste into a launch options field.
- Use :func:`build_gamescope_argv` when actually exec-ing the command.
+ Either a raw ``gamescope … --`` line or ``SCB_*=1 … scb --`` when wrapping
+ with ScopeBuddy. Suitable for copy/paste into Steam launch options.
"""
+ if opts.wrap_with_scopebuddy:
+ return build_scopebuddy_wrap_cmd(opts)
return shlex.join(build_gamescope_argv(opts))
diff --git a/src/game_setup_hub/presets.py b/src/game_setup_hub/presets.py
index 93bb556..b849bf2 100644
--- a/src/game_setup_hub/presets.py
+++ b/src/game_setup_hub/presets.py
@@ -4,7 +4,7 @@
from dataclasses import dataclass
-from game_setup_hub.tool_check import is_tool_available
+from game_setup_hub.tool_check import is_scopebuddy_available, is_tool_available
@dataclass
@@ -23,6 +23,8 @@ def is_installed(self) -> bool:
return is_tool_available("gamemoderun")
if "MANGOHUD" in self.value:
return is_tool_available("mangohud")
+ if self.name == "ScopeBuddy" or " scb --" in self.value or " scopebuddy --" in self.value:
+ return is_scopebuddy_available()
return True
@@ -51,6 +53,13 @@ def is_installed(self) -> bool:
value="PROTON_LOG=1",
description="Write Proton debug log to /tmp/proton_*.log. Useful for troubleshooting.",
),
+ LaunchPreset(
+ name="ScopeBuddy",
+ value="SCB_AUTO_RES=1 SCB_AUTO_HDR=1 SCB_AUTO_VRR=1 scb --",
+ description="Wrap with ScopeBuddy: auto resolution, HDR, and VRR from the primary display.",
+ install_command="curl -fsSL https://raw.githubusercontent.com/HikariKnight/ScopeBuddy/refs/heads/main/bin/scopebuddy | sudo tee /usr/local/bin/scopebuddy && sudo chmod +x /usr/local/bin/scopebuddy",
+ install_url="https://github.com/OpenGamingCollective/ScopeBuddy",
+ ),
]
LAUNCH_PRESETS_MAP: dict[str, str] = {p.name: p.value for p in LAUNCH_PRESETS}
diff --git a/src/game_setup_hub/protontricks.py b/src/game_setup_hub/protontricks.py
index cb499e4..2e97797 100644
--- a/src/game_setup_hub/protontricks.py
+++ b/src/game_setup_hub/protontricks.py
@@ -9,13 +9,83 @@
PROTONTRICKS_FLATPAK = "com.github.Matoking.protontricks"
-# Common Winetricks verbs users might want
-COMMON_VERBS = [
- ("vcrun2022", "Visual C++ 2022 Redistributable"),
- ("dotnet48", ".NET Framework 4.8"),
- ("d3dx9", "DirectX 9 (d3dx9)"),
- ("corefonts", "Core fonts"),
- ("arial", "Arial font"),
+# Common Winetricks verbs users might want, grouped by category. The grouping
+# is exposed by the API so the UI can render labelled sections instead of one
+# long flat list. Each entry is ``(verb_id, human_label)``; categories appear
+# in the order defined here.
+COMMON_VERBS_GROUPED: list[tuple[str, list[tuple[str, str]]]] = [
+ (
+ "Visual C++ Runtime",
+ [
+ ("vcrun2022", "Visual C++ 2022 Redistributable"),
+ ("vcrun2019", "Visual C++ 2019 Redistributable"),
+ ("vcrun2017", "Visual C++ 2017 Redistributable"),
+ ("vcrun2015", "Visual C++ 2015 Redistributable"),
+ ("vcrun2013", "Visual C++ 2013 Redistributable"),
+ ("vcrun2010", "Visual C++ 2010 Redistributable"),
+ ("vcrun2008", "Visual C++ 2008 Redistributable"),
+ ("vcrun2005", "Visual C++ 2005 Redistributable"),
+ ],
+ ),
+ (
+ ".NET Framework",
+ [
+ ("dotnet48", ".NET Framework 4.8"),
+ ("dotnet472", ".NET Framework 4.7.2"),
+ ("dotnet462", ".NET Framework 4.6.2"),
+ ("dotnet40", ".NET Framework 4.0"),
+ ("dotnet35sp1", ".NET Framework 3.5 SP1"),
+ ],
+ ),
+ (
+ ".NET Core",
+ [
+ ("dotnetdesktop6", ".NET Desktop Runtime 6"),
+ ],
+ ),
+ (
+ "DirectX",
+ [
+ ("d3dx9", "DirectX 9 (d3dx9)"),
+ ("d3dx9_43", "DirectX 9 (d3dx9_43)"),
+ ("d3dx10", "DirectX 10 (d3dx10)"),
+ ("d3dx11_43", "DirectX 11 (d3dx11_43)"),
+ ("d3dcompiler_43", "D3D Compiler 43"),
+ ("d3dcompiler_47", "D3D Compiler 47"),
+ ],
+ ),
+ (
+ "Media & codecs",
+ [
+ ("wmp11", "Windows Media Player 11"),
+ ("quartz", "DirectShow (quartz)"),
+ ("xact", "XACT engine (audio)"),
+ ("xna40", "XNA Framework 4.0"),
+ ],
+ ),
+ (
+ "Fonts",
+ [
+ ("corefonts", "Microsoft core fonts"),
+ ("arial", "Arial font"),
+ ("tahoma", "Tahoma font"),
+ ("cjkfonts", "CJK (Chinese/Japanese/Korean) fonts"),
+ ],
+ ),
+ (
+ "Other",
+ [
+ ("physx", "NVIDIA PhysX runtime"),
+ ("vb6run", "Visual Basic 6 runtime"),
+ ("gdiplus", "GDI+ (gdiplus)"),
+ ("mfc42", "MFC 4.2 runtime"),
+ ],
+ ),
+]
+
+# Flat list kept for backwards compatibility with any existing import sites.
+COMMON_VERBS: list[tuple[str, str]] = [
+ verb for _category, verbs in COMMON_VERBS_GROUPED for verb in verbs
]
# Steam app IDs are decimal integers. Anything else is rejected so a malicious
diff --git a/src/game_setup_hub/scopebuddy.py b/src/game_setup_hub/scopebuddy.py
new file mode 100644
index 0000000..e246840
--- /dev/null
+++ b/src/game_setup_hub/scopebuddy.py
@@ -0,0 +1,285 @@
+"""ScopeBuddy config paths, parsing, auto-detect capabilities, and presets."""
+
+from __future__ import annotations
+
+import os
+import re
+import shlex
+import subprocess
+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 find_scopebuddy, find_tool, is_tool_available
+
+SCOPEBUDDY_CONFIG_DIR = Path.home() / ".config" / "scopebuddy"
+SCOPEBUDDY_GLOBAL_CONF = SCOPEBUDDY_CONFIG_DIR / "scb.conf"
+SCOPEBUDDY_APPID_DIR = SCOPEBUDDY_CONFIG_DIR / "AppID"
+SCOPEBUDDY_ENVVARS_DIR = SCOPEBUDDY_CONFIG_DIR / "envvars"
+
+SCB_KNOWN_KEYS: tuple[str, ...] = (
+ "SCB_GAMESCOPE_ARGS",
+ "SCB_AUTO_RES",
+ "SCB_AUTO_HDR",
+ "SCB_AUTO_VRR",
+ "SCB_AUTO_REFRESH",
+ "SCB_AUTO_FRAME_LIMIT",
+ "SCB_NOSCOPE",
+ "SCB_NESTEDFIX",
+ "SCB_APPENDMODE",
+ "SCB_DEBUG",
+)
+
+SCOPEBUDDY_PRESETS: dict[str, dict[str, str]] = {
+ "4K HDR VRR": {
+ "SCB_GAMESCOPE_ARGS": "-W 3840 -H 2160 -w 3840 -h 2160 -f -b",
+ "SCB_AUTO_RES": "1",
+ "SCB_AUTO_HDR": "1",
+ "SCB_AUTO_VRR": "1",
+ },
+ "1080p capped 60": {
+ "SCB_GAMESCOPE_ARGS": "-W 1920 -H 1080 -w 1920 -h 1080 -f -b -r 60",
+ "SCB_AUTO_RES": "0",
+ },
+ "Mangoapp default": {
+ "SCB_GAMESCOPE_ARGS": "--mangoapp -f -b",
+ "SCB_AUTO_RES": "1",
+ },
+}
+
+
+def _strip_inline_comment_unquoted(value: str) -> str:
+ """Remove ``# ...`` only when ``#`` is not inside simple quotes."""
+ in_single = in_double = False
+ for i, ch in enumerate(value):
+ if ch == "'" and not in_double:
+ in_single = not in_single
+ elif ch == '"' and not in_single:
+ in_double = not in_double
+ elif ch == "#" and not in_single and not in_double:
+ return value[:i].rstrip()
+ return value.rstrip()
+
+
+def parse_scb_conf(path: Path) -> dict[str, str]:
+ """Parse a bash-style ``scb.conf`` into key/value pairs.
+
+ Lines like ``export KEY=val``, ``KEY="quoted"``, toggles ``KEY=1``.
+ """
+ if not path.exists():
+ return {}
+ try:
+ text = path.read_text(encoding="utf-8")
+ except OSError:
+ return {}
+
+ out: dict[str, str] = {}
+ for raw_line in text.splitlines():
+ line = raw_line.strip()
+ if not line or line.startswith("#"):
+ continue
+ if line.startswith("export "):
+ line = line[7:].strip()
+ if "=" not in line:
+ continue
+ key, _, rest = line.partition("=")
+ key = key.strip()
+ if not key:
+ continue
+ val = rest.strip()
+ val = _strip_inline_comment_unquoted(val)
+ if len(val) >= 2 and val[0] == val[-1] and val[0] in ("'", '"'):
+ inner = val[1:-1]
+ out[key] = inner
+ else:
+ try:
+ toks = shlex.split(val, posix=True)
+ out[key] = " ".join(toks) if len(toks) > 1 else (toks[0] if toks else "")
+ except ValueError:
+ out[key] = val
+ return out
+
+
+def write_scb_conf(path: Path, cfg: dict[str, str]) -> bool:
+ """Write ``cfg`` as bash ``KEY=value`` lines (quoted with :func:`shlex.quote`)."""
+ lines: list[str] = []
+ for key, value in cfg.items():
+ key = key.strip()
+ if not key:
+ continue
+ if value == "":
+ lines.append(f"{key}=")
+ else:
+ lines.append(f"{key}={shlex.quote(value)}")
+ body = "\n".join(lines) + ("\n" if lines else "")
+ try:
+ atomic_write_text(path, body)
+ return True
+ except OSError:
+ return False
+
+
+def read_scopebuddy_version(binary_path: str) -> str:
+ """Best-effort version from the ScopeBuddy script (``SCB_VER=``)."""
+ try:
+ p = Path(binary_path)
+ if not p.is_file():
+ return ""
+ head = p.read_text(encoding="utf-8", errors="ignore")[:12_000]
+ m = re.search(r"SCB_VER=[\"']?([0-9.]+)[\"']?", head)
+ if m:
+ return m.group(1)
+ except OSError:
+ pass
+ return ""
+
+
+def scopebuddy_available_info() -> dict[str, str | bool]:
+ """Return availability, invoke name, path, version, and config dir."""
+ found = find_scopebuddy()
+ if not found:
+ return {
+ "available": False,
+ "binary": "",
+ "path": "",
+ "version": "",
+ "config_dir": str(SCOPEBUDDY_CONFIG_DIR),
+ }
+ invoke, path = found
+ return {
+ "available": True,
+ "binary": invoke,
+ "path": path,
+ "version": read_scopebuddy_version(path),
+ "config_dir": str(SCOPEBUDDY_CONFIG_DIR),
+ }
+
+
+def _gdctl_supports_json(gdctl: str) -> bool:
+ try:
+ r = subprocess.run(
+ [gdctl, "show", "--format=json"],
+ capture_output=True,
+ text=True,
+ timeout=3,
+ )
+ return r.returncode == 0 and bool((r.stdout or "").strip())
+ except (OSError, subprocess.TimeoutExpired, FileNotFoundError):
+ return False
+
+
+def detect_auto_capabilities() -> dict[str, bool]:
+ """Which ``SCB_AUTO_*`` backends are likely usable on this session."""
+ jq = is_tool_available("jq")
+ kde = bool(os.environ.get("KDE_FULL_SESSION")) and bool(find_tool("kscreen-doctor")) and jq
+
+ is_gnome_session = os.environ.get("XDG_CURRENT_DESKTOP", "").upper().find("GNOME") >= 0 or (
+ (os.environ.get("DESKTOP_SESSION") or "").lower() == "gnome"
+ )
+ gdctl_path = find_tool("gdctl")
+ gnome_gdctl = bool(
+ is_gnome_session and jq and gdctl_path is not None and _gdctl_supports_json(gdctl_path)
+ )
+
+ gnome_randr = bool(find_tool("gnome-randr"))
+ wlroots = bool(find_tool("wlr-randr")) and jq
+
+ return {
+ "kde": kde,
+ "gnome_gdctl": gnome_gdctl,
+ "gnome_randr": gnome_randr,
+ "wlroots": wlroots,
+ "jq": jq,
+ }
+
+
+def per_app_conf_path(app_key: str) -> Path:
+ """Path to ``AppID/.conf`` for a Steam app id or Heroic/Lutris slug."""
+ safe = sanitize_filename(app_key.replace(":", "_").replace("/", "_"), fallback="game")
+ return SCOPEBUDDY_APPID_DIR / f"{safe}.conf"
+
+
+def list_per_app_overrides() -> list[dict[str, str]]:
+ """List existing per-app override files under ``AppID/``."""
+ if not SCOPEBUDDY_APPID_DIR.is_dir():
+ return []
+ rows: list[dict[str, str]] = []
+ for p in sorted(SCOPEBUDDY_APPID_DIR.glob("*.conf")):
+ stem = p.stem
+ rows.append({"key": stem, "path": str(p)})
+ return rows
+
+
+def read_global_config() -> dict[str, str]:
+ return parse_scb_conf(SCOPEBUDDY_GLOBAL_CONF)
+
+
+def write_global_config(cfg: dict[str, str]) -> bool:
+ SCOPEBUDDY_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
+ return write_scb_conf(SCOPEBUDDY_GLOBAL_CONF, cfg)
+
+
+def read_per_app_config(app_key: str) -> tuple[Path, bool, dict[str, str]]:
+ path = per_app_conf_path(app_key)
+ exists = path.is_file()
+ return path, exists, parse_scb_conf(path) if exists else {}
+
+
+def write_per_app_config(app_key: str, cfg: dict[str, str]) -> bool:
+ path = per_app_conf_path(app_key)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ return write_scb_conf(path, cfg)
+
+
+def delete_per_app_config(app_key: str) -> bool:
+ path = per_app_conf_path(app_key)
+ try:
+ path.unlink(missing_ok=True)
+ return True
+ except OSError:
+ return False
+
+
+def envvars_path(name: str) -> Path:
+ """Path to ``envvars/.conf`` for a named env-var snippet."""
+ safe = sanitize_filename(name.replace(":", "_").replace("/", "_"), fallback="profile")
+ return SCOPEBUDDY_ENVVARS_DIR / f"{safe}.conf"
+
+
+def list_envvars() -> list[dict[str, str]]:
+ """List existing envvars snippet files under ``envvars/``."""
+ if not SCOPEBUDDY_ENVVARS_DIR.is_dir():
+ return []
+ rows: list[dict[str, str]] = []
+ for p in sorted(SCOPEBUDDY_ENVVARS_DIR.glob("*.conf")):
+ rows.append({"name": p.stem, "path": str(p)})
+ return rows
+
+
+def read_envvars(name: str) -> tuple[Path, bool, dict[str, str]]:
+ path = envvars_path(name)
+ exists = path.is_file()
+ return path, exists, parse_scb_conf(path) if exists else {}
+
+
+def write_envvars(name: str, cfg: dict[str, str]) -> bool:
+ path = envvars_path(name)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ return write_scb_conf(path, cfg)
+
+
+def delete_envvars(name: str) -> bool:
+ path = envvars_path(name)
+ try:
+ path.unlink(missing_ok=True)
+ return True
+ except OSError:
+ return False
+
+
+def rescan_tools() -> dict[str, str | bool]:
+ """Force re-detection of ``scb`` / ``scopebuddy`` (clears tool_check cache)."""
+ from game_setup_hub.tool_check import find_tool as _ft
+
+ _ft.cache_clear()
+ return scopebuddy_available_info()
diff --git a/src/game_setup_hub/steam.py b/src/game_setup_hub/steam.py
index 7302685..a7b854d 100644
--- a/src/game_setup_hub/steam.py
+++ b/src/game_setup_hub/steam.py
@@ -12,7 +12,15 @@
def is_steam_running() -> bool:
- """Check if Steam client is running. Edit localconfig.vdf with Steam closed."""
+ """True if a process named ``steam`` exists (``pgrep -x steam``).
+
+ This is **not** foreground detection: Steam minimized to the tray, only a
+ web helper showing, or the main window closed while the client stays
+ resident still counts as running. ``ps``/``grep steam`` will also list many
+ related PIDs (``steamwebhelper``, ``reaper``, etc.); we only match the main
+ client binary name ``steam``. Edit ``localconfig.vdf`` with the client
+ fully quit (Exit from tray if needed).
+ """
try:
r = subprocess.run(["pgrep", "-x", "steam"], capture_output=True, timeout=2)
return r.returncode == 0
diff --git a/src/game_setup_hub/tool_check.py b/src/game_setup_hub/tool_check.py
index 746469f..ed9006a 100644
--- a/src/game_setup_hub/tool_check.py
+++ b/src/game_setup_hub/tool_check.py
@@ -59,6 +59,16 @@
"/usr/bin/wlr-randr",
"/usr/local/bin/wlr-randr",
),
+ "scopebuddy": (
+ "/usr/bin/scopebuddy",
+ "/usr/local/bin/scopebuddy",
+ os.path.expanduser("~/.local/bin/scopebuddy"),
+ ),
+ "scb": (
+ "/usr/bin/scb",
+ "/usr/local/bin/scb",
+ os.path.expanduser("~/.local/bin/scb"),
+ ),
}
@@ -91,3 +101,22 @@ def find_tool(name: str) -> str | None:
def is_tool_available(name: str) -> bool:
"""Return True if *name* can be found anywhere on the system."""
return find_tool(name) is not None
+
+
+def find_scopebuddy() -> tuple[str, str] | None:
+ """Return ``(invoke_name, absolute_path)`` for ScopeBuddy.
+
+ Prefers the short ``scb`` binary when present, else ``scopebuddy``.
+ """
+ scb = find_tool("scb")
+ if scb:
+ return ("scb", scb)
+ scopebuddy = find_tool("scopebuddy")
+ if scopebuddy:
+ return ("scopebuddy", scopebuddy)
+ return None
+
+
+def is_scopebuddy_available() -> bool:
+ """Return True if ``scb`` or ``scopebuddy`` is on the system."""
+ return find_scopebuddy() is not None
diff --git a/tests/test_gamescope.py b/tests/test_gamescope.py
index 578a89c..1fc31f1 100644
--- a/tests/test_gamescope.py
+++ b/tests/test_gamescope.py
@@ -8,6 +8,7 @@
GamescopeOptions,
build_gamescope_argv,
build_gamescope_cmd,
+ build_scopebuddy_wrap_cmd,
)
@@ -46,3 +47,27 @@ def test_fsr_sharpness_clamped() -> None:
argv = build_gamescope_argv(opts)
sharpness_idx = argv.index("--fsr-sharpness")
assert argv[sharpness_idx + 1] == "20"
+
+
+def test_scopebuddy_wrap_cmd_env_order() -> None:
+ opts = GamescopeOptions(
+ wrap_with_scopebuddy=True,
+ scb_auto_res=True,
+ scb_auto_hdr=True,
+ scb_noscope=True,
+ )
+ cmd = build_scopebuddy_wrap_cmd(opts)
+ assert "SCB_AUTO_RES=1" in cmd
+ assert "SCB_AUTO_HDR=1" in cmd
+ assert "SCB_NOSCOPE=1" in cmd
+ assert cmd.endswith(" scb --") or cmd.endswith(" scopebuddy --")
+ parts = shlex.split(cmd)
+ assert parts[-1] == "--"
+ assert parts[-2] in ("scb", "scopebuddy")
+
+
+def test_wrap_mode_argv_is_tokenized() -> None:
+ opts = GamescopeOptions(wrap_with_scopebuddy=True, scb_auto_vrr=True)
+ argv = build_gamescope_argv(opts)
+ assert "SCB_AUTO_VRR=1" in argv
+ assert argv[-1] == "--"
diff --git a/tests/test_scopebuddy.py b/tests/test_scopebuddy.py
new file mode 100644
index 0000000..f2f4eee
--- /dev/null
+++ b/tests/test_scopebuddy.py
@@ -0,0 +1,104 @@
+"""ScopeBuddy config parsing, envvars helpers, and rescan."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import game_setup_hub.scopebuddy as scb_mod
+from game_setup_hub.scopebuddy import (
+ delete_envvars,
+ list_envvars,
+ parse_scb_conf,
+ read_envvars,
+ rescan_tools,
+ write_envvars,
+ write_scb_conf,
+)
+
+
+def test_parse_scb_conf_basic(tmp_path: Path) -> None:
+ p = tmp_path / "scb.conf"
+ p.write_text(
+ 'export SCB_GAMESCOPE_ARGS="-f -b"\n'
+ "SCB_AUTO_RES=1\n"
+ "# comment\n"
+ "SCB_NOSCOPE=1\n",
+ encoding="utf-8",
+ )
+ cfg = parse_scb_conf(p)
+ assert cfg["SCB_GAMESCOPE_ARGS"] == "-f -b"
+ assert cfg["SCB_AUTO_RES"] == "1"
+ assert cfg["SCB_NOSCOPE"] == "1"
+
+
+def test_write_scb_conf_roundtrip(tmp_path: Path) -> None:
+ p = tmp_path / "out.conf"
+ ok = write_scb_conf(
+ p,
+ {"SCB_GAMESCOPE_ARGS": "--mangoapp -f", "SCB_AUTO_RES": "1"},
+ )
+ assert ok is True
+ cfg = parse_scb_conf(p)
+ assert cfg["SCB_GAMESCOPE_ARGS"] == "--mangoapp -f"
+ assert cfg["SCB_AUTO_RES"] == "1"
+
+
+def test_envvars_roundtrip(tmp_path, monkeypatch) -> None:
+ """``write_envvars`` should land under ``envvars/`` and round-trip."""
+ monkeypatch.setattr(scb_mod, "SCOPEBUDDY_CONFIG_DIR", tmp_path)
+ monkeypatch.setattr(scb_mod, "SCOPEBUDDY_ENVVARS_DIR", tmp_path / "envvars")
+
+ assert list_envvars() == []
+
+ ok = write_envvars(
+ "nvidia-wayland",
+ {"VK_DRIVER_FILES": "/usr/share/vulkan/icd.d/nvidia.json", "DXVK_HUD": "fps"},
+ )
+ assert ok is True
+
+ rows = list_envvars()
+ assert any(r["name"] == "nvidia-wayland" for r in rows)
+
+ path, exists, cfg = read_envvars("nvidia-wayland")
+ assert exists is True
+ assert path.parent == tmp_path / "envvars"
+ assert cfg["VK_DRIVER_FILES"].endswith("nvidia.json")
+ assert cfg["DXVK_HUD"] == "fps"
+
+ assert delete_envvars("nvidia-wayland") is True
+ assert list_envvars() == []
+
+
+def test_envvars_sanitizes_names(tmp_path, monkeypatch) -> None:
+ """Slashes and weird chars in names should not escape ``envvars/``."""
+ monkeypatch.setattr(scb_mod, "SCOPEBUDDY_CONFIG_DIR", tmp_path)
+ monkeypatch.setattr(scb_mod, "SCOPEBUDDY_ENVVARS_DIR", tmp_path / "envvars")
+
+ assert write_envvars("../escape/attempt:two", {"FOO": "bar"}) is True
+ files = list((tmp_path / "envvars").glob("*.conf"))
+ assert len(files) == 1
+ assert files[0].parent == tmp_path / "envvars"
+
+
+def test_rescan_tools_clears_cache_and_returns_info() -> None:
+ """``rescan_tools`` must drop the lru_cache and return availability info."""
+ from game_setup_hub.tool_check import find_tool
+
+ sentinel = "__protonshift_rescan_sentinel__"
+ find_tool.cache_clear()
+ find_tool(sentinel)
+ assert any(sentinel in str(k) for k in find_tool.cache_info()._asdict().values()) or (
+ find_tool.cache_info().currsize >= 1
+ )
+
+ result = rescan_tools()
+
+ find_tool(sentinel)
+ assert find_tool.cache_info().misses >= 1, (
+ "after rescan the sentinel lookup must miss the cache again"
+ )
+ info_at_end = find_tool.cache_info()
+ assert info_at_end.hits == 0, (
+ "no hits should be recorded post-rescan for distinct probe keys"
+ )
+ assert {"available", "binary", "path", "version", "config_dir"} <= set(result.keys())
diff --git a/vm-test/README.md b/vm-test/README.md
new file mode 100644
index 0000000..049bd0d
--- /dev/null
+++ b/vm-test/README.md
@@ -0,0 +1,36 @@
+# VM test environment
+
+Exercise a **packaged ProtonShift** build (AppImage, `.deb`, …) on **real desktop stacks** inside **KVM/QEMU** guests driven by **[Quickemu](https://github.com/quickemu-project/quickemu)**.
+
+The renderer is **Electron**: you want a composed session (**X11/Wayland**), workable GPU virtio setup, Steam/Wine tooling, and MangoHud/Gamescope — **`scripts/ci/linux-matrix.sh`** Docker runs complement this but **do not replace** it.
+
+---
+
+## Documentation
+
+👉 **[`docs/index.md`](docs/index.md)** — **per-distro** runbooks (**Ubuntu**, **Fedora**, **Debian**, **CachyOS**, **openSUSE**, **Bazzite**).
+
+| Topic | Doc |
+| --- | --- |
+| Distro index | [`docs/index.md`](docs/index.md) |
+| Host: Quickemu, KVM, **`smbd`** (SMB share host) | [`docs/host-prerequisites.md`](docs/host-prerequisites.md) |
+| Guest: mount **`build/`**, AppImage **`--no-sandbox`**, SCP | [`docs/guest-build-share-appimage.md`](docs/guest-build-share-appimage.md) |
+| **`quickemu/`** paths & **`*.conf`** quirks | [`docs/layout-and-quickemu.md`](docs/layout-and-quickemu.md) |
+| Manual QA | [`smoke-checklist.md`](smoke-checklist.md) |
+
+**Boot:** `./run-vm.sh list` • `./run-vm.sh ` • `./run-vm.sh --status` (see **`run-vm.sh`** header).
+
+**Artifacts staged into the SMB share** (host **`$(repo)/build/`** → guest **`/mnt/protonshift-build/`**, passed via Quickemu **`--public-dir`**):
+
+| Host path | Guest path | What's there |
+| --- | --- | --- |
+| `build/ProtonShift-*.AppImage` (+ `.deb`/`.rpm`) | `/mnt/protonshift-build/` | Packaged builds from **`pnpm run dist:*`** |
+| `vm-test/provision/*.sh` | `/mnt/protonshift-build/_provision/` | Per-distro provision scripts (copied by **`run-vm.sh`**) |
+| `vm-test/docs/*.md`, `smoke-checklist.md`, `README.md` | `/mnt/protonshift-build/_docs/` | These runbooks — `less` / paste commands directly from the guest |
+
+After mounting the share inside the VM, the per-distro doc is one command away:
+
+```bash
+less /mnt/protonshift-build/_docs/README.md # overview
+less /mnt/protonshift-build/_docs/ubuntu-24.04.md # or whichever guest you booted
+```
diff --git a/vm-test/docs/bazzite.md b/vm-test/docs/bazzite.md
new file mode 100644
index 0000000..f79159b
--- /dev/null
+++ b/vm-test/docs/bazzite.md
@@ -0,0 +1,30 @@
+# Bazzite (`bazzite`)
+
+> **In-guest:** if you can mount the SMB share, `less /mnt/protonshift-build/_docs/bazzite.md`. Otherwise SCP this file (or the whole `_docs/` directory) over and `less ./bazzite.md`.
+
+Immutable **rpm-ostree** — Steam, MangoHud, Gamescope, and GameMode are present by default. **`provision/bazzite.sh`** avoids installing **`cifs-utils`** via **`rpm-ostree`** (would need a reboot). Prefer **SSH/SCP** for the AppImage; the script enables **Heroic/Lutris** via **Flatpak** (**`--user`**).
+
+**Host:** [Host prerequisites](host-prerequisites.md); build **`ProtonShift-*.AppImage`** (**`cd electron && pnpm run dist:appimage`**).
+
+```bash
+cd vm-test && ./run-vm.sh bazzite
+```
+
+**Guest**
+
+1. Prefer **SSH/SCP** to copy **`ProtonShift-*.AppImage`** (+ **`provision/bazzite.sh`** if needed) — see **[SCP fallback](guest-build-share-appimage.md)**. **Alternatively**, if **`cifs-utils`** is available without **`rpm-ostree`**, **[mount `build/`](guest-build-share-appimage.md)** and skip manual copy.
+2. Run **Flatpak wiring** **without sudo** where your login session owns **`--user`** Flatpak:
+
+```bash
+bash /mnt/protonshift-build/_provision/bazzite.sh # SMB path
+# or: bash ./bazzite.sh # copied into $HOME via SCP
+```
+
+3. Typical launch after **`chmod +x`**:
+
+```bash
+chmod +x ~/protonshift-build/ProtonShift-*.AppImage
+~/protonshift-build/ProtonShift-*.AppImage --no-sandbox
+```
+
+**[Electron / `--no-sandbox`](guest-build-share-appimage.md)** • **[smoke checklist](../smoke-checklist.md)**
diff --git a/vm-test/docs/cachyos.md b/vm-test/docs/cachyos.md
new file mode 100644
index 0000000..374849e
--- /dev/null
+++ b/vm-test/docs/cachyos.md
@@ -0,0 +1,23 @@
+# CachyOS (`cachyos`)
+
+> **In-guest:** `less /mnt/protonshift-build/_docs/cachyos.md` once the SMB share is mounted (step 1).
+
+Arch-style **`pacman`** guest; **`arch.sh`** provisioning (Steam/repos; **protontricks** skipped if absent).
+
+**Host:** [Host prerequisites](host-prerequisites.md); build **`ProtonShift-*.AppImage`** (**`cd electron && pnpm run dist:appimage`**).
+
+```bash
+cd vm-test && ./run-vm.sh cachyos
+```
+
+**Guest**
+
+1. **`sudo pacman -Sy --needed cifs-utils`**, **[mount `build/`](guest-build-share-appimage.md)**.
+2.
+
+```bash
+sudo bash /mnt/protonshift-build/_provision/arch.sh
+chmod +x /mnt/protonshift-build/ProtonShift-*.AppImage
+```
+
+**[AppImage quirks](guest-build-share-appimage.md)** • **[smoke checklist](../smoke-checklist.md)**
diff --git a/vm-test/docs/debian-12.md b/vm-test/docs/debian-12.md
new file mode 100644
index 0000000..96cac43
--- /dev/null
+++ b/vm-test/docs/debian-12.md
@@ -0,0 +1,26 @@
+# Debian 12 (`debian-12`)
+
+> **In-guest:** `less /mnt/protonshift-build/_docs/debian-12.md` once the SMB share is mounted (step 1).
+
+Stable **bookworm**, older glibc baseline. There is **no** **`debian.sh`** yet — install **`cifs-utils`**, enable **`contrib`** / **`non-free`** as needed for Steam packages, and mirror what **`ubuntu.sh`** / **`fedora.sh`** pull in (**Steam**, **Flatpak**, **MangoHud**, **Gamemode**, **Gamescope**, **protontricks** when available).
+
+**Host:** [Host prerequisites](host-prerequisites.md); build **`ProtonShift-*.AppImage`** (**`cd electron && pnpm run dist:appimage`**). On bookworm Quickemu usually comes from the **upstream `.deb`** (see prerequisites doc).
+
+```bash
+cd vm-test && ./run-vm.sh debian-12
+```
+
+**Guest**
+
+1. **`sudo apt install -y cifs-utils`**, **[mount `build/`](guest-build-share-appimage.md)** when using SMB.
+2. There is **no** **`debian.sh`**. Install tooling via **`apt`** (enable **`contrib`** / **`non-free`** as needed for **`steam`** / **`steam-installer`**), e.g.
+
+```bash
+sudo apt update
+sudo apt install -y flatpak dbus-user-session
+sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
+# If your sources allow: steam mangohud gamescope gamemode protontricks (mirror ubuntu.sh/fedora.sh)
+```
+
+3. **`chmod +x /mnt/protonshift-build/ProtonShift-*.AppImage`** and launch per **[guest-build-share-appimage.md](guest-build-share-appimage.md)**.
+4. **[`../smoke-checklist.md`](../smoke-checklist.md)**.
diff --git a/vm-test/docs/fedora-41.md b/vm-test/docs/fedora-41.md
new file mode 100644
index 0000000..176cbac
--- /dev/null
+++ b/vm-test/docs/fedora-41.md
@@ -0,0 +1,25 @@
+# Fedora 41 (`fedora-41`)
+
+> **In-guest:** `less /mnt/protonshift-build/_docs/fedora-41.md` once the SMB share is mounted (step 1).
+
+**`.rpm` / RPMFusion** Steam stack; **`dnf`** provisioning.
+
+**Host:** [Host prerequisites](host-prerequisites.md) → **`cd electron && pnpm run dist:appimage`**.
+
+**Boot**
+
+```bash
+cd vm-test && ./run-vm.sh fedora-41
+```
+
+**Guest (installer done, signed in)**
+
+1. **`sudo dnf install -y cifs-utils`**, **[mount `build/`](guest-build-share-appimage.md)**, verify **`_provision/`**.
+2.
+
+```bash
+sudo bash /mnt/protonshift-build/_provision/fedora.sh
+chmod +x /mnt/protonshift-build/ProtonShift-*.AppImage
+```
+
+**[AppImage / SCP](guest-build-share-appimage.md)** • **[smoke checklist](../smoke-checklist.md)**
diff --git a/vm-test/docs/guest-build-share-appimage.md b/vm-test/docs/guest-build-share-appimage.md
new file mode 100644
index 0000000..af48967
--- /dev/null
+++ b/vm-test/docs/guest-build-share-appimage.md
@@ -0,0 +1,44 @@
+# Guest: mount `build/` and run the AppImage
+
+Assume the host exported **`$(repo)/build`** via `./run-vm.sh …` (**`--public-dir`**). Provision scripts expect **`build/`** to contain **`ProtonShift-*.AppImage`** and **`_provision/`** (staging from **`run-vm.sh`**).
+
+## Mount the SMB share (Linux guest)
+
+Needs **`cifs-utils`** (install via your distro if not already pulled in by provisioning).
+
+```bash
+sudo mkdir -p /mnt/protonshift-build
+sudo mount -t cifs //10.0.2.4/qemu /mnt/protonshift-build \
+ -o "guest,vers=3.0,ro,uid=$(id -u),gid=$(id -g),forceuid,forcegid"
+ls /mnt/protonshift-build/
+```
+
+You should see **`ProtonShift-*.AppImage`** and **`_provision/`**. **`mount`(8)** error **`(2)`** ⇒ install **`smbd`** on the host, restart the VM with **`run-vm.sh`**.
+
+**Running `_provision/*.sh`** — Scripts are staged by the host under **`build/_provision/`**; mount the share **first**, then run the script for your distro (e.g. **`sudo bash /mnt/protonshift-build/_provision/ubuntu.sh`**). Filenames are listed per guest in **[docs/index.md](index.md)**.
+
+## Electron AppImage on Linux guests (`/tmp` / sandbox)
+
+Unpack usually uses **`/tmp/.mount_*`**. If **`/tmp` is `nosuid`**, Chromium’s setuid helper fails (**`chrome-sandbox`**, mode **4755**). Easiest VM smoke:
+
+```bash
+/mnt/protonshift-build/ProtonShift-*.AppImage --no-sandbox
+```
+
+Alternatives: **`TMPDIR`** under **`$HOME`**, or extract with **`--appimage-extract`**, **`chown/chmod`** **`chrome-sandbox`**, **`./squashfs-root/AppRun`**. Don’t plain **`sudo ./ProtonShift-*.AppImage`** — Electron rejects root without **`--no-sandbox`**.
+
+## Python backend **`exited with code 1`**
+
+Packaged trees are often read-only; current Electron builds set **`PYTHONDONTWRITEBYTECODE=1`** for packaged runs. **Rebuild** the AppImage from a current checkout. Check terminal **`[python] …`** for the traceback.
+
+## SCP fallback (host → guest)
+
+On the host (VM running):
+
+```bash
+cd vm-test
+PORT=$(awk -F= '/ssh_port/{print $2}' quickemu//.ports)
+scp -P "$PORT" ../build/ProtonShift-*.AppImage YOUR_USER@localhost:~/
+```
+
+Guest: **`chmod +x`** and run from **`~/`**. Copy **`provision/*.sh`** with adjusted paths if you skip SMB.
diff --git a/vm-test/docs/host-prerequisites.md b/vm-test/docs/host-prerequisites.md
new file mode 100644
index 0000000..6e37acb
--- /dev/null
+++ b/vm-test/docs/host-prerequisites.md
@@ -0,0 +1,77 @@
+# Host prerequisites (one-time)
+
+Quickemu exposes the repo’s **`build/`** to guests via `--public-dir` as **`smb://10.0.2.4/qemu`**. KVM should be usable by your user; **`smbd`** must run on the **host**.
+
+## KVM check
+
+```bash
+egrep -c '(vmx|svm)' /proc/cpuinfo # > 0 ⇒ CPU virtualization
+ls -l /dev/kvm # must exist; readable by kvm group / your user
+```
+
+```bash
+sudo usermod -aG kvm "$USER" # then re-login
+```
+
+## Quickemu + QEMU (+ OVMF where applicable)
+
+Quickemu may be missing from older distro repos — use community packages or upstream.
+
+### Pop!_OS, Ubuntu 22.04 / 24.04, Mint, elementary, Zorin
+
+```bash
+sudo apt-add-repository ppa:flexiondotorg/quickemu
+sudo apt update
+sudo apt install quickemu qemu-system-x86 qemu-system-modules-spice ovmf
+```
+
+### Ubuntu 25.04+ (Quickemu in Universe)
+
+```bash
+sudo apt install quickemu qemu-system-x86 qemu-system-modules-spice ovmf
+```
+
+### Debian 13+ (trixie)
+
+```bash
+sudo apt install quickemu qemu-system-x86 ovmf
+```
+
+### Debian 12 (bookworm) — upstream `.deb`
+
+```bash
+# Replace x.y.z with the latest tag from releases.
+curl -fsSLO https://github.com/quickemu-project/quickemu/releases/latest/download/quickemu_x.y.z-1_all.deb
+sudo apt install ./quickemu_x.y.z-1_all.deb
+```
+
+### Fedora 41+, Bazzite, Nobara
+
+```bash
+sudo dnf install quickemu qemu-system-x86 edk2-ovmf
+```
+
+### Arch / CachyOS / Manjaro / EndeavourOS
+
+```bash
+yay -S quickemu
+```
+
+### Source fallback
+
+```bash
+git clone --filter=blob:none https://github.com/quickemu-project/quickemu
+cd quickemu/docs && sudo make install
+```
+
+Runtime deps: [Quickemu installation wiki](https://github.com/quickemu-project/quickemu/wiki/01-Installation).
+
+## Samba (`smbd`) for `--public-dir`
+
+Without **`smbd`**, VMs still boot; guests won’t mount **`//10.0.2.4/qemu`** (use SCP instead — see [guest-build-share-appimage.md](guest-build-share-appimage.md)).
+
+```bash
+sudo apt install --no-install-recommends samba # Pop!_OS / Debian / Ubuntu
+sudo dnf install samba # Fedora / Bazzite
+sudo pacman -S samba # Arch / CachyOS
+```
diff --git a/vm-test/docs/index.md b/vm-test/docs/index.md
new file mode 100644
index 0000000..8332962
--- /dev/null
+++ b/vm-test/docs/index.md
@@ -0,0 +1,33 @@
+# VM test guides
+
+Hands-on flows for exercising a packaged ProtonShift build ([`run-vm.sh`](../run-vm.sh) + Quickemu).
+
+> **Reading these docs *inside* the guest VM.** Booting via `./run-vm.sh `
+> stages this directory into the SMB share. After mounting (see
+> [`guest-build-share-appimage.md`](./guest-build-share-appimage.md)):
+>
+> ```bash
+> ls /mnt/protonshift-build/_docs/
+> less /mnt/protonshift-build/_docs/.md
+> ```
+>
+> Copy/paste commands from there instead of alt-tabbing back to the host.
+
+| Config | Distro focus | Doc |
+| --- | --- | --- |
+| `ubuntu-24.04` | Ubuntu 24.04 LTS | [`ubuntu-24.04.md`](./ubuntu-24.04.md) |
+| `fedora-41` | Fedora 41 Workstation | [`fedora-41.md`](./fedora-41.md) |
+| `debian-12` | Debian 12 (bookworm) | [`debian-12.md`](./debian-12.md) |
+| `cachyos` | CachyOS / Arch-style | [`cachyos.md`](./cachyos.md) |
+| `opensuse-tumbleweed` | openSUSE Tumbleweed | [`opensuse-tumbleweed.md`](./opensuse-tumbleweed.md) |
+| `bazzite` | Bazzite (immutable) | [`bazzite.md`](./bazzite.md) |
+
+**Shared**
+
+- [**Host prerequisites**](host-prerequisites.md) — Quickemu, QEMU, KVM, Samba (`smbd`)
+- [**Guest: share mount + AppImage**](guest-build-share-appimage.md) — CIFS mount, Electron `--no-sandbox`, SCP fallback
+- [**Layout & Quickemu quirks**](layout-and-quickemu.md) — **`*.extras.conf`**, avoiding broken **`*.conf`** in git
+
+After install: **[`smoke-checklist.md`](../smoke-checklist.md)**
+
+Add guests by cloning [`quickemu/*.extras.conf`](../quickemu/) and running **`./run-vm.sh`** with a configured short name (see table). Advanced Quickemu: [upstream wiki](https://github.com/quickemu-project/quickemu/wiki/05-Advanced-quickemu-configuration).
diff --git a/vm-test/docs/layout-and-quickemu.md b/vm-test/docs/layout-and-quickemu.md
new file mode 100644
index 0000000..5e524c9
--- /dev/null
+++ b/vm-test/docs/layout-and-quickemu.md
@@ -0,0 +1,18 @@
+# Layout and Quickemu quirks
+
+## Repository paths
+
+| Path | Role |
+| --- | --- |
+| `quickemu/.extras.conf` | `quickget` args (**`QG_*`**) + Quickemu overrides; committed fragments only. |
+| `quickemu//…` (local) | **`quickget` output** (**`.conf`** with **`iso=`** / disks). Recreated when broken or stale. |
+
+**[`run-vm.sh`](../run-vm.sh)** merges **`.extras.conf`** into the generated **`.conf`**, and passes **`$(repo)/build`** via **`--public-dir`**.
+
+Guests mount **`//10.0.2.4/qemu`** at **`/mnt/protonshift-build`** when **`smbd`** runs on the host; see **[guest-build-share-appimage.md](./guest-build-share-appimage.md)**.
+
+## Avoid committing hand-written **`*.conf`**
+
+`quickget` writes **`iso=`** lines only when the machine **`.conf`** does **not** already exist (e.g. **`quickemu/ubuntu-24.04/ubuntu-24.04.conf`**). A tracked **`.conf`** that lacks **`iso=`** prevents regeneration ⇒ Quickemu errors with **`You haven't specified a .iso`**. **`run-vm.sh`** removes broken files and reruns **`quickget`**.
+
+Heavy outputs (ISOs, **`disk.qcow2`**, merged **`*.conf`**) are **`gitignore`d** ([`quickemu/.gitignore`](../quickemu/.gitignore)).
diff --git a/vm-test/docs/opensuse-tumbleweed.md b/vm-test/docs/opensuse-tumbleweed.md
new file mode 100644
index 0000000..44c33da
--- /dev/null
+++ b/vm-test/docs/opensuse-tumbleweed.md
@@ -0,0 +1,23 @@
+# openSUSE Tumbleweed (`opensuse-tumbleweed`)
+
+> **In-guest:** `less /mnt/protonshift-build/_docs/opensuse-tumbleweed.md` once the SMB share is mounted (step 1).
+
+Rolling **`zypper`** / **`.rpm`**.
+
+**Host:** [Host prerequisites](host-prerequisites.md); build **`ProtonShift-*.AppImage`** (**`cd electron && pnpm run dist:appimage`**).
+
+```bash
+cd vm-test && ./run-vm.sh opensuse-tumbleweed
+```
+
+**Guest**
+
+1. **`sudo zypper install cifs-utils`**, **[mount `build/`](guest-build-share-appimage.md)**.
+2.
+
+```bash
+sudo bash /mnt/protonshift-build/_provision/opensuse.sh
+chmod +x /mnt/protonshift-build/ProtonShift-*.AppImage
+```
+
+**[AppImage quirks](guest-build-share-appimage.md)** • **[smoke checklist](../smoke-checklist.md)**
diff --git a/vm-test/docs/ubuntu-24.04.md b/vm-test/docs/ubuntu-24.04.md
new file mode 100644
index 0000000..92cc440
--- /dev/null
+++ b/vm-test/docs/ubuntu-24.04.md
@@ -0,0 +1,26 @@
+# Ubuntu 24.04 (`ubuntu-24.04`)
+
+> **In-guest:** `less /mnt/protonshift-build/_docs/ubuntu-24.04.md` once the SMB share is mounted (step 1).
+
+Stock **`.deb`/Steam-heavy** baseline; scripted provisioning matches Noble quirks (gamescope PPA when needed).
+
+**Host:** [Host prerequisites](host-prerequisites.md) → build AppImage (`cd electron && pnpm run dist:appimage`).
+
+**Boot**
+
+```bash
+cd vm-test && ./run-vm.sh ubuntu-24.04
+```
+
+**Guest (finish the distro installer once, reboot, sign in)**
+
+1. Install **`cifs-utils`**, **[mount the SMB share](guest-build-share-appimage.md)**, confirm **`_provision/`** appears.
+2. Run provisioning (also wires **`fstab`** when mount succeeds):
+
+```bash
+sudo bash /mnt/protonshift-build/_provision/ubuntu.sh
+chmod +x /mnt/protonshift-build/ProtonShift-*.AppImage
+```
+
+3. Launch ProtonShift (often **`--no-sandbox`** in VMs): **[guest-build-share-appimage.md](guest-build-share-appimage.md)**.
+4. **[`../smoke-checklist.md`](../smoke-checklist.md)**.
diff --git a/vm-test/provision/_mount-build-share.sh b/vm-test/provision/_mount-build-share.sh
new file mode 100644
index 0000000..d1e4b80
--- /dev/null
+++ b/vm-test/provision/_mount-build-share.sh
@@ -0,0 +1,44 @@
+#!/usr/bin/env bash
+# Source this from a per-distro provision script (after installing
+# `cifs-utils` or its equivalent). Mounts the host's build/ directory at
+# /mnt/protonshift-build using the Samba share Quickemu's `--public-dir`
+# advertises at smb://10.0.2.4/qemu.
+
+mount_protonshift_build() {
+ local target="${1:-/mnt/protonshift-build}"
+ mkdir -p "${target}"
+
+ if mountpoint -q "${target}"; then
+ return 0
+ fi
+
+ local user_for_id="${SUDO_USER:-${USER:-root}}"
+ local guest_uid guest_gid
+ guest_uid="$(id -u "${user_for_id}")"
+ guest_gid="$(id -g "${user_for_id}")"
+
+ if mount -t cifs //10.0.2.4/qemu "${target}" \
+ -o "guest,vers=3.0,ro,uid=${guest_uid},gid=${guest_gid},forceuid,forcegid" 2>/dev/null; then
+ echo "Mounted //10.0.2.4/qemu → ${target}"
+ if ! grep -q '//10.0.2.4/qemu' /etc/fstab 2>/dev/null; then
+ printf '//10.0.2.4/qemu %s cifs guest,vers=3.0,ro,uid=%s,gid=%s,forceuid,forcegid,nofail,_netdev 0 0\n' \
+ "${target}" "${guest_uid}" "${guest_gid}" >> /etc/fstab
+ fi
+ cat <.md # this distro's runbook
+EOF
+ return 0
+ fi
+
+ cat </dev/null || \
+ echo "(protontricks not in repos — install from AUR if you need it)"
+
+flatpak remote-add --if-not-exists flathub \
+ https://flathub.org/repo/flathub.flatpakrepo
+flatpak install -y flathub com.heroicgameslauncher.hgl net.lutris.Lutris
+
+mount_protonshift_build
+
+echo ""
+echo "Provisioning done."
+echo "Run: /mnt/protonshift-build/ProtonShift-*.AppImage"
diff --git a/vm-test/provision/bazzite.sh b/vm-test/provision/bazzite.sh
new file mode 100755
index 0000000..2b436d8
--- /dev/null
+++ b/vm-test/provision/bazzite.sh
@@ -0,0 +1,25 @@
+#!/usr/bin/env bash
+# Bazzite is rpm-ostree, so most game tooling is preinstalled (Steam,
+# MangoHud, Gamescope, GameMode). Just install Heroic + Lutris via Flatpak
+# and copy the AppImage in via SCP — `cifs-utils` would require an
+# rpm-ostree install + reboot, which is overkill for a smoke test.
+set -euo pipefail
+
+flatpak remote-add --if-not-exists --user flathub \
+ https://flathub.org/repo/flathub.flatpakrepo
+flatpak install -y --user flathub com.heroicgameslauncher.hgl net.lutris.Lutris
+
+mkdir -p /var/home/"${SUDO_USER:-$USER}"/protonshift-build
+cat </dev/null || true
+add-apt-repository -y multiverse 2>/dev/null || true
+apt-get update -qq
+
+# flatpak Depends: fuse3. The transitional "fuse" .deb (FUSE 2 userspace) conflicts
+# (fuse3 Breaks: fuse) on every arch — including fuse:i386 on multiarch guests.
+# apt sometimes satisfies virtual "Depends: fuse" via fuse:i386 anyway; pinning the
+# concrete package "fuse" forces resolution through fuse3 (Provides: fuse).
+install -d -m0755 /etc/apt/preferences.d
+cat >/etc/apt/preferences.d/99-protonshift-fuse.pref <<'EOF'
+# Satisfy Depends: fuse via fuse3 only (never the fuse/FUSE2 metapackage).
+Package: fuse
+Pin: release *
+Pin-Priority: -1
+EOF
+
+DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends fuse3
+DEBIAN_FRONTEND=noninteractive apt-get remove -y fuse 'fuse:i386' 2>/dev/null || true
+
+. /etc/os-release
+CODENAME="${VERSION_CODENAME:-${UBUNTU_CODENAME:-}}"
+VID="${VERSION_ID:-}"
+
+gamescope_has_candidate() {
+ local cand=""
+ cand="$(apt-cache policy gamescope 2>/dev/null | sed -n 's/^[[:space:]]*Candidate:[[:space:]]*\(.*\)$/\1/p' | head -1)"
+ [[ -n "${cand}" && "${cand}" != "(none)" ]]
+}
+
+# gamescope has no Debian/Ubuntu mirror entry on Noble (24.04); use Marco
+# Trevisán's gamescope PPA for that release (and Pop!_OS / Ubuntu clones that
+# still report noble + VERSION_ID 24.04). Jammy: prefer universe package.
+if ! gamescope_has_candidate; then
+ if [[ "${CODENAME}" == noble || "${VID}" == "24.04" ]]; then
+ echo "Adding ppa:3v1n0/gamescope for Ubuntu Noble / 24.04-family (gamescope not in stock repos)…"
+ add-apt-repository -y ppa:3v1n0/gamescope
+ apt-get update -qq
+ elif [[ "${CODENAME}" == jammy ]]; then
+ add-apt-repository -y universe
+ apt-get update -qq
+ else
+ echo "Note: no gamescope path for codename=${CODENAME}, VERSION_ID=${VID}; skipping PPA/game repository tweak." >&2
+ fi
+fi
+
+# AppImage needs FUSE2 userspace libs. Prefer libfuse2t64 (Noble+) else
+# libfuse2-2 (Jammy/Debian bookworm). Omit transitional "fuse" — clashes with fuse3.
+FUSE2_AIMG=()
+if apt-cache show libfuse2t64 &>/dev/null; then FUSE2_AIMG=(libfuse2t64)
+elif apt-cache show libfuse2-2 &>/dev/null; then FUSE2_AIMG=(libfuse2-2)
+fi
+
+apt-get install -y --no-install-recommends \
+ steam-installer \
+ mangohud \
+ gamemode \
+ protontricks \
+ flatpak \
+ cifs-utils \
+ curl \
+ "${FUSE2_AIMG[@]}"
+
+if ! apt-get install -y --no-install-recommends gamescope; then
+ if gamescope_has_candidate; then
+ echo "Note: apt could not install gamescope despite a Candidate (dependency/conflict?). Continuing." >&2
+ else
+ echo "Note: gamescope has no Candidate (PPA unreachable or unsupported arch?). ProtonShift will grey out Gamescope." >&2
+ fi
+fi
+
+flatpak remote-add --if-not-exists flathub \
+ https://flathub.org/repo/flathub.flatpakrepo
+flatpak install -y flathub com.heroicgameslauncher.hgl net.lutris.Lutris
+
+mount_protonshift_build
+
+echo ""
+echo "Provisioning done. Launch ProtonShift (Electron needs the sandbox tweak on many setups):"
+echo " VM / Ubuntu /tmp nosuid:"
+echo " /mnt/protonshift-build/ProtonShift-*.AppImage --no-sandbox"
+echo " Or TMPDIR under HOME (often allows setuid sandbox):"
+echo " mkdir -p \"\${HOME}/.cache/protonshift-appimage-tmp\""
+echo " TMPDIR=\"\${HOME}/.cache/protonshift-appimage-tmp\" /mnt/protonshift-build/ProtonShift-*.AppImage"
+echo " Do not run AppImage as root unless you pass --no-sandbox (electron blocks root by default)."
diff --git a/vm-test/quickemu/.gitignore b/vm-test/quickemu/.gitignore
new file mode 100644
index 0000000..530c52d
--- /dev/null
+++ b/vm-test/quickemu/.gitignore
@@ -0,0 +1,16 @@
+# Generated by Quickemu / quickemu — bulky and machine-specific.
+# The only things checked in here are *.extras.conf fragments; everything
+# else (merged conf, per-VM disk image / OVMF state / launch wrapper / ports
+# file / runtime log / installer ISO) is host-local and gets regenerated.
+*.conf
+*.conf.bak.*
+OVMF_VARS.fd
+**/OVMF_VARS.fd
+**/disk*.qcow2
+**/*.iso
+**/*.log
+**/*.ports
+# Quickemu writes a .sh launcher next to disk.qcow2; we only ship the
+# *.extras.conf fragments in vm-test/quickemu/ root, so any .sh in a
+# subdirectory is generated.
+*/*.sh
diff --git a/vm-test/run-vm.sh b/vm-test/run-vm.sh
new file mode 100755
index 0000000..8d38c42
--- /dev/null
+++ b/vm-test/run-vm.sh
@@ -0,0 +1,185 @@
+#!/usr/bin/env bash
+# Boot a Quickemu guest described by vm-test/quickemu/.extras.conf.
+#
+# Usage:
+# ./run-vm.sh # download ISO via quickget if needed, merge custom Quickemu knobs, boot
+# ./run-vm.sh --status # print VM info only
+# ./run-vm.sh list # list available guests
+#
+# quickget refuses to regenerate -.conf if that path already
+# exists WITHOUT an iso/img line — a common trap when committing a stray
+# handcrafted .conf. We only ship *.extras.conf fragments; merged .conf lives
+# in quickemu/*.conf (typically gitignored) with proper iso=/img= pointers.
+#
+# Host build artefacts are exposed via:
+# quickemu --public-dir "$(repo)/build/"
+# Guests mount smb://10.0.2.4/qemu (see docs/guest-build-share-appimage.md + provision/).
+
+set -euo pipefail
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+CONF_DIR="${HERE}/quickemu"
+ARTIFACT_DIR="$(cd "${HERE}/.." && pwd)/build"
+
+list_configs() {
+ ls -1 "${CONF_DIR}"/*.extras.conf 2>/dev/null | sed 's|.*/||; s/\.extras\.conf$//' | sort -u
+}
+
+# Merge ASSIGNMENT lines from extras into MAIN_CONF — fragment keys shadow
+# any existing keys picked up by quickemu/quickget upstream.
+quickemu_merge_overrides() {
+ local main_conf="$1" extras="$2"
+ [[ -f "${extras}" ]] || return 0
+ local stripped tmp_assign
+ tmp_assign="$(mktemp)"
+ stripped="$(mktemp)"
+ grep -E '^[a-zA-Z_][a-zA-Z0-9_]*=' "${extras}" \
+ | grep -Ev '^(CONF_BASENAME|QG_DISTRO|QG_RELEASE|QG_EDITION)=' > "${tmp_assign}" || true
+ cp "${main_conf}" "${stripped}"
+
+ # GNU grep exits 1 when it would print nothing (`set -e` would abort). Use awk.
+ local k=""
+ while IFS= read -r line || [[ -n "${line}" ]]; do
+ [[ "$line" =~ ^([a-zA-Z_][a-zA-Z0-9_]*)= ]] || continue
+ k="${BASH_REMATCH[1]}"
+ awk -v p="${k}=" 'substr($0, 1, length(p)) != p { print }' "${stripped}" > "${stripped}.new"
+ mv "${stripped}.new" "${stripped}"
+ done < "${tmp_assign}"
+
+ {
+ cat "${stripped}"
+ cat "${tmp_assign}"
+ } > "${main_conf}"
+
+ rm -f "${stripped}" "${tmp_assign}"
+}
+
+conf_has_boot_media() {
+ local f="$1"
+ [[ -f "$f" ]] || return 1
+ grep -qE '^iso="|^fixed_iso="|^img="' "$f"
+}
+
+if [[ $# -lt 1 ]] || [[ "$1" == "--help" ]] || [[ "$1" == "-h" ]]; then
+ echo "Usage: $0 |list [--status]"
+ echo ""
+ echo "Available VMs:"
+ list_configs | sed 's/^/ /'
+ exit 0
+fi
+
+if [[ "$1" == "list" ]]; then
+ list_configs
+ exit 0
+fi
+
+NAME="$1"
+shift || true
+EXTRAS="${CONF_DIR}/${NAME}.extras.conf"
+if [[ ! -f "${EXTRAS}" ]]; then
+ echo "Unknown VM '${NAME}'. Known names:" >&2
+ list_configs | sed 's/^/ /' >&2
+ exit 1
+fi
+
+for tool in quickemu quickget; do
+ if ! command -v "${tool}" >/dev/null 2>&1; then
+ echo "Required tool '${tool}' not on PATH. Install Quickemu first." >&2
+ echo "See vm-test/README.md for distro-specific install commands." >&2
+ exit 1
+ fi
+done
+
+mkdir -p "${ARTIFACT_DIR}"
+cd "${CONF_DIR}"
+
+unset QG_DISTRO QG_RELEASE QG_EDITION CONF_BASENAME
+# shellcheck disable=SC1090
+source "${EXTRAS}"
+
+if [[ -z "${QG_DISTRO:-}" ]] || [[ -z "${QG_RELEASE:-}" ]]; then
+ echo "extras file must define QG_DISTRO and QG_RELEASE: ${EXTRAS}" >&2
+ exit 1
+fi
+
+BASE="${CONF_BASENAME:-$NAME}"
+MAIN_CONF="${CONF_DIR}/${BASE}.conf"
+
+if [[ -f "${MAIN_CONF}" ]] && ! conf_has_boot_media "${MAIN_CONF}"; then
+ echo "Stale ${BASE}.conf (no iso/img/image). Removing so quickget can regenerate iso=…" >&2
+ mv -f "${MAIN_CONF}" "${MAIN_CONF}.bak.$(date +%s)" 2>/dev/null \
+ || rm -f "${MAIN_CONF}"
+fi
+
+# quickget ALWAYS exits successfully after fetching *and refuses to regenerate
+# an existing *.conf*. If the iso line is gone we nuked MAIN_CONF above; if the
+# per-ISO directory is empty/unusable, let quickget refill it.
+needs_fetch=false
+if [[ ! -f "${MAIN_CONF}" ]]; then
+ needs_fetch=true
+elif ! conf_has_boot_media "${MAIN_CONF}"; then
+ rm -f "${MAIN_CONF}"
+ needs_fetch=true
+fi
+
+if [[ "${needs_fetch}" == true ]]; then
+ echo "Fetching ${QG_DISTRO} ${QG_RELEASE}${QG_EDITION:+ (${QG_EDITION})} via quickget…"
+ if [[ -n "${QG_EDITION:-}" ]]; then
+ quickget "${QG_DISTRO}" "${QG_RELEASE}" "${QG_EDITION}"
+ else
+ quickget "${QG_DISTRO}" "${QG_RELEASE}"
+ fi
+fi
+
+if [[ ! -f "${MAIN_CONF}" ]]; then
+ echo "ERROR: expected Quickemu conf at ${MAIN_CONF}" >&2
+ echo "(quickget emits .conf — tweak CONF_BASENAME in ${EXTRAS}?)" >&2
+ exit 1
+fi
+if ! conf_has_boot_media "${MAIN_CONF}"; then
+ echo "ERROR: ${MAIN_CONF} still has no iso/fixed_iso/img assignment." >&2
+ exit 1
+fi
+
+quickemu_merge_overrides "${MAIN_CONF}" "${EXTRAS}"
+
+PROV_STAGE="${ARTIFACT_DIR}/_provision"
+mkdir -p "${PROV_STAGE}"
+cp "${HERE}/provision/"*.sh "${PROV_STAGE}/"
+chmod +x "${PROV_STAGE}/"*.sh
+
+# Stage vm-test docs into the SMB share so guests can `cat`/`less` them at
+# /mnt/protonshift-build/_docs/.md without bouncing back to the host.
+DOCS_STAGE="${ARTIFACT_DIR}/_docs"
+rm -rf "${DOCS_STAGE}"
+mkdir -p "${DOCS_STAGE}"
+cp "${HERE}/docs/"*.md "${DOCS_STAGE}/"
+cp "${HERE}/smoke-checklist.md" "${DOCS_STAGE}/"
+cp "${HERE}/README.md" "${DOCS_STAGE}/README.md"
+
+QUICKEMU_ARGS=(--vm "${BASE}.conf" --public-dir "${ARTIFACT_DIR}")
+
+if [[ "${1:-}" == "--status" ]]; then
+ QUICKEMU_ARGS+=(--status-quo)
+fi
+
+if ! command -v smbd >/dev/null 2>&1; then
+ cat >&2 <.sh"
+echo " Guest docs (paste): /mnt/protonshift-build/_docs/.md"
+exec quickemu "${QUICKEMU_ARGS[@]}"
diff --git a/vm-test/smoke-checklist.md b/vm-test/smoke-checklist.md
new file mode 100644
index 0000000..c39511d
--- /dev/null
+++ b/vm-test/smoke-checklist.md
@@ -0,0 +1,63 @@
+# Protonshift smoke-test checklist
+
+Run inside the VM after `provision/.sh` completes and the
+AppImage is launched.
+
+## 1. App boots cleanly
+
+- [ ] AppImage starts without an error dialog.
+- [ ] No `python3: command not found` in stderr (we ship our own).
+- [ ] Window decorates correctly and chrome buttons (min/max/close) work.
+
+## 2. Backend is reachable
+
+- [ ] Open the renderer DevTools (`PROTONSHIFT_DEVTOOLS=1` in dev only —
+ in packaged builds, observe via `journalctl --user -f`).
+- [ ] In a guest terminal:
+
+ ```bash
+ pgrep -af 'game_setup_hub.api' | head
+ ```
+
+ → should show one process running from
+ `/tmp/.mount_*/resources/python/runtime/bin/python3`, *not* `/usr/bin/python3`.
+
+## 3. Game source discovery
+
+- [ ] Library list populates (Steam if installed; Heroic / Lutris too if
+ flatpak'd).
+- [ ] Source badges render (Steam / Heroic / Lutris).
+- [ ] Search filters across sources.
+
+## 4. Tool detection
+
+For each tool that exists in this guest, the matching panel should not be
+greyed out:
+
+- [ ] MangoHud
+- [ ] Gamescope
+- [ ] GameMode (`gamemoderun`)
+- [ ] protontricks (if installed)
+
+For tools that are *not* installed, the panel should display the install
+hint and stay disabled — never crash.
+
+## 5. Editors
+
+- [ ] Open the MangoHud config editor (no per-game game required); change
+ a value → write succeeds.
+- [ ] Open the Environment Variables page and toggle a preset → file
+ under `~/.config/environment.d/` updates.
+
+## 6. Graceful exit
+
+- [ ] Close the window.
+- [ ] `pgrep -af game_setup_hub.api` → empty (Python child was reaped).
+
+## 7. Distro-specific
+
+- **Ubuntu/Debian deb**: `sudo dpkg -i /mnt/protonshift-build/protonshift_*.deb`
+ installs cleanly with no missing-deps prompts.
+- **Fedora/openSUSE rpm**: `sudo rpm -i …` likewise.
+- **Bazzite (immutable)**: only the AppImage path applies; deb/rpm should
+ not be attempted.