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)"
+1 -1
View File
@@ -35,7 +35,7 @@ A setting that works around a bug or a hardware quirk.
| WA-010 | Insta360 Link reset-resume quirk (`usbcore.quirks=2e1a:4c01:b`) — `hosts/idols-ai/default.nix` | the camera stays enumerated with a stalled UVC endpoint after resume | Drop after the Insta360 firmware or the kernel `uvcvideo` fixes it | 2026-08-01 | on kernel or firmware change | active |
| WA-011 | Kernel module blacklist (`esp4`, `esp6`, `rxrpc`) — `hardening/kernel-hardening.nix` | attack surface for the Dirty Frag LPE (CVE-2026-43284 / CVE-2026-43500) | Kept as defence in depth; drop only if something needs those modules | 2026-10-03 | rarely | active |
| WA-018 | user-owned `~/.ssh/config` + disabled systemd ssh-proxy include — `home/base/tui/ssh.nix`, `modules/nixos/base/ssh.nix` | bwrap exposes root-owned store files as `nobody` | Drop once sandboxes preserve store ownership | 2026-10-06 | when sandbox layout changes | active |
| WA-019 | programs.steam.extraCompatPackages = [ pkgs.dwproton-bin pkgs.proton-ge-bin ] — modules/nixos/desktop/gaming.nix | ACE games fail on GE/mainline Proton, work with DW-Proton | Drop once mainline or GE-Proton runs these games (GE-Proton#433) | 2026-10-07 | on Proton/GE bump | active |
| WA-019 | DW-Proton for Steam and umu — `hardening/nixpaks/modules/gaming-policy.nix`, `hardening/nixpaks/umu.nix`, `modules/nixos/desktop/gaming.nix` | ACE games fail on GE/mainline Proton, work with DW-Proton | Drop once mainline or GE-Proton runs these games (GE-Proton#433) | 2026-10-07 | on Proton/GE bump | active |
| WA-024 | Wi-Fi power saving disabled (`networking.networkmanager.wifi.powersave = false`) — `hosts/12kingdoms-shoukei/apple-silicon.nix` | On this BCM4378/brcmfmac laptop, powersave is a common source of intermittent ASSOC-REJECT / authentication-timeout failures; NetworkManager then mistakes the failure for a bad key, re-prompts, and the client (Noctalia, nmtui, nmcli) saves the answer as a duplicate `<SSID> 1` profile | Drop once the kernel carries the brcmfmac `port_authorized` fix (WA-025) and no association failures remain | 2026-10-07 | watch `journalctl -u wpa_supplicant` for ASSOC-REJECT / authentication timeout | active |
| WA-026 | guest deploys run `installOnHost` then `systemctl restart microvm@<guest>` on the physical host — `Justfile`, `lib/genMicrovmGuestModule.nix` | this is the fallback that microvm.nix's own `microvm.deploy.rebuild` uses when it cannot switch in place (`doc/src/ssh-deploy.md`), so only the in-place switch is lost: its sshSwitch step runs `nix-store --load-db` against the guest's read-only virtiofs `/nix/store` and dies on `cannot remount "/nix/store" writable` (nix 2.35) | Switch guests in place with sshSwitch once microvm.nix or nix handles a read-only store. Until then, note that a host activation re-links `/var/lib/microvms/<guest>/current` to the runner baked into the host's system, so switch the host after installing a guest from a newer tree | 2026-10-09 | on microvm.nix or nix bump | active |
| WA-028 | GitHub SSH pinned to IPv4 (`AddressFamily = "inet"`) — `home/base/tui/ssh.nix` | a v6 fake AAAA for `ssh.github.com` from the `2001:2::/64` pool (WA-029) is not proven on the 443 SSH tunnel yet | Drop once the v6 fake-ip pool is proven for GitHub SSH | 2026-10-10 | on a mihomo bump or a v6 pool change | active |
+53 -36
View File
@@ -32,28 +32,30 @@ workflow.
`Media` means audio playback, microphone, Camera and ScreenCast portals. Writable paths are listed
explicitly; other files use the document portal. Every app also has private writable state.
| App | Network | Writable paths | Grants |
| ------------ | ------------- | ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| Firefox | Host loopback | `~/.mozilla`, personal files, Trash, Firefox artwork | Audio, browserpass, MPRIS, idle, FIDO2 key; camera/screen sharing intentionally unavailable |
| Chromium | Host loopback | `~/.config/chromium`, personal files | Media, browserpass, MPRIS, idle, FIDO2 key, v12 Secret |
| Chrome | Isolated | `~/.config/google-chrome`, Downloads | Media, browserpass, MPRIS, idle, FIDO2 key, v12 Secret |
| Brave Origin | Isolated | `~/.config/BraveSoftware/Brave-Origin`, Downloads | v12 Secret |
| Edge | Isolated | `~/.config/microsoft-edge`, Downloads | v12 Secret |
| QQ | Isolated | Personal files, private app state | Media, notifications, tray, MPRIS, idle, accessibility |
| Telegram | Isolated | Personal files, private app state | Media, notifications, tray, MPRIS, idle, accessibility, Ayatana indicators |
| Zoom | Isolated | `~/.zoom`, `zoomus.conf`, `zoom.conf`, Downloads, `Documents/Zoom`, private app state | Media, X11/XWayland, FHS, SNI tray, idle, `/dev/video0-1`, read-only `/sys/class/drm` |
| WeChat | Isolated | Documents, Downloads, app-scoped `wechat` data, private app state | Media, Wayland, X11 fallback, SNI tray, file chooser portal |
| Foliate | None | Documents/Downloads/Pictures read-only, app-scoped state | File chooser, OpenURI, notifications, IME, accessibility |
| Krita | None | Documents/Downloads/Pictures writable, app-scoped state | File chooser, OpenURI, print, inhibit, notifications, IME, accessibility |
| Bambu Studio | Isolated | Documents/Downloads/Pictures writable, app-scoped state | X11/XWayland, PulseAudio, cloud/LAN network, file chooser, notifications, IME, accessibility |
| OrcaSlicer | Isolated | Documents/Downloads/Pictures writable, app-scoped state | X11/XWayland, cloud/LAN network, file chooser, notifications, IME, accessibility |
| Remmina | Isolated | `~/.config/remmina`, `~/.local/share/remmina`, `~/.remmina`, Downloads | PulseAudio, SSH agent, file chooser, OpenURI, notifications, IME, accessibility |
| OBS Studio | Isolated | `~/.config/obs-studio`, Videos/Pictures/Downloads | ScreenCast, V4L2 camera (`/dev/video0-1`), PulseAudio, tray, notifications, IME, accessibility |
| Blender | Isolated | `~/.config/blender`, Documents/Downloads/Pictures/Videos | X11/XWayland, PulseAudio, notifications, IME, accessibility |
| 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 |
| App | Network | Writable paths | Grants |
| ------------ | ------------- | ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| Firefox | Host loopback | `~/.mozilla`, personal files, Trash, Firefox artwork | Audio, browserpass, MPRIS, idle, FIDO2 key; camera/screen sharing intentionally unavailable |
| Chromium | Host loopback | `~/.config/chromium`, personal files | Media, browserpass, MPRIS, idle, FIDO2 key, v12 Secret |
| Chrome | Isolated | `~/.config/google-chrome`, Downloads | Media, browserpass, MPRIS, idle, FIDO2 key, v12 Secret |
| Brave Origin | Isolated | `~/.config/BraveSoftware/Brave-Origin`, Downloads | v12 Secret |
| Edge | Isolated | `~/.config/microsoft-edge`, Downloads | v12 Secret |
| QQ | Isolated | Personal files, private app state | Media, notifications, tray, MPRIS, idle, accessibility |
| Telegram | Isolated | Personal files, private app state | Media, notifications, tray, MPRIS, idle, accessibility, Ayatana indicators |
| Zoom | Isolated | `~/.zoom`, `zoomus.conf`, `zoom.conf`, Downloads, `Documents/Zoom`, private app state | Media, X11/XWayland, FHS, SNI tray, idle, `/dev/video0-1`, read-only `/sys/class/drm` |
| WeChat | Isolated | Documents, Downloads, app-scoped `wechat` data, private app state | Media, Wayland, X11 fallback, SNI tray, file chooser portal |
| Foliate | None | Documents/Downloads/Pictures read-only, app-scoped state | File chooser, OpenURI, notifications, IME, accessibility |
| Krita | None | Documents/Downloads/Pictures writable, app-scoped state | File chooser, OpenURI, print, inhibit, notifications, IME, accessibility |
| Bambu Studio | Isolated | Documents/Downloads/Pictures writable, app-scoped state | X11/XWayland, PulseAudio, cloud/LAN network, file chooser, notifications, IME, accessibility |
| OrcaSlicer | Isolated | Documents/Downloads/Pictures writable, app-scoped state | X11/XWayland, cloud/LAN network, file chooser, notifications, IME, accessibility |
| Remmina | Isolated | `~/.config/remmina`, `~/.local/share/remmina`, `~/.remmina`, Downloads | PulseAudio, SSH agent, file chooser, OpenURI, notifications, IME, accessibility |
| OBS Studio | Isolated | `~/.config/obs-studio`, Videos/Pictures/Downloads | ScreenCast, V4L2 camera (`/dev/video0-1`), PulseAudio, tray, notifications, IME, accessibility |
| Blender | Isolated | `~/.config/blender`, Documents/Downloads/Pictures/Videos | X11/XWayland, PulseAudio, notifications, IME, accessibility |
| 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, SNI tray, 32-bit GPU drivers, joystick evdev, sysfs, OpenURI, inhibit, IME |
| Steam | Isolated | `~/Gaming` only, mounted as the sandbox `$HOME` | X11/XWayland, PulseAudio, SNI tray, 32-bit GPU drivers, joystick evdev, sysfs, OpenURI, inhibit, IME |
| Heroic | Isolated | `~/Gaming` only, mounted as the sandbox `$HOME` | X11/XWayland, PulseAudio, SNI tray, 32-bit GPU drivers, joystick evdev, sysfs, OpenURI, inhibit, IME |
All browsers get read-only NSS certificates, printing, native notifications and accessibility.
@@ -72,9 +74,8 @@ All browsers get read-only NSS certificates, printing, native notifications and
## 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:
Steam, Heroic and umu 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
@@ -82,24 +83,39 @@ data, not that they are isolated from each other:
- 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.
run through `umu-sandbox` (see the `nix-config-umu-game` skill), Steam and Heroic launch their
games from inside their own sandboxes, and protontricks runs only inside the sandbox:
`umu-sandbox protontricks <appid> winecfg`, or
`umu-sandbox protontricks -c 'wine regedit' <appid>`. A host protontricks would run Wine on those
prefixes as the host user.
- Replacing `programs.steam` costs its extras: no host `steam-run` or protontricks, and no `extest`
(needs `/dev/uinput`). Remote Play and LAN discovery need inbound connections that pasta isolation
does not forward; online play, the store and downloads are unaffected.
- The launchers keep a StatusNotifier tray icon (`desktopIntegration.tray`), so closing Steam or
Heroic to the tray still works and the desktop shell has an icon to show. Notifications stay off.
- 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.
keyboard) into `/dev/gamepad-input`, mounted as the sandbox `/dev/input`. hidraw (Steam Input's
gyro/touchpad remapping, SDL's HIDAPI drivers) and `/dev/uinput` are not granted; uinput would let
a game inject input into the host session, and it is not namespaced. A real controller therefore
works through evdev, but Steam Input cannot create virtual gamepads. Like the FIDO2 directory,
`/dev/gamepad-input` is created at boot; after a first deploy with `switch`, run
`sudo systemd-tmpfiles --create --prefix=/dev/gamepad-input` and
`sudo udevadm trigger --action=add -s input` before starting a launcher.
- 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.
- Portals see processes in the FHS layer as host apps, so no file chooser or document portal mount
is granted (they would return host paths and every exported document), and GameMode is
unavailable: `gamemoded` cannot reach PIDs in the sandbox's namespace. Desktop entries that Heroic
or Steam create for a game land in the sandbox's `~/.local/share/applications` (`~/Gaming/...`),
so the host application menu does not show them.
## Limits
- X11: Zoom and WeChat have shared-display trust; portal permissions cannot enforce portal-only
capture.
- X11: Zoom, WeChat and the game launchers have shared-display trust; portal permissions cannot
enforce portal-only capture.
- PulseAudio grants recording authority. Read-only GPG sockets still permit signing and decryption.
- pasta isolates loopback, not LAN access. Chromecast discovery is unverified.
- Brave Origin and Edge intentionally have no PulseAudio, Camera, ScreenCast or MPRIS grants. They
@@ -166,8 +182,9 @@ data, not that they are isolated from each other:
- [nixpaks/](nixpaks/): wrappers and Home Manager package overlay.
- [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.
- [gaming-policy.nix](nixpaks/modules/gaming-policy.nix), [nixpaks/umu.nix](nixpaks/umu.nix),
[nixpaks/steam.nix](nixpaks/steam.nix), [nixpaks/heroic.nix](nixpaks/heroic.nix): game launcher
sandbox and the three launcher wrappers.
- [portal-groups.nix](nixpaks/modules/portal-groups.nix),
[desktop-integration.nix](nixpaks/modules/desktop-integration.nix): portal and desktop service
grants.
+2
View File
@@ -64,6 +64,8 @@ in
imv = wrapper super ./imv.nix;
wechat = wrapper pkgs-master ./wechat.nix;
umu = wrapper super ./umu.nix;
steam = wrapper super ./steam.nix;
heroic = wrapper super ./heroic.nix;
};
})
];
+52
View File
@@ -0,0 +1,52 @@
# Heroic Games Launcher sandbox for Epic, GOG and Amazon games. Heroic keeps its
# configuration in ~/.config/heroic and downloads its Wine/Proton/DXVK builds
# into ~/.config/heroic/tools, both inside ~/Gaming, and installs games under
# ~/Games (its default, the sandbox's view of ~/Gaming/Games). It launches them
# itself, so they inherit this sandbox.
{
lib,
heroic,
gamescope,
# Heroic is Electron inside this FHS, so its CJK text needs these too.
wqy_zenhei,
noto-fonts-cjk-sans,
buildEnv,
runCommandLocal,
mkNixPak,
...
}:
let
appId = "com.heroicgameslauncher.hgl";
package = heroic.override {
extraPkgs = _: [
gamescope
wqy_zenhei
noto-fonts-cjk-sans
];
};
wrapped = mkNixPak {
config = {
imports = [ ./modules/gaming-policy.nix ];
app = {
inherit package;
binPath = "bin/heroic";
};
flatpak = { inherit appId; };
};
};
exe = lib.getExe wrapped.config.script;
assets = runCommandLocal "heroic-desktop-assets" { } ''
mkdir -p $out/share/applications
substitute ${package}/share/applications/${appId}.desktop \
$out/share/applications/${appId}.desktop \
--replace-fail "Exec=heroic" "Exec=${exe}"
cp -r ${package}/share/icons $out/share/icons
'';
in
buildEnv {
inherit (wrapped.config.script) name meta passthru;
paths = [
wrapped.config.script
assets
];
}
+14 -13
View File
@@ -48,15 +48,19 @@
pulse = lib.mkDefault true;
pipewire = lib.mkDefault false;
};
bind.rw = with sloth; [
(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.
bind.rw =
with sloth;
[
(mkdir (concat' runtimeDir "/nixpak-${config.flatpak.appId}-tmp"))
"/tmp"
# Chrome/Firefox/Telegram/etc. use /tmp for single-instance IPC. Share
# this directory across launches of the same app only; app IDs differ.
[
(mkdir (concat' runtimeDir "/nixpak-${config.flatpak.appId}-tmp"))
"/tmp"
]
]
];
# File chooser results arrive through the document portal; an app
# without a chooser gets no view of the exported documents.
++ lib.optional config.portalGroups.fileChooser (concat' runtimeDir "/doc");
bind.ro = [
# Reuse the host's NixOS fontconfig instead of maintaining a second font
# set in every sandbox. Nixpak's own font module generates an isolated
@@ -69,10 +73,6 @@
]
"/etc/localtime"
"/etc/zoneinfo"
(sloth.concat' sloth.xdgConfigHome "/gtk-2.0")
(sloth.concat' sloth.xdgConfigHome "/gtk-3.0")
(sloth.concat' sloth.xdgConfigHome "/gtk-4.0")
(sloth.concat' sloth.xdgConfigHome "/kdeglobals")
"/sys/devices/platform"
]
++ lib.optionals config.gpu.enable [
@@ -111,8 +111,9 @@
pkgs.shared-mime-info
];
XCURSOR_PATH = "${pkgs.bibata-cursors}/share/icons:${pkgs.bibata-cursors}/share/pixmaps";
# Host NixOS fontconfig, bind-mounted above.
FONTCONFIG_FILE = "/etc/fonts/fonts.conf";
# Host NixOS fontconfig, bind-mounted above. A policy whose app builds
# its own FHS layer overrides this (see modules/gaming-policy.nix).
FONTCONFIG_FILE = lib.mkDefault "/etc/fonts/fonts.conf";
NIXOS_XDG_OPEN_USE_PORTAL = "1";
}
// lib.genAttrs [
@@ -5,9 +5,29 @@
# visible. All launchers share that data, so they are one trust domain: keep
# their grants identical here rather than per launcher.
{
lib,
pkgs,
sloth,
...
}:
let
# The FHS layers Steam, Heroic and umu build have their own /etc/fonts, whose
# config lists only DejaVu and the XDG font directory, so the CJK fonts those
# layers ship in /usr/share/fonts were never scanned (Steam rendered tofu).
# Include both that directory and the host conf.d snippet that lists the host
# font directories: Steam's CEF only reads the FHS fonts by family name,
# while Wine and Heroic's Electron use the host families too.
fontsConf = pkgs.writeText "gaming-fonts.conf" ''
<?xml version="1.0"?>
<!DOCTYPE fontconfig SYSTEM "urn:fontconfig:fonts.dtd">
<fontconfig>
<include ignore_missing="yes">/etc/fonts/conf.d</include>
<dir>/usr/share/fonts</dir>
<dir prefix="xdg">fonts</dir>
<cachedir prefix="xdg">fontconfig</cachedir>
</fontconfig>
'';
in
{
imports = [
./desktop-base.nix
@@ -17,6 +37,10 @@
# per-app XDG directories would silently discard on exit.
xdgStorage.enable = false;
pasta.mode = "isolate";
# Steam and Heroic register a StatusNotifierItem for their tray icon; without
# these grants the desktop shell has nothing to show for it (the same reason
# WeChat's tray icon needed them).
desktopIntegration.tray = true;
portalGroups = {
openUri = true;
inhibit = true;
@@ -61,6 +85,16 @@
env = {
DISPLAY = sloth.envOr "DISPLAY" "";
XAUTHORITY = sloth.envOr "XAUTHORITY" (sloth.concat' sloth.homeDir "/.Xauthority");
# See fontsConf: the FHS layers' own /etc/fonts never scans their
# /usr/share/fonts, and desktop-base's value points there.
FONTCONFIG_FILE = "${fontsConf}";
# Proton builds that Steam offers per game and protontricks resolves, as
# programs.steam.extraCompatPackages would set. Store paths: a game can
# read them but not modify them. DW-Proton is WA-019.
STEAM_EXTRA_COMPAT_TOOLS_PATHS = lib.makeSearchPathOutput "steamcompattool" "" [
pkgs.dwproton-bin
pkgs.proton-ge-bin
];
};
};
}
+29 -19
View File
@@ -13,25 +13,35 @@
// {
default = true;
};
config.bubblewrap.bind.rw = lib.mkIf config.xdgStorage.enable (
config.bubblewrap.bind = lib.mkIf config.xdgStorage.enable (
with sloth;
[
[
(mkdir appCacheDir)
xdgCacheHome
]
[
(mkdir appConfigDir)
xdgConfigHome
]
[
(mkdir appDataDir)
xdgDataHome
]
[
(mkdir (concat' appDir "/state"))
xdgStateHome
]
]
{
rw = [
[
(mkdir appCacheDir)
xdgCacheHome
]
[
(mkdir appConfigDir)
xdgConfigHome
]
[
(mkdir appDataDir)
xdgDataHome
]
[
(mkdir (concat' appDir "/state"))
xdgStateHome
]
];
# The host's GTK and KDE theme settings, over the private config
# directory (nixpak mounts read-only binds after read-write ones).
ro = [
(concat' xdgConfigHome "/gtk-2.0")
(concat' xdgConfigHome "/gtk-3.0")
(concat' xdgConfigHome "/gtk-4.0")
(concat' xdgConfigHome "/kdeglobals")
];
}
);
}
+58
View File
@@ -0,0 +1,58 @@
# Steam sandbox. Steam starts its own pressure-vessel container inside this
# one, as umu does, so games stay inside ~/Gaming: the library, the compatdata
# prefixes and every Proton build live there. DW-Proton and GE-Proton come
# from modules/gaming-policy.nix. Remote Play and LAN transfers need inbound
# connections that pasta isolation does not forward.
{
lib,
steam,
gamescope,
mangohud,
# Steam's UI and its pressure-vessel container use the fonts inside this FHS,
# not the host fontconfig, so CJK text renders as tofu without them.
wqy_zenhei,
noto-fonts-cjk-sans,
buildEnv,
runCommandLocal,
mkNixPak,
...
}:
let
appId = "com.valvesoftware.Steam";
package = steam.override {
extraPkgs = _: [
gamescope
mangohud
wqy_zenhei
noto-fonts-cjk-sans
];
};
wrapped = mkNixPak {
config = {
imports = [ ./modules/gaming-policy.nix ];
app = {
inherit package;
binPath = "bin/steam";
};
flatpak = { inherit appId; };
};
};
exe = lib.getExe wrapped.config.script;
# The desktop entry and icons ship in the wrapped package; the nixpak script
# has no share/ of its own, so point the entry at the script instead of the
# unsandboxed binary.
assets = runCommandLocal "steam-desktop-assets" { } ''
mkdir -p $out/share/applications
substitute ${package}/share/applications/steam.desktop \
$out/share/applications/steam.desktop \
--replace-fail "Exec=steam" "Exec=${exe}"
cp -r ${package}/share/icons $out/share/icons
'';
in
buildEnv {
inherit (wrapped.config.script) name meta passthru;
paths = [
wrapped.config.script
assets
];
}
+9
View File
@@ -2,11 +2,17 @@
# 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.
# `umu-sandbox protontricks <appid> ...` fixes a Steam game's prefix the same way.
{
steam,
umu-launcher-unwrapped,
mangohud,
winetricks,
protontricks,
# Wine draws its dialogs with these; the FHS fontconfig only scans
# /usr/share/fonts (see modules/gaming-policy.nix).
wqy_zenhei,
noto-fonts-cjk-sans,
bbe,
gamescope,
dwproton-bin,
@@ -25,6 +31,9 @@ let
umu-launcher-unwrapped
mangohud
winetricks
protontricks
wqy_zenhei
noto-fonts-cjk-sans
bbe
gamescope
];
+45
View File
@@ -133,6 +133,50 @@ let
else
!app.desktopIntegration.tray;
# Checks shared by every game launcher suite (modules/gaming-policy.nix):
# ~/Gaming is the sandbox home and the only host home path, joysticks are
# the only input devices, compat tools come from the store, and the launcher
# grants no file chooser or document portal.
gamingChecks =
name:
let
app = load name;
rw = map renderBind app.bubblewrap.bind.rw;
source = entry: if builtins.isList entry then builtins.head entry else entry;
sources = binds: map (entry: source (renderBind entry)) binds;
in
{
sharedHome = lib.elem [ "$HOME/Gaming" "$HOME" ] rw;
noXdgStorage = !app.xdgStorage.enable;
writableScope = lib.all (
path: path == "$HOME/Gaming" || path == "$XDG_RUNTIME_DIR/nixpak-${app.flatpak.appId}-tmp"
) (sources app.bubblewrap.bind.rw);
noHostHomeReads =
!lib.any (
path:
lib.any (prefix: lib.hasPrefix prefix path) [
"$HOME"
"$XDG_CACHE_HOME"
"$XDG_CONFIG_HOME"
"$XDG_DATA_HOME"
"$XDG_STATE_HOME"
]
) (sources app.bubblewrap.bind.ro);
gamepadsOnly =
lib.elem [
"/dev/gamepad-input"
"/dev/input"
] app.bubblewrap.bind.dev
&& !lib.elem "/dev/input" (sources 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;
compatToolsInStore = lib.all (lib.hasPrefix builtins.storeDir) (
lib.splitString ":" app.bubblewrap.env.STEAM_EXTRA_COMPAT_TOOLS_PATHS
);
openUri = app.portalGroups.openUri;
noFileChooser = !app.portalGroups.fileChooser;
};
mkAppPolicy =
name: spec:
let
@@ -204,6 +248,7 @@ in
load
mountStrings
renderBind
gamingChecks
withoutSecretPortal
mkAppPolicy
globalChecks
+14
View File
@@ -0,0 +1,14 @@
{
mkAppPolicy,
gamingChecks,
...
}:
mkAppPolicy "heroic" {
network = "pasta";
media = false;
pulse = true;
x11 = true;
tray = true;
notifications = false;
extraChecks = gamingChecks "heroic";
}
+14
View File
@@ -0,0 +1,14 @@
{
mkAppPolicy,
gamingChecks,
...
}:
mkAppPolicy "steam" {
network = "pasta";
media = false;
pulse = true;
x11 = true;
tray = true;
notifications = false;
extraChecks = gamingChecks "steam";
}
+4 -28
View File
@@ -2,45 +2,21 @@
lib,
load,
mkAppPolicy,
renderBind,
gamingChecks,
...
}:
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;
tray = 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;
extraChecks = gamingChecks "umu" // {
# DW-Proton comes from the store, which a game can read but not modify.
protonPath = lib.hasPrefix builtins.storeDir app.bubblewrap.env.UMU_PROTONPATH;
openUri = app.portalGroups.openUri;
noFileChooser = !app.portalGroups.fileChooser;
};
}
+12 -2
View File
@@ -33,6 +33,8 @@ Nixpak wrappers are accepted in three layers:
| mpv | Pass | Interactive validation pending |
| imv | Pass | Interactive validation pending |
| umu | Pass | Wuthering Waves and Star Rail from their desktop entries |
| Steam | Pending | Interactive validation pending; checklist below |
| Heroic | Pending | Interactive validation pending; checklist below |
Intentional exceptions:
@@ -59,8 +61,8 @@ Known packaging limitations:
## 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:
For umu, Steam and Heroic. Run umu with a game installed through the `nix-config-umu-game` skill and
launched from its desktop entry, and Steam and Heroic with a game launched from the launcher:
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).
@@ -72,6 +74,14 @@ through the `nix-config-umu-game` skill and launched from its desktop entry:
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.
7. Steam lists DW-Proton and GE-Proton under a game's Compatibility tab, and
`umu-sandbox protontricks <appid> winecfg` opens winecfg for a game in each Steam library,
including one on DW-Proton.
Steam and Heroic: both wrappers build and their sandboxes mount only `~/Gaming`; neither has been
launched through its desktop entry yet. Steam is expected to lose Remote Play, LAN transfers and
Steam Input virtual gamepads (see `hardening/README.md`), and Heroic's game shortcuts stay inside
the sandbox.
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,
+21 -22
View File
@@ -20,31 +20,30 @@ in
# Other Optimizations
# Usage:
# UMU game launchers use MangoHud by default; set ENABLE_MANGOHUD=0 in their conf to disable.
# Steam - add this as a launch option: `mangohud %command%` / `gamemoderun %command%`
# Steam - add this as a launch option: `mangohud %command%`. GameMode does not reach
# sandboxed games (see hardening/README.md), so `gamemoderun` is not available there.
# ==========================================================================
home.packages =
(with pkgs; [
# https://github.com/flightlessmango/MangoHud
# a simple overlay program for monitoring FPS, temperature, CPU and GPU load, and more.
mangohud
home.packages = with pkgs; [
# https://github.com/flightlessmango/MangoHud
# a simple overlay program for monitoring FPS, temperature, CPU and GPU load, and more.
mangohud
# https://github.com/Open-Wine-Components/umu-launcher
# 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
# https://heroicgameslauncher.com/
(heroic.override {
extraPkgs = _pkgs: [
pkgs.gamescope
];
})
]);
# https://github.com/Open-Wine-Components/umu-launcher
# 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
# Steam and Heroic share the same sandbox policy and the same ~/Gaming
# home, so their libraries, prefixes and Proton builds stay inside it.
# Steam runs its own pressure-vessel container inside the sandbox; see
# modules/nixos/desktop/gaming.nix for what replacing programs.steam costs.
nixpaks.steam
# Heroic Games Launcher - primarily for Epic Games & GOG
# https://heroicgameslauncher.com/
nixpaks.heroic
];
};
}
+5 -4
View File
@@ -24,10 +24,11 @@ Related:
The disk layout is fully declarative — see [disko-fs.nix](./disko-fs.nix) for the root disk. Run
`df -Th` / `lsblk` on the host if an up-to-date snapshot of the current mounts is needed.
Steam libraries live under the persisted home directory, so they survive the tmpfs root:
`~/Games/SteamLibrary` is the library that used to sit on the `/data` disk. Steam finds a moved
library when it is added once through _Settings → Storage → Add Drive_, or by starting Steam with
the `libraryfolders.vdf` already listing it.
Steam libraries live under the persisted `~/Gaming`, the game sandbox's home, so they survive the
tmpfs root: `~/Gaming/Games/SteamLibrary` (`~/Games/SteamLibrary` inside the sandbox) is the library
that used to sit on the `/data` disk. Steam finds a moved library when it is added once through
_Settings → Storage → Add Drive_, or by starting Steam with the `libraryfolders.vdf` already listing
it.
## 4090 water block
+8 -16
View File
@@ -299,21 +299,19 @@ in
# Games / Media
# ======================================
# Home of the game sandboxes (hardening/nixpaks/modules/gaming-policy.nix);
# installed games are in ~/Gaming/Games.
# Home of the game sandboxes (hardening/nixpaks/modules/gaming-policy.nix).
# Steam, Heroic, umu and the games they launch all see it as $HOME, so
# their libraries, prefixes, saves and Proton builds live inside it:
# ~/Gaming/.local/share/Steam, ~/Gaming/.config/heroic,
# ~/Gaming/.local/share/umu, ~/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,
# so that survives too.
# .desktop entries created on the host for sandboxed launchers. Home
# Manager rewrites its mimeapps.list symlink into the bind mount at
# activation, so that survives too.
".local/share/applications"
".steam"
".config/MangoHud"
".config/blender"
".local/share/umu"
".local/share/Steam"
# WiVRn atomically replaces its settings and pairing files, so preserve
# the directory rather than individual files.
{
@@ -324,12 +322,6 @@ in
directory = ".android"; # ADB host keys for Quest USB authorization
mode = "0700";
}
".local/state/Heroic"
".config/heroic"
".local/share/GOG.com"
".local/share/StardewValley"
".local/share/feral-interactive"
# ======================================
# Meeting / Remote Desktop / Recording
+11 -29
View File
@@ -23,35 +23,17 @@ in
# Begineer Guide: <https://www.reddit.com/r/linux_gaming/wiki/faq/>
# ==========================================================================
# Games installed by Steam works fine on NixOS, no other configuration needed.
# https://github.com/NixOS/nixpkgs/blob/master/doc/packages/steam.section.md
programs.steam = {
# Some location that should be persistent:
# ~/.local/share/Steam - The default Steam install location
# ~/.local/share/Steam/steamapps/common - The default Game install location
# ~/.steam/root - A symlink to ~/.local/share/Steam
# ~/.steam - Some Symlinks & user info
enable = true;
package = pkgs.steam;
# https://github.com/Winetricks/winetricks
# Whether to enable protontricks, a simple wrapper for running Winetricks commands for Proton-enabled games.
protontricks.enable = true;
# Whether to enable Load the extest library into Steam, to translate X11 input events to uinput events (e.g. for using Steam Input on Wayland) .
extest.enable = true;
fontPackages = [
pkgs.wqy_zenhei # Need by steam for Chinese
];
# DW-Proton (Dawn Winery's Proton fork) carries the anti-cheat and game
# patches that mainline Proton/GE-Proton lack, so it is what runs the anime
# gacha games (Wuthering Waves, Honkai: Star Rail, Arknights: Endfield, ...);
# GE-Proton is the general fallback. Both appear as compatibility tools in
# each game's Properties -> Compatibility.
# https://dawn.wine/dawn-winery/dwproton
extraCompatPackages = [
pkgs.dwproton-bin
pkgs.proton-ge-bin
];
};
# Steam runs in a nixpak sandbox (hardening/nixpaks/steam.nix) that the
# home manager installs, so `programs.steam` is off: its `package` option
# calls `.override` on whatever it is given, which a sandbox script cannot
# answer, and it would install a second, unsandboxed steam plus `steam-run`.
# What is kept from it: 32-bit graphics, which the game sandboxes mount as
# /run/opengl-driver-32, and the controller/VR udev rules. Dropped with it:
# `extest` (needs /dev/uinput, which no sandbox gets) and the host
# protontricks (it would run Wine on game-writable prefixes; the umu
# sandbox carries one instead).
hardware.graphics.enable32Bit = true;
hardware.steam-hardware.enable = true;
# Expose DW-Proton to umu-run: umu ignores Steam's
# STEAM_EXTRA_COMPAT_TOOLS_PATHS, and a store path baked into a generated