diff --git a/.agents/skills/nix-config-umu-game/SKILL.md b/.agents/skills/nix-config-umu-game/SKILL.md index 4ebfbf65..8b45666d 100644 --- a/.agents/skills/nix-config-umu-game/SKILL.md +++ b/.agents/skills/nix-config-umu-game/SKILL.md @@ -12,34 +12,63 @@ description: The bundled `scripts/umu-install.nu` renders the files in `scripts/templates/`. It creates a prefix, runs the installer, and writes launchers. Use the Nix DW-Proton default unless research identifies a specific installed release; then select it with `PROTONPATH` and pin that path in the game's `conf`. -**Every per-game detail lives under `~/Games/`**, never in this repo. +**Every per-game detail lives under `~/Gaming/Games/`**, never in this repo. + +## The sandbox + +Games run only inside the umu nixpak sandbox (`hardening/nixpaks/umu.nix`, policy in +`hardening/nixpaks/modules/gaming-policy.nix`). It mounts `~/Gaming` over `$HOME`; no other host +file is visible or writable. `umu-run`, `winetricks`, `bbe`, `mangohud` and `gamescope` exist only +inside it, and `umu-sandbox ` runs a command there. + +**Two views of one path:** `~/Gaming/` on the host is `~/` in the sandbox, so a game +lives at `~/Gaming/Games/` on the host and at `~/Games/` inside. The host has no +`~/Games`: a path written that way is a sandbox path. Typed after `umu-sandbox` in a host shell, +`~/Games//run` expands to exactly that sandbox path. Host environment variables do not reach +the sandbox; put them in `conf`. + +Everything under `~/Gaming` is writable by the games, so **the host never executes a file from it**: + +- launch with `umu-sandbox ~/Games//run` and use `umu-sandbox ~/Games//exec ...`; the + generated desktop entry does the former. Running `run` or `exec` directly refuses to start. +- the installer and `prelaunch` also run through `umu-sandbox`; +- `umu-install.nu` refuses to write through a symlink in the game directory, and uninstalling uses + the repo's `umu-uninstall.nu`, not a script in `~/Gaming`. ## Bundled files - `scripts/umu-install.nu`: installer and template renderer +- `scripts/umu-uninstall.nu`: removes a game's desktop entry and, after confirmation, its directory +- `scripts/common.nu`: name, symlink and sandbox-path helpers shared by both - `scripts/templates/*.tpl`: non-executable templates; placeholders use `@NAME@` - `scripts/tests/test_install.py`: offline regression tests ## Layout -- `~/Games/global.conf`: optional, sourced by every game before its own `conf` -- `~/Games//prefix`: the Wine prefix (`WINEPREFIX`) -- `~/Games//conf`: optional per-game overrides (`ENABLE_MANGOHUD=0`, ...) -- `~/Games//prelaunch`: optional executable, run before every launch -- `~/Games//run`: generated launcher -- `~/Games//exec`: generated helper: `exec [args...]` -- `~/Games//.desktop`: generated desktop entry -- `~/Games//uninstall`: generated: drop the desktop entry and (with `y`) the dir +Host paths; inside the sandbox drop the `Gaming/`. + +- `~/Gaming`: the sandbox's `$HOME`, shared with the other game launchers (umu runtime, Steam, + Heroic, saves written to `$HOME`) +- `~/Gaming/Games/global.conf`: optional, sourced by every game before its own `conf` +- `~/Gaming/Games//prefix`: the Wine prefix (`WINEPREFIX`) +- `~/Gaming/Games//conf`: optional per-game overrides (`ENABLE_MANGOHUD=0`, ...) +- `~/Gaming/Games//prelaunch`: optional executable, run before every launch +- `~/Gaming/Games//run`: generated launcher +- `~/Gaming/Games//exec`: generated helper: `umu-sandbox ~/Games//exec ` +- `~/Gaming/Games//.desktop`: generated desktop entry Run, from the repo root: `nu .agents/skills/nix-config-umu-game/scripts/umu-install.nu [gameid] [setup-args...]` -`` is relative to the prefix and `GAMEID` defaults to `umu-default`. At install, -`PROTONPATH` resolves from `$PROTONPATH`, `$UMU_PROTONPATH` (the Nix-provided DW-Proton), then -Steam's per-user `compatibilitytools.d`. At launch, the generated `run` script checks the current -`$PROTONPATH` and `$UMU_PROTONPATH` before its saved default. A game's `conf` is sourced after those -defaults, so set `PROTONPATH` there to pin a particular installed version across launches. +`` is relative to the prefix and `GAMEID` defaults to `umu-default`. A `` +outside `~/Gaming` is copied into the game directory first; `-` skips the installer and only +rewrites `run`, `exec` and the desktop entry. At install, `PROTONPATH` resolves from `$PROTONPATH`, +`$UMU_PROTONPATH` (the Nix-provided DW-Proton), then Steam's per-user `compatibilitytools.d` in the +sandbox home; it must be a store path or under `~/Gaming`. `run`, `exec` and the desktop entry hold +sandbox paths. At launch, the generated `run` script checks the current `$PROTONPATH` and +`$UMU_PROTONPATH` before its saved default. A game's `conf` is sourced after those defaults, so set +`PROTONPATH` there to pin a particular installed version across launches. ## Procedure @@ -50,21 +79,24 @@ defaults, so set `PROTONPATH` there to pin a particular installed version across (`protondb.com/app/`), Steam Community / r/linux_gaming threads, and GitHub issues for the launcher and Proton. Record whether reports require a Proton family and exact release. If no exact release is required, use the Nix-provided DW-Proton default. If one is required, inspect - `$UMU_PROTONPATH` and installed Steam tools under `~/.local/share/Steam/compatibilitytools.d/` or - `~/.steam/root/compatibilitytools.d/`; select the matching installed path with `PROTONPATH`. - Prefix the installer command with `PROTONPATH=""` and set the same value - in the game's `conf` to pin it across launches; otherwise the runtime Nix `UMU_PROTONPATH` may - take precedence. If the required release is absent from Nix and Steam, stop and ask the user - before downloading it from elsewhere. Do not substitute a different release or use - ProtonPlus/Lutris to fetch one. + `$UMU_PROTONPATH`, the Nix GE-Proton + (`nix build --no-link --print-out-paths .#nixosConfigurations..pkgs.proton-ge-bin.steamcompattool`) + and installed Steam tools under `~/Gaming/.local/share/Steam/compatibilitytools.d/` (the + sandbox's `~/.local/share/Steam/compatibilitytools.d/`); select the matching installed path with + `PROTONPATH`. Prefix the installer command with `PROTONPATH=""` and set + the same value in the game's `conf` to pin it across launches; otherwise the runtime Nix + `UMU_PROTONPATH` may take precedence. If the required release is absent from Nix and Steam, stop + and ask the user before downloading it from elsewhere. Do not substitute a different release or + use ProtonPlus/Lutris to fetch one: a host-side downloader writes outside `~/Gaming`, where the + sandbox cannot see it, and a store path cannot be modified by a game. 2. **Slug.** Pick a lowercase `` (e.g. `wuthering-waves`). 3. **GAMEID.** The umu database maps a title to a `GAMEID` whose protonfixes add CJK fonts, drop the `SteamOS`/`SteamDeck` vars, and keep Wine's `Documents` inside the prefix. **Without it some games save into the host home or the wrong prefix.** Fetch `https://raw.githubusercontent.com/Open-Wine-Components/umu-database/main/umu-database.csv`, match the title, and use its `umu-`; if nothing matches, leave the default. -4. **Download.** `curl -fL -o ~/Games//setup.exe`. The CN store pages are JS/token - driven, so the user normally supplies the URL or the file itself. +4. **Download.** `curl -fL -o ~/Gaming/Games//setup.exe`. The CN store pages are + JS/token driven, so the user normally supplies the URL or the file itself. 5. **Install.** Run the bundled script. The first run also downloads umu's Steam runtime and the protonfix fonts, so it is slow. The installer is usually a GUI the user clicks through, but **extra args after `[gameid]` go to `setup.exe`**, so try a silent flag first (`/S`, `/quiet`, @@ -72,21 +104,23 @@ defaults, so set `PROTONPATH` there to pin a particular installed version across auto-starts the launcher. The script writes `run`/`exec` even if the installer exits non-zero, so a killed installer is not fatal. 6. **Find the launcher.** If `` is only known after install, locate it under - `~/Games//prefix/drive_c` and set `LAUNCHER` in `~/Games//conf`. Re-running the - installer script invokes `setup.exe` again; only do so if that installer is known to handle - reruns. -7. **Per-game fix.** Port step 1 findings into `~/Games//prelaunch` (bash, `chmod +x`): + `~/Gaming/Games//prefix/drive_c` and set `LAUNCHER` in `~/Gaming/Games//conf`. + Re-running the installer script invokes `setup.exe` again; only do so if that installer is known + to handle reruns. +7. **Per-game fix.** Port step 1 findings into `~/Gaming/Games//prelaunch` (bash, `chmod +x`): `winetricks` verbs via `~/Games//exec winetricks `, file patches or registry tweaks - via `~/Games//exec`. Never commit the fix to this repo. + via `~/Games//exec`. `prelaunch` runs in the sandbox, so it uses sandbox paths and calls + `exec` directly; from a host shell, prefix it with `umu-sandbox`. Never commit the fix to this + repo. 8. **Re-run every launch, not once.** A launcher that self-updates overwrites files it patches, so those patches belong in `prelaunch`. -9. **Verify.** `~/Games//run` must open a visible launcher window. MangoHud is enabled by - default with FPS and frame time at the top-left; set `ENABLE_MANGOHUD=0` in `conf` to disable it, - or override `MANGOHUD_CONFIG`. Re-running `prelaunch` must be idempotent. The launcher may then - download the game body itself (tens of GB) -- that is the launcher's job, not this skill's, so - the install is **done** once the window is usable. A crawl usually means the proxy: the launcher - probes the CDN itself and mihomo routes that probe through a distant node, so the download starts - from a far mirror; routing is in the +9. **Verify.** `umu-sandbox ~/Games//run` must open a visible launcher window. MangoHud is + enabled by default with FPS and frame time at the top-left; set `ENABLE_MANGOHUD=0` in `conf` to + disable it, or override `MANGOHUD_CONFIG`. Re-running `prelaunch` must be idempotent. The + launcher may then download the game body itself (tens of GB) -- that is the launcher's job, not + this skill's, so the install is **done** once the window is usable. A crawl usually means the + proxy: the launcher probes the CDN itself and mihomo routes that probe through a distant node, so + the download starts from a far mirror; routing is in the [mihomo README](../../../modules/nixos/desktop/networking/mihomo/README.md). `ENABLE_LOG=1` captures `last-run.log` when something misbehaves. @@ -95,12 +129,13 @@ defaults, so set `PROTONPATH` there to pin a particular installed version across A WebView2/.NET (WPF) launcher that shows an empty or fully transparent window is rendering with `AllowsTransparency`. Fix the DLL, not Wine: -1. `find ~/Games//prefix/drive_c -iname 'launcher_main.dll'` (usually under a `X.Y.Z.W` +1. `find ~/Gaming/Games//prefix/drive_c -iname 'launcher_main.dll'` (usually under a `X.Y.Z.W` version dir). 2. Rename that WPF property in place, keeping the byte length so PE offsets stay valid: - `bbe -e 's/\x12AllowsTransparency/\x09IsEnabled\x1bA\x00\x03AAAAA/' `. -3. Put it in `~/Games//prelaunch` so it is re-applied on every launch, because a launcher - self-update restores the original DLL. Restart the launcher after patching. + `bbe -e 's/\x12AllowsTransparency/\x09IsEnabled\x1bA\x00\x03AAAAA/' `. `bbe` exists only in + the sandbox: prefix it with `umu-sandbox` from a host shell. +3. Put it in `~/Gaming/Games//prelaunch` so it is re-applied on every launch, because a + launcher self-update restores the original DLL. Restart the launcher after patching. ## Half-width tile instead of fullscreen @@ -139,8 +174,8 @@ The string itself is fine and so are the fonts: the same launcher renders 请输入手机号 and 登录 correctly, and the garbled label exists as correct UTF-16 in `KRSDKEx.dll`. The bytes are simply decoded with a single-byte Western codepage. -**This is not the prefix locale.** Setting `LANG=zh_CN.UTF-8` in `~/Games//conf` does change -the prefix (verified in this repo: `"ACP"` went `1252` -> `936` and `"LocaleName"` `en-US` -> +**This is not the prefix locale.** Setting `LANG=zh_CN.UTF-8` in `~/Gaming/Games//conf` does +change the prefix (verified in this repo: `"ACP"` went `1252` -> `936` and `"LocaleName"` `en-US` -> `zh-CN`), and the garbling stayed **byte-for-byte identical**. The launcher decodes those bytes as Latin-1 no matter what Windows codepage it is told to use, so this is an upstream bug in its own text pipeline: report it, or accept it -- the launcher still works. `LANG` is nevertheless the lever @@ -156,40 +191,64 @@ installers honour it; try `/quiet` or `--silent` too, then fall back to the GUI. ## Desktop entry and persistence The script also writes `~/.local/share/applications/.desktop` (and a copy at -`~/Games//.desktop`), so the game shows up in the desktop launcher. Under an -impermanence setup both `~/Games` and `~/.local/share/applications` must be in the host's +`~/Gaming/Games//.desktop`), so the game shows up in the desktop launcher. The host copy +is written by the installer on the host; the sandbox cannot see or change it. Under an impermanence +setup both `~/Gaming` and `~/.local/share/applications` must be in the host's `preservation.preserveAt` list, in `hosts//preservation.nix`. ## Uninstall -Run `~/Games//uninstall`: it removes the desktop entry and then asks before deleting -`~/Games/` (prefix + game files). To keep the game and only drop the menu entry, delete the -`.desktop` file by hand. +From the repo root: + +`nu .agents/skills/nix-config-umu-game/scripts/umu-uninstall.nu ` + +It removes the desktop entry and then asks before deleting `~/Gaming/Games/` (prefix + game +files). To keep the game and only drop the menu entry, delete the `.desktop` file by hand. + +## Games installed before the sandbox + +Move `~/Games` to `~/Gaming/Games` and the old `~/.local/share/umu` to `~/Gaming/.local/share/umu` +(on the same filesystem, so both are renames; umu can also download its runtime again). Paths inside +the game directories stay valid, because the sandbox sees them at the old `~/Games/`. The old +desktop entries execute `run` on the host, and an old `uninstall` script is host-executed too, so +rewrite the launchers without re-running the installer, passing the `LAUNCHER` and `GAMEID` from the +old `run`: + +`nu .agents/skills/nix-config-umu-game/scripts/umu-install.nu - ` + +This also deletes the old `uninstall`. ## Optimize the launch -`~/Games//conf` (or `~/Games/global.conf`) is a shell file the generated `run` sources. -Defaults are `ENABLE_MANGOHUD=1`, `MANGOHUD_CONFIG="fps=1,frametime=1,position=top-left"`, +`~/Gaming/Games//conf` (or `~/Gaming/Games/global.conf`) is a shell file the generated `run` +sources. Defaults are `ENABLE_MANGOHUD=1`, `MANGOHUD_CONFIG="fps=1,frametime=1,position=top-left"`, `ENABLE_GAMEMODE=0`, `ENABLE_GAMESCOPE=0`, `GAMESCOPE_ARGS="-f"`, and `ENABLE_LOG=0`. Set `ENABLE_MANGOHUD=0` to hide the overlay, `ENABLE_GAMESCOPE=1` + -`GAMESCOPE_ARGS="-f -w 3840 -h 2160"` for the handheld / multi-monitor case, `ENABLE_GAMEMODE=1` for -GameMode, and `ENABLE_LOG=1` to capture the run. A dGPU wrapper still goes outside: -`nvidia-offload ~/Games//run`. Shader caches are kept in `~/Games//shader-cache`. +`GAMESCOPE_ARGS="-f -w 3840 -h 2160"` for the handheld / multi-monitor case, and `ENABLE_LOG=1` to +capture the run. Gamescope runs inside the sandbox without `CAP_SYS_NICE`, so it cannot raise its +own priority. `ENABLE_GAMEMODE=1` has no effect: `gamemoderun` is not in the sandbox, whose PID +namespace and D-Bus filter keep `gamemoded` from managing the game. A host wrapper such as +`nvidia-offload` does not work either, because the sandbox clears the environment; export its +variables (`__NV_PRIME_RENDER_OFFLOAD=1`, ...) in `conf` instead. Shader caches are kept in +`~/Gaming/Games//shader-cache`. ## Escape hatches -- `~/Games//exec [args...]` runs anything in the prefix with the right env: an - absolute path to a game exe or repair tool, or a bare Wine tool (`winecfg`, `explorer`, `regedit`, - `uninstaller`), which it runs as `umu-run `. Both forms are handed to - `umu-run`, so they execute inside umu's Steam runtime container -- that is what makes them work on - a non-FHS distro like NixOS, where Proton's wine cannot start on its own. -- `~/Games//run ` passes extra args to the launcher. +- `umu-sandbox ~/Games//exec [args...]` runs anything in the prefix with the + right env: an absolute path to a game exe or repair tool, or a bare Wine tool (`winecfg`, + `explorer`, `regedit`, `uninstaller`), which it runs as `umu-run `. Both + forms are handed to `umu-run`, so they execute inside umu's Steam runtime container -- that is + what makes them work on a non-FHS distro like NixOS, where Proton's wine cannot start on its own. +- `umu-sandbox ~/Games//run ` passes extra args to the launcher. - Kill a stuck prefix with `pkill -f '/Games//prefix'`, or - `rm -f ~/Games//prefix/pfx.lock`. + `rm -f ~/Gaming/Games//prefix/pfx.lock`. ## Common mistakes -- Committing a game name, URL, or patch to this repo instead of `~/Games//`. +- Launching again while the first launch after a Proton update or a moved umu runtime shows no + window: it is preparing steamrt4 and upgrading the prefix, which can take a minute, and a second + launch blocks on the same locks. Wait, or `pkill -f '/Games//prefix'` and launch once. +- Committing a game name, URL, or patch to this repo instead of `~/Gaming/Games//`. - Patching once instead of in `prelaunch` -- the next launcher self-update undoes it. - Dropping the `GAMEID` -- the in-game CJK fonts and the save location go wrong. - Installing more CJK fonts when the text is mojibake -- decode the bytes first (see above). @@ -198,7 +257,12 @@ GameMode, and `ENABLE_LOG=1` to capture the run. A dGPU wrapper still goes outsi - Running a Wine tool without umu. `umu-run winecfg` does not work -- umu only special-cases `winetricks` -- and `$PROTONPATH/files/bin/wine winecfg` fails on any non-FHS distro (on NixOS the 32-bit loader it needs lives only inside the Steam runtime container). Use - `~/Games//exec winecfg`; `exec` goes through `umu-run`, which provides that container. + `umu-sandbox ~/Games//exec winecfg`; `exec` goes through `umu-run`, which provides that + container. +- Running `run`, `exec` or `prelaunch` from a host shell, or adding a host-side helper that executes + a file under `~/Gaming`: games can rewrite those files. Go through `umu-sandbox`. +- Pointing `PROTONPATH`, a launcher or an installer at a host path outside `~/Gaming`: the sandbox + cannot see it. Inside `conf` and `prelaunch`, write sandbox paths (`~/Games/`). - Waiting for the game download before calling the install done: the launcher's own download is out of scope here. - On NixOS a game needing 32-bit or Vulkan needs `hardware.graphics.enable32Bit`, and Steam is a @@ -212,8 +276,9 @@ From the repo root: python3 .agents/skills/nix-config-umu-game/scripts/tests/test_install.py ``` -Requires `python3`, `nu`, `bash`, and `shellcheck`. Uses temporary homes and a stub `umu-run`; no -game downloads, Wine windows, or changes to existing games. It checks template permissions, rendered -shell syntax and lint, installer arguments and failures, configuration precedence, prelaunch, -logging, launch wrappers, literal paths, runtime Proton overrides, tool routing, and uninstall -confirmation. +Requires `python3`, `nu`, `bash`, and `shellcheck`. Uses temporary homes and stub `umu-run` and +`umu-sandbox`; no game downloads, Wine windows, or changes to existing games. It checks template +permissions, rendered shell syntax and lint, installer arguments and failures, configuration +precedence, prelaunch, logging, launch wrappers, literal paths, runtime Proton overrides, tool +routing, uninstall confirmation, the sandbox guard, host-to-sandbox path translation, and refusal to +write through planted symlinks. diff --git a/.agents/skills/nix-config-umu-game/scripts/common.nu b/.agents/skills/nix-config-umu-game/scripts/common.nu new file mode 100644 index 00000000..0f4b956e --- /dev/null +++ b/.agents/skills/nix-config-umu-game/scripts/common.nu @@ -0,0 +1,53 @@ +# Helpers shared by umu-install.nu and umu-uninstall.nu. +# +# Games only run in the umu sandbox (hardening/nixpaks/umu.nix), which mounts +# ~/Gaming over $HOME: a game installed at ~/Gaming/Games/ on the host is +# ~/Games/ inside. Everything under ~/Gaming is writable by the games, so +# these scripts, which run on the host, treat it as untrusted: they refuse +# symlinks before writing or deleting there, and never execute a file from it. + +# The sandbox home, as seen from the host. +export def gaming-dir [] { + $nu.home-dir | path join "Gaming" +} + +# Where games are installed, as seen from the host. +export def games-dir [] { + gaming-dir | path join "Games" +} + +export def reject-symlink [path: string, label: string] { + let kind = (try { $path | path type } catch { "missing" }) + if $kind == "symlink" { + print --stderr $"error: ($label) must not be a symlink: ($path)" + exit 2 + } +} + +# Both roots must be real directories before anything below them is touched. +export def reject-symlinked-roots [] { + reject-symlink (gaming-dir) "Gaming directory" + reject-symlink (games-dir) "Games directory" +} + +export def check-name [name: string] { + if not ($name =~ '^[a-z0-9]+(-[a-z0-9]+)*$') { + print --stderr "error: name must be a lowercase slug (letters, digits, hyphens)" + exit 2 + } +} + +# The path as the sandbox sees it, or null if the sandbox cannot see it: +# ~/Gaming becomes $HOME, and the Nix store is read-only. +export def sandbox-path [path: string] { + let full = ($path | path expand) + let store = ($env.NIX_STORE_DIR? | default "/nix/store") + let gaming = (gaming-dir) + if $full == $gaming or ($full | str starts-with $"($gaming)/") { + $full | str replace $gaming $nu.home-dir + } else if ($full | str starts-with $"($store)/") { + $full + } else { + null + } +} diff --git a/.agents/skills/nix-config-umu-game/scripts/templates/exec.sh.tpl b/.agents/skills/nix-config-umu-game/scripts/templates/exec.sh.tpl index 4e810f83..dc34c288 100644 --- a/.agents/skills/nix-config-umu-game/scripts/templates/exec.sh.tpl +++ b/.agents/skills/nix-config-umu-game/scripts/templates/exec.sh.tpl @@ -1,7 +1,14 @@ #!/usr/bin/env bash # Generated by the nix-config-umu-game skill -- run a tool in this prefix. -# exec [args...] e.g. exec winecfg / exec explorer +# umu-sandbox ~/Games//exec [args...] e.g. winecfg set -euo pipefail +# The host must never execute this game-writable file: launch it with +# `umu-sandbox `, as the desktop entry does. This check only catches +# mistakes; the sandbox is the boundary. +if [ "${UMU_SANDBOX:-}" != 1 ]; then + echo "run this inside the umu sandbox: umu-sandbox $0" >&2 + exit 1 +fi export WINEPREFIX=@WINEPREFIX@ DEFAULT_PROTONPATH=@PROTONPATH@ export PROTONPATH="${PROTONPATH:-${UMU_PROTONPATH:-$DEFAULT_PROTONPATH}}" diff --git a/.agents/skills/nix-config-umu-game/scripts/templates/launcher.desktop.tpl b/.agents/skills/nix-config-umu-game/scripts/templates/launcher.desktop.tpl index 9393ac01..537b37f9 100644 --- a/.agents/skills/nix-config-umu-game/scripts/templates/launcher.desktop.tpl +++ b/.agents/skills/nix-config-umu-game/scripts/templates/launcher.desktop.tpl @@ -2,8 +2,7 @@ Type=Application Name=@DISPLAY_NAME@ Comment=Windows game launcher installed via umu -Exec=@RUN@ -Path=@DIR@ +Exec=umu-sandbox @RUN@ Terminal=false Categories=Game; StartupNotify=false diff --git a/.agents/skills/nix-config-umu-game/scripts/templates/run.sh.tpl b/.agents/skills/nix-config-umu-game/scripts/templates/run.sh.tpl index 4e1ae9d4..4356e632 100644 --- a/.agents/skills/nix-config-umu-game/scripts/templates/run.sh.tpl +++ b/.agents/skills/nix-config-umu-game/scripts/templates/run.sh.tpl @@ -1,6 +1,13 @@ #!/usr/bin/env bash # Generated by the nix-config-umu-game skill -- re-run its script to regenerate. set -euo pipefail +# The host must never execute this game-writable file: launch it with +# `umu-sandbox `, as the desktop entry does. This check only catches +# mistakes; the sandbox is the boundary. +if [ "${UMU_SANDBOX:-}" != 1 ]; then + echo "run this inside the umu sandbox: umu-sandbox $0" >&2 + exit 1 +fi HERE="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)" # Defaults; ~/Games/global.conf and this game conf/ override them. WINEPREFIX=@WINEPREFIX@ diff --git a/.agents/skills/nix-config-umu-game/scripts/templates/uninstall.sh.tpl b/.agents/skills/nix-config-umu-game/scripts/templates/uninstall.sh.tpl deleted file mode 100644 index 5e8e9ef8..00000000 --- a/.agents/skills/nix-config-umu-game/scripts/templates/uninstall.sh.tpl +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env bash -# Generated by the nix-config-umu-game skill. -set -euo pipefail -GAMES_DIR_PATH=@GAMES_DIR@ -if [[ -L "$GAMES_DIR_PATH" ]]; then - echo "refusing symlinked Games directory: $GAMES_DIR_PATH" >&2 - exit 1 -fi -GAMES_DIR="$(cd "$GAMES_DIR_PATH" && pwd -P)" -NAME=@NAME@ -HERE="$(cd "$(dirname "$(readlink -f "$0")")" && pwd -P)" -if [[ "$HERE" != "$GAMES_DIR/$NAME" ]]; then - echo "refusing to remove path outside $GAMES_DIR/$NAME: $HERE" >&2 - exit 1 -fi -rm -f @DESKTOP_FILE@ -echo "desktop entry removed" -printf "Delete %s and all its files? [y/N] " "$HERE" -read -r ans -if [ "$ans" = y ] || [ "$ans" = Y ]; then - cd / - rm -rf "$HERE" - echo "removed $HERE" -else - echo "kept $HERE" -fi diff --git a/.agents/skills/nix-config-umu-game/scripts/tests/test_install.py b/.agents/skills/nix-config-umu-game/scripts/tests/test_install.py index 668172d3..a321c996 100644 --- a/.agents/skills/nix-config-umu-game/scripts/tests/test_install.py +++ b/.agents/skills/nix-config-umu-game/scripts/tests/test_install.py @@ -20,7 +20,7 @@ class InstallerTests(unittest.TestCase): bin_dir = root / "bin" bin_dir.mkdir() self.bin_dir = bin_dir - self.proton = root / "proton" + self.proton = root / "store/proton" (self.proton / "files/bin").mkdir(parents=True) (self.proton / "proton").touch() self.log = root / "call" @@ -31,6 +31,14 @@ printf "%s\n" "$WINEPREFIX" "$PROTONPATH" "$GAMEID" "$@" > "$CALL_LOG" exit "${STUB_EXIT:-0}" """) stub.chmod(0o755) + self.sandbox_log = root / "sandbox-call" + sandbox = bin_dir / "umu-sandbox" + sandbox.write_text("""#!/usr/bin/env bash +pwd >> "$SANDBOX_LOG" +export UMU_SANDBOX=1 +exec "$@" +""") + sandbox.chmod(0o755) self.mangohud_log = root / "mangohud-call" hud = bin_dir / "mangohud" hud.write_text("""#!/usr/bin/env bash @@ -43,22 +51,39 @@ exec "$@" HOME=str(home), PROTONPATH=str(self.proton), CALL_LOG=str(self.log), + SANDBOX_LOG=str(self.sandbox_log), + # Stands in for /nix/store, which the sandbox can always read. + NIX_STORE_DIR=str(root / "store"), MANGOHUD_LOG=str(self.mangohud_log), PATH=str(bin_dir) + os.pathsep + os.environ["PATH"], ) - for key in ("XDG_DATA_HOME", "BASH_ENV", "ENV"): + for key in ("XDG_DATA_HOME", "BASH_ENV", "ENV", "UMU_PROTONPATH", "UMU_SANDBOX"): self.env.pop(key, None) - self.game = home / "Games/test-game" + self.use_home(home) self.launcher = "drive_c/Program Files/Test/launcher.exe" self.gameid = "umu-test" - def invoke_install(self, name, **extra): + def use_home(self, home): + self.home = home + self.env["HOME"] = str(home) + # The host side: umu-install.nu writes here. + self.games = home / "Gaming/Games" + self.game = self.games / "test-game" + # The sandbox mounts ~/Gaming over $HOME. The stub umu-sandbox cannot + # remap paths, so a symlink makes the sandbox paths written into the + # launchers resolve on the host. + self.sandbox_game = home / "Games/test-game" + (home / "Games").symlink_to(self.games, target_is_directory=True) + + def invoke_install(self, name, setup="setup.exe", **extra): + if setup == "setup.exe": + (self.home / setup).touch() return subprocess.run( [ "nu", str(SCRIPTS / "umu-install.nu"), name, - "setup.exe", + setup, self.launcher, self.gameid, "/S", @@ -74,9 +99,19 @@ exec "$@" result = self.invoke_install("test-game", **extra) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + def uninstall(self, name="test-game", input="n\n"): + return subprocess.run( + ["nu", str(SCRIPTS / "umu-uninstall.nu"), name], + env=self.env, + input=input, + capture_output=True, + text=True, + check=False, + ) + def call(self, name, *args, input=None): result = subprocess.run( - [str(self.game / name), *args], + [str(self.bin_dir / "umu-sandbox"), str(self.sandbox_game / name), *args], env=self.env, input=input, capture_output=True, @@ -87,7 +122,7 @@ exec "$@" return self.log.read_text().splitlines() def test_name_must_be_one_slug_component_before_any_install_work(self): - games = self.home / "Games" + games = self.home / "Gaming" outside = self.home / "escaped" invalid_names = [str(outside), "../escaped", "nested/game", ".."] @@ -100,40 +135,37 @@ exec "$@" self.assertFalse(games.exists()) self.assertFalse(outside.exists()) - def test_install_rejects_symlinked_games_root(self): - outside = self.home / "outside-games" - outside.mkdir() - (self.home / "Games").symlink_to(outside, target_is_directory=True) + def test_install_rejects_symlinked_roots(self): + for root in (self.home / "Gaming", self.games): + with self.subTest(root=root.name): + root.parent.mkdir(parents=True, exist_ok=True) + outside = self.home / f"outside-{root.name}" + outside.mkdir() + root.symlink_to(outside, target_is_directory=True) - result = self.invoke_install("test-game") + result = self.invoke_install("test-game") - self.assertNotEqual(result.returncode, 0) - self.assertIn("symlink", result.stderr) - self.assertFalse(self.log.exists()) - self.assertEqual(list(outside.iterdir()), []) + self.assertNotEqual(result.returncode, 0) + self.assertIn("symlink", result.stderr) + self.assertFalse(self.log.exists()) + self.assertEqual(list(outside.iterdir()), []) + root.unlink() def test_uninstall_refuses_games_root_replaced_by_symlink(self): self.install() - games = self.home / "Games" + games = self.games relocated = self.home / "relocated-games" games.rename(relocated) games.symlink_to(relocated, target_is_directory=True) - result = subprocess.run( - [str(self.game / "uninstall")], - env=self.env, - input="y\n", - text=True, - capture_output=True, - check=False, - ) + result = self.uninstall(input="y\n") self.assertNotEqual(result.returncode, 0) self.assertTrue((relocated / "test-game/run").exists()) def test_install_rejects_symlinked_game_directory(self): - games = self.home / "Games" - games.mkdir() + games = self.games + games.mkdir(parents=True) outside = self.home / "outside" outside.mkdir() (games / "test-game").symlink_to(outside, target_is_directory=True) @@ -146,7 +178,7 @@ exec "$@" self.assertEqual(list(outside.iterdir()), []) def test_install_rejects_symlinked_prefix(self): - game = self.home / "Games/test-game" + game = self.game game.mkdir(parents=True) outside = self.home / "outside" outside.mkdir() @@ -160,7 +192,7 @@ exec "$@" self.assertEqual(list(outside.iterdir()), []) def test_install_rejects_dangling_symlink_prefix(self): - game = self.home / "Games/test-game" + game = self.game game.mkdir(parents=True) outside = self.home / "missing-prefix-target" (game / "prefix").symlink_to(outside, target_is_directory=True) @@ -178,14 +210,7 @@ exec "$@" self.game.rename(outside) self.game.symlink_to(outside, target_is_directory=True) - result = subprocess.run( - [str(self.game / "uninstall")], - env=self.env, - input="y\n", - text=True, - capture_output=True, - check=False, - ) + result = self.uninstall(input="y\n") self.assertNotEqual(result.returncode, 0) self.assertTrue(outside.exists()) @@ -195,7 +220,6 @@ exec "$@" for name in ( "run.sh.tpl", "exec.sh.tpl", - "uninstall.sh.tpl", "launcher.desktop.tpl", ): path = SCRIPTS / "templates" / name @@ -210,7 +234,7 @@ exec "$@" hud_call = self.mangohud_log.read_text().splitlines() self.assertEqual(hud_call[0], "fps=1,frametime=1,position=top-left") self.assertEqual( - hud_call[1:], ["umu-run", str(self.game / "prefix" / self.launcher)] + hud_call[1:], ["umu-run", str(self.sandbox_game / "prefix" / self.launcher)] ) def test_run_can_disable_mangohud_per_game(self): @@ -220,19 +244,25 @@ exec "$@" values = self.call("run") self.assertFalse(self.mangohud_log.exists()) - self.assertEqual(values[3:], [str(self.game / "prefix" / self.launcher)]) + self.assertEqual(values[3:], [str(self.sandbox_game / "prefix" / self.launcher)]) def test_generation_and_setup_arguments(self): self.install() - self.assertEqual(self.log.read_text().splitlines()[3:], ["setup.exe", "/S"]) - for name in ("run", "exec", "uninstall"): + self.assertEqual( + self.log.read_text().splitlines()[3:], + [str(self.sandbox_game / "setup.exe"), "/S"], + ) + self.assertFalse((self.game / "uninstall").exists()) + for name in ("run", "exec"): path = self.game / name self.assertTrue(os.access(path, os.X_OK)) subprocess.run(["bash", "-n", str(path)], check=True) subprocess.run(["shellcheck", str(path)], check=True) desktop = self.home / ".local/share/applications/test-game.desktop" self.assertIn("Name=Test Game", desktop.read_text()) - self.assertIn("Exec=" + str(self.game / "run"), desktop.read_text()) + self.assertIn( + "Exec=umu-sandbox " + str(self.sandbox_game / "run"), desktop.read_text() + ) self.assertEqual( desktop.read_bytes(), (self.game / "test-game.desktop").read_bytes() ) @@ -252,7 +282,8 @@ exec "$@" values = self.call("run", "--game-arg") self.assertEqual(values[2], "local") self.assertEqual( - values[3:], [str(self.game / "prefix" / self.launcher), "--game-arg"] + values[3:], + [str(self.sandbox_game / "prefix" / self.launcher), "--game-arg"], ) self.assertTrue((self.game / "patched").exists()) self.assertTrue((self.game / "last-run.log").exists()) @@ -261,19 +292,18 @@ exec "$@" self.launcher = "drive_c/quote'\"$HOME/launcher.exe" self.install() values = self.call("run") - self.assertEqual(values[3:], [str(self.game / "prefix" / self.launcher)]) + self.assertEqual(values[3:], [str(self.sandbox_game / "prefix" / self.launcher)]) def test_values_containing_placeholder_text_are_not_rerendered(self): - self.home = self.home.parent / "@GAMEID@" - self.home.mkdir() - self.env["HOME"] = str(self.home) - self.game = self.home / "Games/test-game" + home = self.home.parent / "@GAMEID@" + home.mkdir() + self.use_home(home) self.launcher = "drive_c/@PROTONPATH@/launcher.exe" self.gameid = "$(printf injected)" self.install() values = self.call("run") - self.assertEqual(values[0], str(self.game / "prefix")) - self.assertEqual(values[3], str(self.game / "prefix" / self.launcher)) + self.assertEqual(values[0], str(self.sandbox_game / "prefix")) + self.assertEqual(values[3], str(self.sandbox_game / "prefix" / self.launcher)) def test_game_conf_pins_installed_proton_over_system_default(self): self.install() @@ -334,19 +364,99 @@ exec "$@" def test_uninstall_confirmation(self): self.install() desktop = self.home / ".local/share/applications/test-game.desktop" - self.call("uninstall", input="n\n") + result = self.uninstall(input="n\n") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) self.assertTrue(self.game.exists()) self.assertFalse(desktop.exists()) - subprocess.run( - [str(self.game / "uninstall")], - env=self.env, - input="y\n", - text=True, - check=True, - capture_output=True, - ) + result = self.uninstall(input="y\n") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) self.assertFalse(self.game.exists()) + def test_run_refuses_to_start_outside_the_sandbox(self): + self.install() + self.log.unlink() + for name in ("run", "exec"): + with self.subTest(name=name): + result = subprocess.run( + [str(self.game / name), "winecfg"], + env=self.env, + capture_output=True, + text=True, + check=False, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("umu-sandbox", result.stderr) + self.assertFalse(self.log.exists()) + + def test_installer_and_prelaunch_run_in_the_sandbox(self): + self.game.mkdir(parents=True) + prelaunch = self.game / "prelaunch" + prelaunch.write_text('#!/usr/bin/env bash\n[ "$UMU_SANDBOX" = 1 ]\n') + prelaunch.chmod(0o755) + self.install() + # Both start in the home, which exists on the host and in the sandbox. + self.assertEqual( + self.sandbox_log.read_text().splitlines(), [str(self.home), str(self.home)] + ) + + def test_rewrite_launchers_without_installer(self): + result = self.invoke_install("test-game", setup="-") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertFalse(self.log.exists()) + self.assertTrue((self.game / "run").exists()) + + def test_old_uninstall_script_is_removed(self): + self.game.mkdir(parents=True) + (self.game / "uninstall").write_text("#!/bin/sh\n") + self.install() + self.assertFalse((self.game / "uninstall").exists()) + + def test_install_refuses_to_write_through_planted_symlinks(self): + target = self.home / "bashrc" + target.write_text("host file\n") + for name in ("run", "exec", "test-game.desktop", "setup.exe"): + with self.subTest(name=name): + self.game.mkdir(parents=True, exist_ok=True) + link = self.game / name + link.unlink(missing_ok=True) + link.symlink_to(target) + result = self.invoke_install("test-game") + self.assertNotEqual(result.returncode, 0) + self.assertIn("symlink", result.stderr) + self.assertEqual(target.read_text(), "host file\n") + link.unlink() + + def test_installer_in_the_sandbox_home_is_used_in_place(self): + setup = self.home / "Gaming/Downloads/setup.exe" + setup.parent.mkdir(parents=True) + setup.touch() + self.install_from(setup) + self.assertEqual( + self.log.read_text().splitlines()[3], str(self.home / "Downloads/setup.exe") + ) + self.assertFalse((self.game / "setup.exe").exists()) + + def test_steam_proton_fallback_is_translated(self): + proton = self.home / "Gaming/.local/share/Steam/compatibilitytools.d/dwproton" + (proton / "files/bin").mkdir(parents=True) + (proton / "proton").touch() + self.env.pop("PROTONPATH") + self.install() + self.assertEqual( + self.log.read_text().splitlines()[1], + str(self.home / ".local/share/Steam/compatibilitytools.d/dwproton"), + ) + + def test_proton_outside_the_sandbox_is_rejected(self): + self.env["NIX_STORE_DIR"] = str(self.home / "store") + result = self.invoke_install("test-game") + self.assertNotEqual(result.returncode, 0) + self.assertIn("cannot see PROTONPATH", result.stderr) + self.assertFalse(self.log.exists()) + + def install_from(self, setup): + result = self.invoke_install("test-game", setup=str(setup)) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) if __name__ == "__main__": unittest.main() diff --git a/.agents/skills/nix-config-umu-game/scripts/umu-install.nu b/.agents/skills/nix-config-umu-game/scripts/umu-install.nu index fb7c3e1d..c2bb56fe 100644 --- a/.agents/skills/nix-config-umu-game/scripts/umu-install.nu +++ b/.agents/skills/nix-config-umu-game/scripts/umu-install.nu @@ -1,13 +1,13 @@ #!/usr/bin/env nu # # Install a Windows game launcher into its own umu-launcher Wine prefix, and -# generate launch/exec/desktop/uninstall helpers for it. +# generate launch/exec/desktop helpers for it. # # umu-launcher is the Proton backend Lutris/Heroic use: it creates and runs a # detached Wine prefix without Steam, and a GAMEID from the umu database pulls # in that game's protonfixes (fonts, env quirks, save location, ...). This # installer is fully generic -- it knows no game names. Every per-game detail -# lives under ~/Games//: +# lives under ~/Gaming/Games// (~/Games// inside the sandbox): # # prefix the Wine prefix (WINEPREFIX) # prelaunch optional executable, run before every launch @@ -15,15 +15,21 @@ # run generated launcher # exec generated helper: run any exe/wine tool in the prefix # .desktop generated desktop entry -# uninstall generated: drop the desktop entry and (with confirmation) this dir # -# ~/Games/global.conf, if present, is sourced by every run script before the -# game's own conf; both may set ENABLE_GAMEMODE, ENABLE_GAMESCOPE, +# Games only run in the umu sandbox (hardening/nixpaks/umu.nix), which mounts +# ~/Gaming over $HOME. The installer, prelaunch, run and exec all execute inside +# it via `umu-sandbox`, so the paths written into run, exec and the desktop +# entry are sandbox paths; this script, on the host, only writes files. +# umu-uninstall.nu removes a game. +# +# ~/Gaming/Games/global.conf, if present, is sourced by every run script before +# the game's own conf; both may set ENABLE_GAMEMODE, ENABLE_GAMESCOPE, # GAMESCOPE_ARGS, ENABLE_LOG, PROTONPATH, GAMEID, LAUNCHER, ... # # PROTONPATH is resolved from $PROTONPATH, then $UMU_PROTONPATH (exported by # modules/nixos/desktop/gaming.nix from pkgs.dwproton-bin), then Steam's -# per-user compatibilitytools.d. +# per-user compatibilitytools.d in the sandbox home. It must be visible in the +# sandbox: the Nix store or ~/Gaming. # # nu .agents/skills/nix-config-umu-game/scripts/umu-install.nu wuthering-waves \ # ~/Downloads/WutheringWaves_setup_3.5.0.exe \ @@ -31,11 +37,10 @@ # umu-3513350 # # Extra arguments after [gameid] are passed to the installer, e.g. "/S" or -# "/quiet" for a silent install. +# "/quiet" for a silent install. A of "-" skips the installer and +# only rewrites the launchers. -def games-dir [] { - $nu.home-dir | path join "Games" -} +use common.nu * # First candidate that actually contains a proton script. def resolve-protonpath [] { @@ -47,7 +52,7 @@ def resolve-protonpath [] { if (not ($umu | is-empty)) and (($umu | path join "proton") | path exists) { return $umu } - let steam = ($nu.home-dir | path join ".local/share/Steam/compatibilitytools.d/dwproton") + let steam = ((gaming-dir) | path join ".local/share/Steam/compatibilitytools.d/dwproton") if (($steam | path join "proton") | path exists) { return $steam } @@ -55,14 +60,6 @@ def resolve-protonpath [] { } # "wuthering-waves" -> "Wuthering Waves" -def reject-symlink [path: string, label: string] { - let kind = (try { $path | path type } catch { "missing" }) - if $kind == "symlink" { - print --stderr $"error: ($label) must not be a symlink: ($path)" - exit 2 - } -} - def display-name [name: string] { $name | split row "-" | each { |w| $w | str capitalize } | str join " " } @@ -70,16 +67,17 @@ def display-name [name: string] { def usage [] { print "Usage: .agents/skills/nix-config-umu-game/scripts/umu-install.nu [gameid] [setup-args...]" print "" - print " directory under ~/Games (e.g. wuthering-waves)" - print " installer to run through umu (a host path)" + print " directory under ~/Gaming/Games (e.g. wuthering-waves)" + print " installer to run through umu (copied into the game directory" + print " unless the sandbox sees it), or - to skip it" print " launcher to run later, relative to the prefix" print " [gameid] umu database id for protonfixes (default umu-default)" print " [setup-args] extra args for setup.exe, e.g. /S or /quiet" print "" - print "Writes ~/Games//{run,exec,.desktop,uninstall}. A per-game" - print "fix goes in ~/Games//prelaunch; overrides go in ~/Games//conf" - print "or ~/Games/global.conf. PROTONPATH comes from $PROTONPATH or" - print "$UMU_PROTONPATH." + print "Writes ~/Gaming/Games//{run,exec,.desktop}; launch with" + print "umu-sandbox ~/Games//run (a sandbox path). A per-game fix goes in" + print "prelaunch; overrides go in conf or ~/Gaming/Games/global.conf. PROTONPATH" + print "comes from $PROTONPATH or $UMU_PROTONPATH." } # Quote substituted shell values as data, including literal apostrophes. @@ -94,41 +92,45 @@ def render-template [name: string, values: record] { } } -def write-run [name: string, launcher: string] { - let dir = ((games-dir) | path join $name) +def write-run [dir: string, launcher: string] { let values = { WINEPREFIX: (shell-quote $env.WINEPREFIX) PROTONPATH: (shell-quote $env.PROTONPATH) GAMEID: (shell-quote $env.GAMEID) LAUNCHER: (shell-quote $launcher) } - mkdir $dir for file in [run exec] { let target = ($dir | path join $file) + reject-symlink $target $file render-template $"($file).sh.tpl" $values | save --force $target ^chmod +x $target } } -def write-integration [name: string] { - let dir = ((games-dir) | path join $name) +def write-integration [name: string, dir: string] { let apps = ($nu.home-dir | path join ".local/share/applications") let entry = ($apps | path join $"($name).desktop") + let copy = ($dir | path join $"($name).desktop") let desktop = (render-template "launcher.desktop.tpl" { DISPLAY_NAME: (display-name $name) - RUN: ($dir | path join "run") - DIR: $dir + RUN: (sandbox-path ($dir | path join "run")) }) mkdir $apps - $desktop | save --force ($dir | path join $"($name).desktop") + reject-symlink $copy "desktop entry copy" + $desktop | save --force $copy $desktop | save --force $entry - let uninstall = ($dir | path join "uninstall") - render-template "uninstall.sh.tpl" { - DESKTOP_FILE: (shell-quote $entry) - GAMES_DIR: (shell-quote (games-dir)) - NAME: (shell-quote $name) - } | save --force $uninstall - ^chmod +x $uninstall +} + +# Run a command in the umu sandbox with the game's environment. Start from the +# host home: inside, that path is the sandbox home, so the working directory +# exists on both sides. +def in-sandbox [...command: string] { + cd $nu.home-dir + (^umu-sandbox env + $"WINEPREFIX=($env.WINEPREFIX)" + $"PROTONPATH=($env.PROTONPATH)" + $"GAMEID=($env.GAMEID)" + ...$command) } def main [...args: string] { @@ -137,48 +139,72 @@ def main [...args: string] { exit 1 } let name = ($args | get 0) - if not ($name =~ '^[a-z0-9]+(-[a-z0-9]+)*$') { - print --stderr "error: name must be a lowercase slug (letters, digits, hyphens)" - exit 2 - } + check-name $name let setup = ($args | get 1) let launcher = ($args | get 2) let gameid = (if ($args | length) >= 4 { $args | get 3 } else { "umu-default" }) let setupArgs = (if ($args | length) >= 5 { $args | skip 4 } else { [] }) - let root = (games-dir) - reject-symlink $root "Games directory" - let dir = ($root | path join $name) + reject-symlinked-roots + let dir = ((games-dir) | path join $name) let prefix = ($dir | path join "prefix") reject-symlink $dir "game directory" reject-symlink $prefix "Wine prefix" + if $setup != "-" and not ($setup | path exists) { + print --stderr $"error: installer not found: ($setup)" + exit 2 + } let protonpath = (resolve-protonpath) if $protonpath == null { print --stderr "error: no Proton found. Set PROTONPATH or UMU_PROTONPATH, or rebuild NixOS so modules/nixos/desktop/gaming.nix exports UMU_PROTONPATH." exit 1 } - $env.PROTONPATH = $protonpath + let visibleProton = (sandbox-path $protonpath) + if $visibleProton == null { + print --stderr $"error: the umu sandbox cannot see PROTONPATH ($protonpath); use a Nix store path or one under ~/Gaming" + exit 2 + } + $env.PROTONPATH = $visibleProton - $env.WINEPREFIX = $prefix + mkdir $dir + $env.WINEPREFIX = (sandbox-path $prefix) $env.GAMEID = $gameid - print $"installing '($name)' into ($env.WINEPREFIX)" - print $" PROTONPATH = ($env.PROTONPATH)" - print $" GAMEID = ($env.GAMEID)" - print "run the installer, then exit it (do not start the game)." + if $setup != "-" { + # The sandbox sees only ~/Gaming and the store, so bring the installer in. + let installer = (if ((sandbox-path $setup) != null) { + sandbox-path $setup + } else { + let target = ($dir | path join ($setup | path basename)) + reject-symlink $target "installer copy" + cp $setup $target + sandbox-path $target + }) - let inst = (^umu-run $setup ...$setupArgs | complete) - if $inst.exit_code != 0 { - print --stderr $"installer exited ($inst.exit_code); writing the launchers anyway" + print $"installing '($name)' into ($prefix)" + print $" PROTONPATH = ($env.PROTONPATH)" + print $" GAMEID = ($env.GAMEID)" + print "run the installer, then exit it (do not start the game)." + + let inst = (in-sandbox umu-run $installer ...$setupArgs | complete) + if $inst.exit_code != 0 { + print --stderr $"installer exited ($inst.exit_code); writing the launchers anyway" + } + + let prelaunch = ($dir | path join "prelaunch") + if ($prelaunch | path exists) { + print $"running prelaunch in the sandbox: ($prelaunch)" + in-sandbox (sandbox-path $prelaunch) + } } - let prelaunch = ($dir | path join "prelaunch") - if ($prelaunch | path exists) { - print $"running prelaunch: ($prelaunch)" - run-external $prelaunch + write-run $dir $launcher + write-integration $name $dir + # Older versions generated an uninstall script here for the host to run; + # a game could rewrite it. umu-uninstall.nu replaces it. + let uninstall = ($dir | path join "uninstall") + if ((try { $uninstall | path type } catch { "missing" }) != "missing") { + rm --force $uninstall } - - write-run $name $launcher - write-integration $name - print $"done. launch with: ($dir)/run" + print $"done. launch with: umu-sandbox (sandbox-path ($dir | path join run))" } diff --git a/.agents/skills/nix-config-umu-game/scripts/umu-uninstall.nu b/.agents/skills/nix-config-umu-game/scripts/umu-uninstall.nu new file mode 100644 index 00000000..e1c8e939 --- /dev/null +++ b/.agents/skills/nix-config-umu-game/scripts/umu-uninstall.nu @@ -0,0 +1,34 @@ +#!/usr/bin/env nu +# +# Remove a game installed by umu-install.nu: drop its desktop entry, then, +# after confirmation, delete ~/Gaming/Games/ (prefix and game files). It +# runs from this repo because anything under ~/Gaming is writable by the games. +# +# nu .agents/skills/nix-config-umu-game/scripts/umu-uninstall.nu wuthering-waves + +use common.nu * + +def main [name: string] { + check-name $name + reject-symlinked-roots + let dir = ((games-dir) | path join $name) + reject-symlink $dir "game directory" + + let entry = ($nu.home-dir | path join ".local/share/applications" $"($name).desktop") + rm --force $entry + print "desktop entry removed" + + if not ($dir | path exists) { + print $"nothing to delete at ($dir)" + return + } + print --no-newline $"Delete ($dir) and all its files? [y/N] " + # nu's `input` ignores piped stdin; head reads it either way. + let answer = (^head -n 1 | str trim) + if $answer in [y Y] { + rm --recursive --force $dir + print $"removed ($dir)" + } else { + print $"kept ($dir)" + } +} diff --git a/hardening/README.md b/hardening/README.md index 81d6f9fd..d407b0bc 100644 --- a/hardening/README.md +++ b/hardening/README.md @@ -53,6 +53,7 @@ explicitly; other files use the document portal. Every app also has private writ | MuseScore | Isolated | `~/.config/MuseScore`, `~/.local/share/MuseScore`, Documents/Downloads/Music/Pictures | ALSA via PipeWire, ALSA sequencer, X11/XWayland, print, notifications, IME, accessibility | | mpv | Isolated | `~/.config/mpv`, `~/.cache/mpv`, `~/.local/state/mpv`; media dirs read-only | MPRIS, file chooser, OpenURI | | imv | None | `$HOME` read-only | File chooser, OpenURI | +| umu | Isolated | `~/Gaming` only, mounted as the sandbox `$HOME` | X11/XWayland, PulseAudio, 32-bit GPU drivers, joystick evdev, sysfs, OpenURI, inhibit, IME | All browsers get read-only NSS certificates, printing, native notifications and accessibility. @@ -69,6 +70,32 @@ All browsers get read-only NSS certificates, printing, native notifications and - Bookmarks, history, localStorage, IndexedDB and extension data remain readable by the browser. nixpak isolates them from other apps, not from browser compromise. +## Game launchers + +Game launchers (umu now; Heroic and Steam to follow) share +[gaming-policy.nix](nixpaks/modules/gaming-policy.nix). The goal is that games cannot read host +data, not that they are isolated from each other: + +- `~/Gaming` is mounted over `$HOME`; nothing else in the host home is visible. Installed games are + in `~/Gaming/Games`, the sandbox's `~/Games`, which is where launchers install by default. The + per-app XDG directories are off because games write saves straight into `$HOME`. +- All launchers share that data, so they form one trust domain: a compromised game can change + another launcher's configuration. Their grants therefore stay identical. +- `~/Gaming` is game-writable, so the host never executes a file from it. Generated umu launchers + run through `umu-sandbox`; see the `nix-config-umu-game` skill. +- Controllers: host udev rules hard-link joystick evdev nodes (not ones that also claim to be a + keyboard) into `/dev/gamepad-input`, mounted as the sandbox `/dev/input`. hidraw (Steam Input, + SDL's HIDAPI drivers) and `/dev/uinput` are not granted; uinput would let a game inject input into + the host session. Like the FIDO2 directory, `/dev/gamepad-input` is created at boot. +- The sandbox gets Flatpak's `--device=all` sysfs set without `/sys/block`, the udev database and + `/etc/passwd`, `/etc/group` and `/etc/machine-id`, which pressure-vessel and Wine need. +- Proton's pressure-vessel container is a third bwrap layer (nixpak, buildFHSEnv, pressure-vessel). + This relies on unprivileged user namespaces and on the inner FHS root having no `/.flatpak-info`; + with it, pressure-vessel would switch to a Flatpak sub-sandbox it cannot get. +- Portals see processes in the FHS layer as host apps, so no file chooser is granted (it would + return host paths), and GameMode is unavailable: `gamemoded` cannot reach PIDs in the sandbox's + namespace. + ## Limits - X11: Zoom and WeChat have shared-display trust; portal permissions cannot enforce portal-only @@ -121,8 +148,10 @@ All browsers get read-only NSS certificates, printing, native notifications and desktop/microphone audio, local NVENC H.264 MP4 recording and local RTMP streaming. External provider authentication and browser sources remain unvalidated. - Remmina, Blender, mpv and imv build and pass the policy suite; interactive validation is still - pending. MuseScore passed launch, XWayland and ALSA-to-PipeWire routing checks; audible playback - and user workflows still need confirmation. + pending. umu runs Wuthering Waves and Star Rail from their desktop entries on the RTX 4090, with a + working controller; audio and the in-sandbox isolation check are pending. MuseScore passed launch, + XWayland and ALSA-to-PipeWire routing checks; audible playback and user workflows still need + confirmation. - Chromium smoke test passed: renderer sandbox, environment clearing, bind scope, disposable-profile persistence and loopback policy. - FIDO2 browser passthrough is enabled for Firefox/Chromium/Chrome only. WebAuthn with the CanoKey @@ -135,7 +164,10 @@ All browsers get read-only NSS certificates, printing, native notifications and ## Code map - [nixpaks/](nixpaks/): wrappers and Home Manager package overlay. -- [desktop-base.nix](nixpaks/modules/desktop-base.nix): shared runtime and XDG storage. +- [desktop-base.nix](nixpaks/modules/desktop-base.nix), + [xdg-storage.nix](nixpaks/modules/xdg-storage.nix): shared runtime and per-app XDG storage. +- [gaming-policy.nix](nixpaks/modules/gaming-policy.nix), [nixpaks/umu.nix](nixpaks/umu.nix): game + launcher sandbox and the umu wrapper. - [portal-groups.nix](nixpaks/modules/portal-groups.nix), [desktop-integration.nix](nixpaks/modules/desktop-integration.nix): portal and desktop service grants. diff --git a/hardening/nixpaks/default.nix b/hardening/nixpaks/default.nix index 0fd95a83..ce45c8c2 100644 --- a/hardening/nixpaks/default.nix +++ b/hardening/nixpaks/default.nix @@ -24,10 +24,20 @@ in # authoritative, and removal on unplug leaves no stale node for a reused # hidraw minor. 60-fido-id.rules sets ID_FIDO_TOKEN from the report # descriptor, so the CanoKey's keyboard interface is never linked. - systemd.tmpfiles.rules = [ "d /dev/fido-hidraw 0755 root root -" ]; + # + # Game sandboxes (see modules/gaming-policy.nix) get joysticks the same way: + # their evdev nodes are linked into /dev/gamepad-input, which the sandbox + # mounts as /dev/input. A device that also claims to be a keyboard is left + # out, so key events never reach a game. + systemd.tmpfiles.rules = [ + "d /dev/fido-hidraw 0755 root root -" + "d /dev/gamepad-input 0755 root root -" + ]; services.udev.extraRules = '' ACTION=="add", SUBSYSTEM=="hidraw", ENV{ID_FIDO_TOKEN}=="1", RUN+="${pkgs.coreutils}/bin/ln -f /dev/%k /dev/fido-hidraw/%k" ACTION=="remove", SUBSYSTEM=="hidraw", RUN+="${pkgs.coreutils}/bin/rm -f /dev/fido-hidraw/%k" + ACTION=="add", SUBSYSTEM=="input", KERNEL=="event*|js*", ENV{ID_INPUT_JOYSTICK}=="1", ENV{ID_INPUT_KEYBOARD}!="1", RUN+="${pkgs.coreutils}/bin/ln -f $devnode /dev/gamepad-input/%k" + ACTION=="remove", SUBSYSTEM=="input", KERNEL=="event*|js*", RUN+="${pkgs.coreutils}/bin/rm -f /dev/gamepad-input/%k" ''; # Add nixpaked Apps into nixpkgs, and reference them in home-manager or other nixos modules @@ -53,6 +63,7 @@ in mpv = wrapper super ./mpv.nix; imv = wrapper super ./imv.nix; wechat = wrapper pkgs-master ./wechat.nix; + umu = wrapper super ./umu.nix; }; }) ]; diff --git a/hardening/nixpaks/modules/desktop-base.nix b/hardening/nixpaks/modules/desktop-base.nix index 5d054e77..01586599 100644 --- a/hardening/nixpaks/modules/desktop-base.nix +++ b/hardening/nixpaks/modules/desktop-base.nix @@ -11,6 +11,7 @@ imports = [ ./portal-groups.nix ./desktop-integration.nix + ./xdg-storage.nix ]; gpu = { enable = lib.mkDefault true; @@ -48,22 +49,6 @@ pipewire = lib.mkDefault false; }; bind.rw = with sloth; [ - [ - (mkdir appCacheDir) - xdgCacheHome - ] - [ - (mkdir appConfigDir) - xdgConfigHome - ] - [ - (mkdir appDataDir) - xdgDataHome - ] - [ - (mkdir (concat' appDir "/state")) - xdgStateHome - ] (concat' runtimeDir "/doc") # Chrome/Firefox/Telegram/etc. use /tmp for single-instance IPC. Share # this directory across launches of the same app only; app IDs differ. diff --git a/hardening/nixpaks/modules/gaming-policy.nix b/hardening/nixpaks/modules/gaming-policy.nix new file mode 100644 index 00000000..f183ce7f --- /dev/null +++ b/hardening/nixpaks/modules/gaming-policy.nix @@ -0,0 +1,66 @@ +# Shared policy for game launchers. Every launcher sees the same home: ~/Gaming +# is mounted over $HOME, so game installs (~/Gaming/Games, the sandbox's +# ~/Games where launchers put them by default), Proton builds, the umu runtime +# and saves all live in that one directory. Nothing else in the host home is +# visible. All launchers share that data, so they are one trust domain: keep +# their grants identical here rather than per launcher. +{ + sloth, + ... +}: +{ + imports = [ + ./desktop-base.nix + ./network.nix + ]; + # Games write saves straight into $HOME (~/.factorio, ~/.renpy, ...), which + # per-app XDG directories would silently discard on exit. + xdgStorage.enable = false; + pasta.mode = "isolate"; + portalGroups = { + openUri = true; + inhibit = true; + inputMethod = true; + }; + bubblewrap = { + bind.rw = [ + [ + (sloth.mkdir (sloth.concat' sloth.homeDir "/Gaming")) + sloth.homeDir + ] + ]; + # Wine and the Steam client talk X11 (XWayland). + sockets.x11 = true; + bind.ro = [ + # 32-bit games and Proton's 32-bit half; nixpak maps only the 64-bit + # /run/opengl-driver. + "/run/opengl-driver-32" + # pressure-vessel resolves the user and D-Bus needs a machine ID. The + # buildFHSEnv layer takes both from this /etc. + "/etc/passwd" + "/etc/group" + "/etc/machine-id" + # Flatpak's --device=all sysfs set minus /sys/block: Wine reads the CPU + # topology from /sys/devices/system/cpu, and SDL, Wine's winebus and + # pressure-vessel enumerate GPUs and controllers through sysfs and udev. + "/sys/bus" + "/sys/class" + "/sys/dev" + "/sys/devices" + "/run/udev/data" + "/run/udev/control" + ]; + # Joystick evdev nodes only, hard-linked by the host udev rules in + # ../default.nix; the whole /dev/input would expose every keyboard. + bind.dev = [ + [ + "/dev/gamepad-input" + "/dev/input" + ] + ]; + env = { + DISPLAY = sloth.envOr "DISPLAY" ""; + XAUTHORITY = sloth.envOr "XAUTHORITY" (sloth.concat' sloth.homeDir "/.Xauthority"); + }; + }; +} diff --git a/hardening/nixpaks/modules/xdg-storage.nix b/hardening/nixpaks/modules/xdg-storage.nix new file mode 100644 index 00000000..c5c195b4 --- /dev/null +++ b/hardening/nixpaks/modules/xdg-storage.nix @@ -0,0 +1,37 @@ +# Private XDG directories under ~/.var/app/, mounted over the host's. +# Game launchers turn this off and mount a whole private home instead (see +# gaming-policy.nix): games also write outside XDG, straight into $HOME. +{ + config, + lib, + sloth, + ... +}: +{ + options.xdgStorage.enable = + lib.mkEnableOption "private per-app XDG cache, config, data and state directories" + // { + default = true; + }; + config.bubblewrap.bind.rw = lib.mkIf config.xdgStorage.enable ( + with sloth; + [ + [ + (mkdir appCacheDir) + xdgCacheHome + ] + [ + (mkdir appConfigDir) + xdgConfigHome + ] + [ + (mkdir appDataDir) + xdgDataHome + ] + [ + (mkdir (concat' appDir "/state")) + xdgStateHome + ] + ] + ); +} diff --git a/hardening/nixpaks/umu.nix b/hardening/nixpaks/umu.nix new file mode 100644 index 00000000..1207d8ff --- /dev/null +++ b/hardening/nixpaks/umu.nix @@ -0,0 +1,57 @@ +# umu-launcher sandbox for Windows games outside Steam. The launchers that the +# nix-config-umu-game skill writes to ~/Gaming/Games/ are game-writable, +# so the host never executes them: `umu-sandbox ~/Games//run` (a sandbox +# path) runs the whole script, conf and prelaunch included, inside the sandbox. +{ + steam, + umu-launcher-unwrapped, + mangohud, + winetricks, + bbe, + gamescope, + dwproton-bin, + writeShellScript, + mkNixPak, + ... +}: +let + appId = "org.openwinecomponents.umu"; + # pkgs/by-name/um/umu-launcher, with a generic entry point and the tools the + # generated launchers call. + package = steam.buildRuntimeEnv { + pname = "umu-sandbox"; + inherit (umu-launcher-unwrapped) version; + extraPkgs = _: [ + umu-launcher-unwrapped + mangohud + winetricks + bbe + gamescope + ]; + executableName = "umu-sandbox"; + runScript = writeShellScript "umu-sandbox" ''exec "$@"''; + # As upstream: Legendary spawns umu and exits without waiting for it. + dieWithParent = false; + meta = umu-launcher-unwrapped.meta // { + mainProgram = "umu-sandbox"; + }; + }; + wrapped = mkNixPak { + config = { + imports = [ ./modules/gaming-policy.nix ]; + app = { + inherit package; + binPath = "bin/umu-sandbox"; + }; + flatpak = { inherit appId; }; + bubblewrap.env = { + # The session's UMU_PROTONPATH does not survive clearEnv; this path + # also tracks every dwproton-bin update. + UMU_PROTONPATH = "${dwproton-bin.steamcompattool}"; + # Lets the generated launchers refuse to start outside the sandbox. + UMU_SANDBOX = "1"; + }; + }; + }; +in +wrapped.config.script diff --git a/hardening/tests/policy-lib.nix b/hardening/tests/policy-lib.nix index 19129855..ac97997c 100644 --- a/hardening/tests/policy-lib.nix +++ b/hardening/tests/policy-lib.nix @@ -18,6 +18,24 @@ let mountStrings = app: builtins.filter builtins.isString (lib.flatten (with app.bubblewrap.bind; rw ++ ro ++ dev)); + # Render a sloth value as the launcher would, with $VAR for environment + # lookups, so suites can assert on paths built from $HOME and friends. + renderSloth = + value: + if builtins.isString value then + value + else if value.type == "env" then + "$" + value.key + else if value.type == "concat" then + renderSloth value.a + renderSloth value.b + else if value.type == "mkdir" then + renderSloth value.dir + else + throw "cannot render sloth value of type ${value.type}"; + + # A bind entry is a path, or a [ source destination ] pair. + renderBind = entry: if builtins.isList entry then map renderSloth entry else renderSloth entry; + forbiddenDbus = argument: lib.any (name: lib.hasInfix name argument) [ @@ -41,6 +59,7 @@ let path == "/dev" || lib.hasPrefix "/dev/video" path || lib.hasPrefix "/dev/bus/usb" path + || path == "/dev/uinput" || path == "/run/dbus" || path == "/run/dbus/system_bus_socket" || path == "/sys" @@ -184,6 +203,7 @@ in appNames load mountStrings + renderBind withoutSecretPortal mkAppPolicy globalChecks diff --git a/hardening/tests/policy/umu.nix b/hardening/tests/policy/umu.nix new file mode 100644 index 00000000..f6dbfc13 --- /dev/null +++ b/hardening/tests/policy/umu.nix @@ -0,0 +1,46 @@ +{ + lib, + load, + mkAppPolicy, + renderBind, + ... +}: +let + app = load "umu"; + rw = map renderBind app.bubblewrap.bind.rw; + source = entry: if builtins.isList entry then builtins.head entry else entry; +in +mkAppPolicy "umu" { + network = "pasta"; + media = false; + pulse = true; + x11 = true; + notifications = false; + extraChecks = { + # ~/Gaming is the sandbox home and the only writable host path besides + # per-app runtime state. + sharedHome = lib.elem [ + "$HOME/Gaming" + "$HOME" + ] rw; + noXdgStorage = !app.xdgStorage.enable; + writableScope = lib.all ( + entry: + let + path = source entry; + in + path == "$HOME/Gaming" || lib.hasPrefix "$XDG_RUNTIME_DIR/" path + ) rw; + gamepadsOnly = + lib.elem [ + "/dev/gamepad-input" + "/dev/input" + ] app.bubblewrap.bind.dev + && !lib.elem "/dev/input" app.bubblewrap.bind.dev + && !lib.any (path: lib.hasPrefix "/dev/hidraw" path) (lib.flatten app.bubblewrap.bind.dev); + driver32 = lib.elem "/run/opengl-driver-32" app.bubblewrap.bind.ro; + protonPath = lib.hasPrefix builtins.storeDir app.bubblewrap.env.UMU_PROTONPATH; + openUri = app.portalGroups.openUri; + noFileChooser = !app.portalGroups.fileChooser; + }; +} diff --git a/hardening/tests/runtime-validation.md b/hardening/tests/runtime-validation.md index c33f412e..a050c531 100644 --- a/hardening/tests/runtime-validation.md +++ b/hardening/tests/runtime-validation.md @@ -32,6 +32,7 @@ Nixpak wrappers are accepted in three layers: | MuseScore | Pass | Interactive launch pass; ALSA routed to default PipeWire sink | | mpv | Pass | Interactive validation pending | | imv | Pass | Interactive validation pending | +| umu | Pass | Wuthering Waves and Star Rail from their desktop entries | Intentional exceptions: @@ -56,6 +57,30 @@ Known packaging limitations: Launch and audio routing pass, but audible playback and score/print/export workflows still need user confirmation. +## Game launcher checklist + +For umu now, and Heroic and Steam when they move into the sandbox. Run each with the game installed +through the `nix-config-umu-game` skill and launched from its desktop entry: + +1. The launcher window opens and the nested pressure-vessel container starts (`ENABLE_LOG=1`, then + check `last-run.log` for bwrap or `/.flatpak-info` errors). +2. Frame rate matches an unsandboxed run of the same scene with the MangoHud overlay; the game uses + the NVIDIA GPU, and a 32-bit game renders. +3. Audio plays; a USB or Bluetooth controller connected before launch works, and a keyboard does not + appear under `/dev/input` inside the sandbox. +4. The launcher downloads at the usual speed through pasta, and online play connects. +5. Inside `umu-sandbox bash`: `ls -A ~` shows the contents of the host's `~/Gaming` and nothing else + from the host home. +6. Running `~/Gaming/Games//run` from a host shell refuses to start. + +umu on dwproton-11.0-15: Wuthering Waves and Star Rail both launch from their desktop entries, and a +controller works. Wuthering Waves' `PROTON_LOG` shows pressure-vessel starting inside the sandbox, +DXVK and vkd3d-proton on the RTX 4090, NVAPI, fsync and pasta networking, with nothing denied by the +sandbox. Still to check: audio, a 32-bit game, the keyboard exclusion and item 5. Wuthering Waves' +ACE anti-cheat driver crashes `winedevice.exe` on an unimplemented `ntoskrnl.exe` function, a Wine +gap; the game runs regardless. The first launch after a Proton update or a moved umu runtime can +show no window for a minute while steamrt4 is prepared and the prefix upgraded. + ## Browser smoke test Build a wrapper from the locked inputs: diff --git a/home/linux/gui/base/gaming.nix b/home/linux/gui/base/gaming.nix index 291a9858..dbafbe18 100644 --- a/home/linux/gui/base/gaming.nix +++ b/home/linux/gui/base/gaming.nix @@ -23,24 +23,18 @@ in # Steam - add this as a launch option: `mangohud %command%` / `gamemoderun %command%` # ========================================================================== - # The nix-config-umu-game skill and the launchers it generates rely on - # umu-launcher, mangohud, winetricks and bbe from this list. - home.packages = (with pkgs; [ # https://github.com/flightlessmango/MangoHud # a simple overlay program for monitoring FPS, temperature, CPU and GPU load, and more. mangohud - # Script to install various redistributable runtime libraries in Wine. - winetricks # https://github.com/Open-Wine-Components/umu-launcher - # a unified launcher for Windows games on Linux - umu-launcher - - # Sed-like editor for binary files - # required by some games to fix problems - bbe + # a unified launcher for Windows games on Linux, sandboxed with + # ~/Gaming as its home and only writable host directory. umu-run, winetricks, + # bbe and mangohud live inside it; the nix-config-umu-game skill and + # the launchers it generates run through `umu-sandbox`. + nixpaks.umu ]) ++ (with pkgs; [ # Heroic Games Launcher - primarily for Epic Games & GOG diff --git a/hosts/idols-ai/preservation.nix b/hosts/idols-ai/preservation.nix index 40c69d64..1e93d0d1 100644 --- a/hosts/idols-ai/preservation.nix +++ b/hosts/idols-ai/preservation.nix @@ -299,7 +299,9 @@ in # Games / Media # ====================================== - "Games" + # Home of the game sandboxes (hardening/nixpaks/modules/gaming-policy.nix); + # installed games are in ~/Gaming/Games. + "Gaming" # .desktop entries created by games/installers (including the umu # launchers the nix-config-umu-game skill generates). Home Manager # rewrites its mimeapps.list symlink into the bind mount at activation, diff --git a/modules/nixos/desktop/gaming.nix b/modules/nixos/desktop/gaming.nix index 301b8e6b..b5fc4004 100644 --- a/modules/nixos/desktop/gaming.nix +++ b/modules/nixos/desktop/gaming.nix @@ -56,7 +56,8 @@ in # Expose DW-Proton to umu-run: umu ignores Steam's # STEAM_EXTRA_COMPAT_TOOLS_PATHS, and a store path baked into a generated # launcher goes stale on every dwproton-bin update. The nix-config-umu-game - # skill and the run/exec scripts it generates read this first. + # installer reads this on the host; the umu sandbox + # (hardening/nixpaks/umu.nix) sets the same path for the run/exec scripts. environment.variables.UMU_PROTONPATH = "${pkgs.dwproton-bin.steamcompattool}"; # GameScope: run a game in its own nested compositor and let it renice