feat(nixpak): sandbox Steam and Heroic in ~/Gaming (#520)

* feat(nixpak): sandbox Steam and Heroic in ~/Gaming

idols-ai stops preserving ~/.local/share/Steam, ~/.steam, ~/.config/heroic
and their game data, which must be moved into ~/Gaming before deploying.

Assisted-by: DeepSeek Harness (deepseek-flash)

* refactor(nixpak): replace steam-fix.nu with sandboxed protontricks

Assisted-by: DeepSeek Harness (claude-opus-5-5)

* fix(nixpak): hide host documents and GTK config from game sandboxes

Assisted-by: DeepSeek Harness (claude-opus-5-5)

* docs(ai): locate the Steam library under ~/Gaming

Assisted-by: DeepSeek Harness (claude-opus-5-5)

* fix(umu-game): confine writes to ~/Gaming and keep foreign entries

Assisted-by: DeepSeek Harness (claude-opus-5-5)

* fix(nixpak): keep Steam and Heroic in the application launcher

The wrappers only carried bin/, so neither launcher had a desktop entry
after replacing programs.steam.

Assisted-by: DeepSeek Harness (deepseek-flash)

* fix(nixpak): give the game sandboxes a fontconfig that sees CJK fonts

Steam rendered Chinese as tofu: the FHS layers build their own /etc/fonts,
whose config lists only DejaVu and the XDG font directory, so neither the
host fonts nor the FHS /usr/share/fonts were ever scanned. Point
FONTCONFIG_FILE at a small config that includes /usr/share/fonts, and ship
WenQuanYi Zen Hei and Noto Sans CJK in all three FHS layers.

Assisted-by: DeepSeek Harness (deepseek-flash)

* fix(nixpak): grant the game launchers a StatusNotifier tray icon

Steam and Heroic register a tray icon over D-Bus, and the game sandboxes
granted no StatusNotifierWatcher access, so the desktop shell had nothing
to show for them. Enable desktopIntegration.tray for all three launchers
and teach the policy suites to expect it.

Assisted-by: DeepSeek Harness (deepseek-flash)

* fix(nixpak): drop the duplicate host CJK font and tidy the font overrides

Steam reads the fonts inside its own FHS layer, so the system-wide wqy_zenhei
added for it no longer serves any sandbox. Give gaming-policy its own comment
for FONTCONFIG_FILE instead of leaving it under the compat-tool one, make the
desktop-base value a default, and note the SNI tray grant in the README table.

Assisted-by: DeepSeek Harness (deepseek-flash)
This commit is contained in:
ryan4yin | 二花 authored and GitHub committed 2026-11-06 02:18:33 -06:00
1 parent 08d2ff7578
commit b726c28c14
25 files changed
+520 -214

No files matched your search

+41 -23
View File
@@ -18,8 +18,8 @@ specific installed release; then select it with `PROTONPATH` and pin that path i
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.
file is visible or writable. `umu-run`, `winetricks` and `bbe` exist only inside it (`mangohud` and
`gamescope` are on the host too), 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
@@ -32,8 +32,9 @@ Everything under `~/Gaming` is writable by the games, so **the host never execut
- 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`.
- `umu-install.nu` refuses to write through a symlink in the game directory and writes its files
there through `umu-sandbox`, so a symlink swapped in later still cannot reach outside `~/Gaming`;
uninstalling uses the repo's `umu-uninstall.nu`, not a script in `~/Gaming`.
## Bundled files
@@ -84,11 +85,12 @@ sandbox paths. At launch, the generated `run` script checks the current `$PROTON
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.
its sandbox path (a store path as is; under `~/Gaming`, drop the `Gaming/`) 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
@@ -202,17 +204,30 @@ 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.
It removes the desktop entry, but only one whose `Exec` is the generated
`umu-sandbox ~/Games/<name>/run`, 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`:
After deploying the sandbox and with every game closed, move `~/Games` to `~/Gaming/Games` and the
old `~/.local/share/umu` to `~/Gaming/.local/share/umu` (umu can also download its runtime again).
On a normal host that is a plain `mv`. On an impermanence host each preserved directory is its own
bind mount, and after the deploy the old ones are no longer mounted, so move them under the
persistent root instead, where both are renames:
```bash
mkdir -p /persistent/home/<user>/Gaming/.local/share
mv /persistent/home/<user>/Games /persistent/home/<user>/Gaming/Games
mv /persistent/home/<user>/.local/share/umu /persistent/home/<user>/Gaming/.local/share/umu
```
`/persistent` is the host's `preservation.preserveAt` root. If a target already exists, `mv` would
nest the source inside it: move the game directories one by one instead, and drop the old umu
runtime. 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>`
@@ -265,8 +280,9 @@ variables (`__NV_PRIME_RENDER_OFFLOAD=1`, ...) in `conf` instead. Shader caches
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
module rather than a package: `modules/nixos/desktop/gaming.nix`.
- On NixOS a game needing 32-bit or Vulkan needs `hardware.graphics.enable32Bit`, which
`modules/nixos/desktop/gaming.nix` sets. Steam is a sandboxed package like umu:
`hardening/nixpaks/steam.nix`.
## Regression tests
@@ -276,9 +292,11 @@ 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 stub `umu-run` and
`umu-sandbox`; no game downloads, Wine windows, or changes to existing games. It checks template
Requires `python3`, `nu`, `bash`, and `shellcheck`; the symlink-swap test also needs `unshare` and
unprivileged user namespaces, and is skipped without them. 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.
routing, uninstall confirmation and foreign desktop entries, the sandbox guard, host-to-sandbox path
translation, refusal to write through planted symlinks, and that a symlink swapped in after that
check cannot redirect a write outside `~/Gaming`.
@@ -38,9 +38,10 @@ export def check-name [name: string] {
}
# 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.
# ~/Gaming becomes $HOME, and the Nix store is read-only. Symlinks stay
# unresolved: under ~/Gaming the games choose their targets.
export def sandbox-path [path: string] {
let full = ($path | path expand)
let full = ($path | path expand --no-symlink)
let store = ($env.NIX_STORE_DIR? | default "/nix/store")
let gaming = (gaming-dir)
if $full == $gaming or ($full | str starts-with $"($gaming)/") {
@@ -6,7 +6,7 @@ set -euo pipefail
# `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
echo "run this inside the umu sandbox: umu-sandbox" @GAME_DIR@"/exec <exe-or-tool>" >&2
exit 1
fi
export WINEPREFIX=@WINEPREFIX@
@@ -5,7 +5,7 @@ set -euo pipefail
# `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
echo "run this inside the umu sandbox: umu-sandbox" @GAME_DIR@"/run" >&2
exit 1
fi
HERE="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)"
@@ -247,11 +247,13 @@ exec "$@"
self.assertEqual(values[3:], [str(self.sandbox_game / "prefix" / self.launcher)])
def test_generation_and_setup_arguments(self):
(self.home / "setup.exe").write_bytes(b"MZ\x00installer")
self.install()
self.assertEqual(
self.log.read_text().splitlines()[3:],
[str(self.sandbox_game / "setup.exe"), "/S"],
)
self.assertEqual((self.game / "setup.exe").read_bytes(), b"MZ\x00installer")
self.assertFalse((self.game / "uninstall").exists())
for name in ("run", "exec"):
path = self.game / name
@@ -372,6 +374,17 @@ exec "$@"
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertFalse(self.game.exists())
def test_uninstall_keeps_desktop_entries_it_did_not_write(self):
apps = self.home / ".local/share/applications"
apps.mkdir(parents=True)
foreign = apps / "firefox.desktop"
foreign.write_text("[Desktop Entry]\nExec=firefox %u\n")
result = self.uninstall("firefox")
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(foreign.read_text(), "[Desktop Entry]\nExec=firefox %u\n")
def test_run_refuses_to_start_outside_the_sandbox(self):
self.install()
self.log.unlink()
@@ -385,7 +398,7 @@ exec "$@"
check=False,
)
self.assertNotEqual(result.returncode, 0)
self.assertIn("umu-sandbox", result.stderr)
self.assertIn(f"umu-sandbox {self.sandbox_game / name}", result.stderr)
self.assertFalse(self.log.exists())
def test_installer_and_prelaunch_run_in_the_sandbox(self):
@@ -394,10 +407,8 @@ exec "$@"
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)]
)
# Every call starts in the home, which exists on the host and in the sandbox.
self.assertEqual(set(self.sandbox_log.read_text().splitlines()), {str(self.home)})
def test_rewrite_launchers_without_installer(self):
result = self.invoke_install("test-game", setup="-")
@@ -426,6 +437,40 @@ exec "$@"
self.assertEqual(target.read_text(), "host file\n")
link.unlink()
def test_writes_into_gaming_stay_there_when_swapped_for_a_symlink(self):
# This stub mounts ~/Gaming over $HOME like the real sandbox, after a
# launcher still running from the installer swaps run for a symlink to
# a host file -- past the installer's own symlink check.
probe = subprocess.run(
["unshare", "-rm", "mount", "--bind", str(self.home), str(self.home)],
capture_output=True,
check=False,
)
if probe.returncode != 0:
self.skipTest("needs unshare and unprivileged user namespaces")
sandbox = self.bin_dir / "umu-sandbox"
sandbox.write_text("""#!/usr/bin/env bash
ln -sfn "$PLANT_TARGET" "$PLANT_LINK"
export UMU_SANDBOX=1
exec unshare --user --map-root-user --mount \\
bash -c 'mount --bind "$HOME/Gaming" "$HOME" && exec "$@"' bash "$@"
""")
target = self.home / "bashrc"
target.write_text("host file\n")
result = self.invoke_install(
"test-game",
setup="-",
PLANT_LINK=str(self.game / "run"),
PLANT_TARGET=str(target),
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(target.read_text(), "host file\n")
self.assertFalse(os.access(target, os.X_OK))
# In the sandbox the link leads to ~/Gaming/bashrc instead.
self.assertIn("nix-config-umu-game", (self.home / "Gaming/bashrc").read_text())
def test_installer_in_the_sandbox_home_is_used_in_place(self):
setup = self.home / "Gaming/Downloads/setup.exe"
setup.parent.mkdir(parents=True)
@@ -19,7 +19,8 @@
# 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.
# entry are sandbox paths. Even the files this script writes under ~/Gaming are
# written through `umu-sandbox`; only the host desktop entry is written here.
# umu-uninstall.nu removes a game.
#
# ~/Gaming/Games/global.conf, if present, is sourced by every run script before
@@ -92,18 +93,31 @@ def render-template [name: string, values: record] {
}
}
# Write a file under ~/Gaming from inside the sandbox: a running game can swap
# it for a symlink after reject-symlink, and in there that symlink can only
# lead back into ~/Gaming. The content is the input, or the host file --from,
# which is streamed instead of read into memory.
def save-in-sandbox [path: string, --from: string, --executable] {
let data = $in
let source = (if $from != null { $from | path expand })
let script = (if $executable { 'cat > "$1" && chmod +x "$1"' } else { 'cat > "$1"' })
let target = (sandbox-path $path)
cd $nu.home-dir
if $source != null { open --raw $source } else { $data } | ^umu-sandbox sh -c $script sh $target
}
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)
GAME_DIR: (shell-quote (sandbox-path $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
render-template $"($file).sh.tpl" $values | save-in-sandbox --executable $target
}
}
@@ -117,14 +131,15 @@ def write-integration [name: string, dir: string] {
})
mkdir $apps
reject-symlink $copy "desktop entry copy"
$desktop | save --force $copy
$desktop | save-in-sandbox $copy
# Not game-writable, so the host writes it directly.
$desktop | save --force $entry
}
# 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] {
def --wrapped in-sandbox [...command: string] {
cd $nu.home-dir
(^umu-sandbox env
$"WINEPREFIX=($env.WINEPREFIX)"
@@ -177,7 +192,7 @@ def main [...args: string] {
} else {
let target = ($dir | path join ($setup | path basename))
reject-symlink $target "installer copy"
cp $setup $target
save-in-sandbox $target --from $setup
sandbox-path $target
})
@@ -204,7 +219,7 @@ def main [...args: string] {
# 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
in-sandbox rm -f (sandbox-path $uninstall)
}
print $"done. launch with: umu-sandbox (sandbox-path ($dir | path join run))"
}
@@ -1,8 +1,9 @@
#!/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.
# Remove a game installed by umu-install.nu: drop the desktop entry it wrote,
# 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
@@ -14,9 +15,19 @@ def main [name: string] {
let dir = ((games-dir) | path join $name)
reject-symlink $dir "game directory"
# Remove the entry only if umu-install.nu wrote it for this game.
let entry = ($nu.home-dir | path join ".local/share/applications" $"($name).desktop")
rm --force $entry
print "desktop entry removed"
let exec = (open --raw ($env.FILE_PWD | path join "templates/launcher.desktop.tpl")
| lines | where ($it | str starts-with "Exec=") | first
| str replace "@RUN@" (sandbox-path ($dir | path join "run")))
if not ($entry | path exists) {
print "no desktop entry"
} else if $exec in (open --raw $entry | lines) {
rm --force $entry
print "desktop entry removed"
} else {
print $"kept ($entry): its Exec is not ($exec)"
}
if not ($dir | path exists) {
print $"nothing to delete at ($dir)"