feat(nixpak): sandbox umu-launcher with ~/Gaming as its home (#519)

* feat(nixpak): sandbox umu-launcher with ~/Games as its home

umu-run, winetricks and bbe leave the host PATH, so umu launchers
generated before this stop working until rewritten with
`umu-install.nu <name> - <launcher> <gameid>`.

Assisted-by: Claude Agent SDK (claude-opus-5-5)

* refactor(nixpak): mount ~/Gaming as the game sandbox home

idols-ai now preserves ~/Gaming instead of ~/Games, so existing games
vanish from the host after deploy until the preserved ~/Games is moved
to ~/Gaming/Games.

Assisted-by: Claude Agent SDK (claude-opus-5-5)

* docs(nixpak): record umu runtime validation

Assisted-by: Claude Agent SDK (claude-opus-5-5)
Assisted-by: DeepSeek Harness (deepseek-flash)
This commit is contained in:
ryan4yin | 二花 authored and GitHub committed 2026-11-05 22:39:48 -06:00
1 parent 0704ba5e21
commit 08d2ff7578
21 files changed
+788 -237

No files matched your search

+130 -65
View File
@@ -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 <command>` runs a command there.
**Two views of one path:** `~/Gaming/<path>` on the host is `~/<path>` in the sandbox, so a game
lives at `~/Gaming/Games/<name>` on the host and at `~/Games/<name>` inside. The host has no
`~/Games`: a path written that way is a sandbox path. Typed after `umu-sandbox` in a host shell,
`~/Games/<name>/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/<name>/run` and use `umu-sandbox ~/Games/<name>/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/<name>/prefix`: the Wine prefix (`WINEPREFIX`)
- `~/Games/<name>/conf`: optional per-game overrides (`ENABLE_MANGOHUD=0`, ...)
- `~/Games/<name>/prelaunch`: optional executable, run before every launch
- `~/Games/<name>/run`: generated launcher
- `~/Games/<name>/exec`: generated helper: `exec <exe-or-tool> [args...]`
- `~/Games/<name>/<name>.desktop`: generated desktop entry
- `~/Games/<name>/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/<name>/prefix`: the Wine prefix (`WINEPREFIX`)
- `~/Gaming/Games/<name>/conf`: optional per-game overrides (`ENABLE_MANGOHUD=0`, ...)
- `~/Gaming/Games/<name>/prelaunch`: optional executable, run before every launch
- `~/Gaming/Games/<name>/run`: generated launcher
- `~/Gaming/Games/<name>/exec`: generated helper: `umu-sandbox ~/Games/<name>/exec <exe-or-tool>`
- `~/Gaming/Games/<name>/<name>.desktop`: generated desktop entry
Run, from the repo root:
`nu .agents/skills/nix-config-umu-game/scripts/umu-install.nu <name> <setup.exe> <launcher> [gameid] [setup-args...]`
`<launcher>` 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.
`<launcher>` is relative to the prefix and `GAMEID` defaults to `umu-default`. A `<setup.exe>`
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/<id>`), 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="<selected-tool-directory>"` 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.<host>.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="<selected-tool-directory>"` 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 `<name>` (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-<id>`; if nothing matches, leave the default.
4. **Download.** `curl -fL <url> -o ~/Games/<name>/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 <url> -o ~/Gaming/Games/<name>/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 `<launcher>` is only known after install, locate it under
`~/Games/<name>/prefix/drive_c` and set `LAUNCHER` in `~/Games/<name>/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/<name>/prelaunch` (bash, `chmod +x`):
`~/Gaming/Games/<name>/prefix/drive_c` and set `LAUNCHER` in `~/Gaming/Games/<name>/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/<name>/prelaunch` (bash, `chmod +x`):
`winetricks` verbs via `~/Games/<name>/exec winetricks <verbs>`, file patches or registry tweaks
via `~/Games/<name>/exec`. Never commit the fix to this repo.
via `~/Games/<name>/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/<name>/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/<name>/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/<name>/prefix/drive_c -iname 'launcher_main.dll'` (usually under a `X.Y.Z.W`
1. `find ~/Gaming/Games/<name>/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/' <dll>`.
3. Put it in `~/Games/<name>/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/' <dll>`. `bbe` exists only in
the sandbox: prefix it with `umu-sandbox` from a host shell.
3. Put it in `~/Gaming/Games/<name>/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/<name>/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/<name>/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/<name>.desktop` (and a copy at
`~/Games/<name>/<name>.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/<name>/<name>.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/<dir>/preservation.nix`.
## Uninstall
Run `~/Games/<name>/uninstall`: it removes the desktop entry and then asks before deleting
`~/Games/<name>` (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 <name>`
It removes the desktop entry and then asks before deleting `~/Gaming/Games/<name>` (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/<name>`. 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 <name> - <launcher> <gameid>`
This also deletes the old `uninstall`.
## Optimize the launch
`~/Games/<name>/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/<name>/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/<name>/run`. Shader caches are kept in `~/Games/<name>/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/<name>/shader-cache`.
## Escape hatches
- `~/Games/<name>/exec <exe-or-tool> [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 <Proton's wine> <tool>`. 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/<name>/run <game args>` passes extra args to the launcher.
- `umu-sandbox ~/Games/<name>/exec <exe-or-tool> [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 <Proton's wine> <tool>`. 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/<name>/run <game args>` passes extra args to the launcher.
- Kill a stuck prefix with `pkill -f '/Games/<name>/prefix'`, or
`rm -f ~/Games/<name>/prefix/pfx.lock`.
`rm -f ~/Gaming/Games/<name>/prefix/pfx.lock`.
## Common mistakes
- Committing a game name, URL, or patch to this repo instead of `~/Games/<name>/`.
- 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/<name>/prefix'` and launch once.
- Committing a game name, URL, or patch to this repo instead of `~/Gaming/Games/<name>/`.
- 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/<name>/exec winecfg`; `exec` goes through `umu-run`, which provides that container.
`umu-sandbox ~/Games/<name>/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/<name>`).
- 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.
@@ -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/<name> on the host is
# ~/Games/<name> 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
}
}
@@ -1,7 +1,14 @@
#!/usr/bin/env bash
# Generated by the nix-config-umu-game skill -- run a tool in this prefix.
# exec <exe-or-tool> [args...] e.g. exec winecfg / exec explorer
# umu-sandbox ~/Games/<name>/exec <exe-or-tool> [args...] e.g. winecfg
set -euo pipefail
# The host must never execute this game-writable file: launch it with
# `umu-sandbox <this file>`, 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}}"
@@ -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
@@ -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 <this file>`, 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@
@@ -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
@@ -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,10 +135,13 @@ exec "$@"
self.assertFalse(games.exists())
self.assertFalse(outside.exists())
def test_install_rejects_symlinked_games_root(self):
outside = self.home / "outside-games"
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()
(self.home / "Games").symlink_to(outside, target_is_directory=True)
root.symlink_to(outside, target_is_directory=True)
result = self.invoke_install("test-game")
@@ -111,29 +149,23 @@ exec "$@"
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()
@@ -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/<name>/:
# lives under ~/Gaming/Games/<name>/ (~/Games/<name>/ 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
# <name>.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 <setup.exe> 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 <name> <setup.exe> <launcher> [gameid] [setup-args...]"
print ""
print " <name> directory under ~/Games (e.g. wuthering-waves)"
print " <setup.exe> installer to run through umu (a host path)"
print " <name> directory under ~/Gaming/Games (e.g. wuthering-waves)"
print " <setup.exe> installer to run through umu (copied into the game directory"
print " unless the sandbox sees it), or - to skip it"
print " <launcher> 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/<name>/{run,exec,<name>.desktop,uninstall}. A per-game"
print "fix goes in ~/Games/<name>/prelaunch; overrides go in ~/Games/<name>/conf"
print "or ~/Games/global.conf. PROTONPATH comes from $PROTONPATH or"
print "$UMU_PROTONPATH."
print "Writes ~/Gaming/Games/<name>/{run,exec,<name>.desktop}; launch with"
print "umu-sandbox ~/Games/<name>/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)"
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
})
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 = (^umu-run $setup ...$setupArgs | complete)
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: ($prelaunch)"
run-external $prelaunch
print $"running prelaunch in the sandbox: ($prelaunch)"
in-sandbox (sandbox-path $prelaunch)
}
}
write-run $name $launcher
write-integration $name
print $"done. launch with: ($dir)/run"
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
}
print $"done. launch with: umu-sandbox (sandbox-path ($dir | path join run))"
}
@@ -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/<name> (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)"
}
}
+35 -3
View File
@@ -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.
+12 -1
View File
@@ -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;
};
})
];
+1 -16
View File
@@ -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.
@@ -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");
};
};
}
+37
View File
@@ -0,0 +1,37 @@
# Private XDG directories under ~/.var/app/<appId>, 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
]
]
);
}
+57
View File
@@ -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/<name> are game-writable,
# so the host never executes them: `umu-sandbox ~/Games/<name>/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
+20
View File
@@ -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
+46
View File
@@ -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;
};
}
+25
View File
@@ -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/<name>/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:
+5 -11
View File
@@ -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
+3 -1
View File
@@ -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,
+2 -1
View File
@@ -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