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
+467 -161

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 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 `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 file is visible or writable. `umu-run`, `winetricks` and `bbe` exist only inside it (`mangohud` and
inside it, and `umu-sandbox <command>` runs a command there. `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 **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 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 - 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. generated desktop entry does the former. Running `run` or `exec` directly refuses to start.
- the installer and `prelaunch` also run through `umu-sandbox`; - 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 - `umu-install.nu` refuses to write through a symlink in the game directory and writes its files
the repo's `umu-uninstall.nu`, not a script in `~/Gaming`. 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 ## 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 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 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 `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 its sandbox path (a store path as is; under `~/Gaming`, drop the `Gaming/`) in the game's `conf`
`UMU_PROTONPATH` may take precedence. If the required release is absent from Nix and Steam, stop to pin it across launches; otherwise the runtime Nix `UMU_PROTONPATH` may take precedence. If the
and ask the user before downloading it from elsewhere. Do not substitute a different release or required release is absent from Nix and Steam, stop and ask the user before downloading it from
use ProtonPlus/Lutris to fetch one: a host-side downloader writes outside `~/Gaming`, where the elsewhere. Do not substitute a different release or use ProtonPlus/Lutris to fetch one: a
sandbox cannot see it, and a store path cannot be modified by a game. 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`). 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 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 `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>` `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 It removes the desktop entry, but only one whose `Exec` is the generated
files). To keep the game and only drop the menu entry, delete the `.desktop` file by hand. `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 ## Games installed before the sandbox
Move `~/Games` to `~/Gaming/Games` and the old `~/.local/share/umu` to `~/Gaming/.local/share/umu` After deploying the sandbox and with every game closed, move `~/Games` to `~/Gaming/Games` and the
(on the same filesystem, so both are renames; umu can also download its runtime again). Paths inside old `~/.local/share/umu` to `~/Gaming/.local/share/umu` (umu can also download its runtime again).
the game directories stay valid, because the sandbox sees them at the old `~/Games/<name>`. The old On a normal host that is a plain `mv`. On an impermanence host each preserved directory is its own
desktop entries execute `run` on the host, and an old `uninstall` script is host-executed too, so bind mount, and after the deploy the old ones are no longer mounted, so move them under the
rewrite the launchers without re-running the installer, passing the `LAUNCHER` and `GAMEID` from the persistent root instead, where both are renames:
old `run`:
```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>` `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>`). 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 - Waiting for the game download before calling the install done: the launcher's own download is out
of scope here. of scope here.
- On NixOS a game needing 32-bit or Vulkan needs `hardware.graphics.enable32Bit`, and Steam is a - On NixOS a game needing 32-bit or Vulkan needs `hardware.graphics.enable32Bit`, which
module rather than a package: `modules/nixos/desktop/gaming.nix`. `modules/nixos/desktop/gaming.nix` sets. Steam is a sandboxed package like umu:
`hardening/nixpaks/steam.nix`.
## Regression tests ## Regression tests
@@ -276,9 +292,11 @@ From the repo root:
python3 .agents/skills/nix-config-umu-game/scripts/tests/test_install.py 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 Requires `python3`, `nu`, `bash`, and `shellcheck`; the symlink-swap test also needs `unshare` and
`umu-sandbox`; no game downloads, Wine windows, or changes to existing games. It checks template 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 permissions, rendered shell syntax and lint, installer arguments and failures, configuration
precedence, prelaunch, logging, launch wrappers, literal paths, runtime Proton overrides, tool 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 routing, uninstall confirmation and foreign desktop entries, the sandbox guard, host-to-sandbox path
write through planted symlinks. 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: # 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] { 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 store = ($env.NIX_STORE_DIR? | default "/nix/store")
let gaming = (gaming-dir) let gaming = (gaming-dir)
if $full == $gaming or ($full | str starts-with $"($gaming)/") { 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 # `umu-sandbox <this file>`, as the desktop entry does. This check only catches
# mistakes; the sandbox is the boundary. # mistakes; the sandbox is the boundary.
if [ "${UMU_SANDBOX:-}" != 1 ]; then 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 exit 1
fi fi
export WINEPREFIX=@WINEPREFIX@ export WINEPREFIX=@WINEPREFIX@
@@ -5,7 +5,7 @@ set -euo pipefail
# `umu-sandbox <this file>`, as the desktop entry does. This check only catches # `umu-sandbox <this file>`, as the desktop entry does. This check only catches
# mistakes; the sandbox is the boundary. # mistakes; the sandbox is the boundary.
if [ "${UMU_SANDBOX:-}" != 1 ]; then 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 exit 1
fi fi
HERE="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)" HERE="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)"
@@ -247,11 +247,13 @@ exec "$@"
self.assertEqual(values[3:], [str(self.sandbox_game / "prefix" / self.launcher)]) self.assertEqual(values[3:], [str(self.sandbox_game / "prefix" / self.launcher)])
def test_generation_and_setup_arguments(self): def test_generation_and_setup_arguments(self):
(self.home / "setup.exe").write_bytes(b"MZ\x00installer")
self.install() self.install()
self.assertEqual( self.assertEqual(
self.log.read_text().splitlines()[3:], self.log.read_text().splitlines()[3:],
[str(self.sandbox_game / "setup.exe"), "/S"], [str(self.sandbox_game / "setup.exe"), "/S"],
) )
self.assertEqual((self.game / "setup.exe").read_bytes(), b"MZ\x00installer")
self.assertFalse((self.game / "uninstall").exists()) self.assertFalse((self.game / "uninstall").exists())
for name in ("run", "exec"): for name in ("run", "exec"):
path = self.game / name path = self.game / name
@@ -372,6 +374,17 @@ exec "$@"
self.assertEqual(result.returncode, 0, result.stdout + result.stderr) self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertFalse(self.game.exists()) 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): def test_run_refuses_to_start_outside_the_sandbox(self):
self.install() self.install()
self.log.unlink() self.log.unlink()
@@ -385,7 +398,7 @@ exec "$@"
check=False, check=False,
) )
self.assertNotEqual(result.returncode, 0) 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()) self.assertFalse(self.log.exists())
def test_installer_and_prelaunch_run_in_the_sandbox(self): 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.write_text('#!/usr/bin/env bash\n[ "$UMU_SANDBOX" = 1 ]\n')
prelaunch.chmod(0o755) prelaunch.chmod(0o755)
self.install() self.install()
# Both start in the home, which exists on the host and in the sandbox. # Every call starts in the home, which exists on the host and in the sandbox.
self.assertEqual( self.assertEqual(set(self.sandbox_log.read_text().splitlines()), {str(self.home)})
self.sandbox_log.read_text().splitlines(), [str(self.home), str(self.home)]
)
def test_rewrite_launchers_without_installer(self): def test_rewrite_launchers_without_installer(self):
result = self.invoke_install("test-game", setup="-") result = self.invoke_install("test-game", setup="-")
@@ -426,6 +437,40 @@ exec "$@"
self.assertEqual(target.read_text(), "host file\n") self.assertEqual(target.read_text(), "host file\n")
link.unlink() 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): def test_installer_in_the_sandbox_home_is_used_in_place(self):
setup = self.home / "Gaming/Downloads/setup.exe" setup = self.home / "Gaming/Downloads/setup.exe"
setup.parent.mkdir(parents=True) setup.parent.mkdir(parents=True)
@@ -19,7 +19,8 @@
# Games only run in the umu sandbox (hardening/nixpaks/umu.nix), which mounts # 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 # ~/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 # 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. # umu-uninstall.nu removes a game.
# #
# ~/Gaming/Games/global.conf, if present, is sourced by every run script before # ~/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] { def write-run [dir: string, launcher: string] {
let values = { let values = {
WINEPREFIX: (shell-quote $env.WINEPREFIX) WINEPREFIX: (shell-quote $env.WINEPREFIX)
PROTONPATH: (shell-quote $env.PROTONPATH) PROTONPATH: (shell-quote $env.PROTONPATH)
GAMEID: (shell-quote $env.GAMEID) GAMEID: (shell-quote $env.GAMEID)
LAUNCHER: (shell-quote $launcher) LAUNCHER: (shell-quote $launcher)
GAME_DIR: (shell-quote (sandbox-path $dir))
} }
for file in [run exec] { for file in [run exec] {
let target = ($dir | path join $file) let target = ($dir | path join $file)
reject-symlink $target $file reject-symlink $target $file
render-template $"($file).sh.tpl" $values | save --force $target render-template $"($file).sh.tpl" $values | save-in-sandbox --executable $target
^chmod +x $target
} }
} }
@@ -117,14 +131,15 @@ def write-integration [name: string, dir: string] {
}) })
mkdir $apps mkdir $apps
reject-symlink $copy "desktop entry copy" 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 $desktop | save --force $entry
} }
# Run a command in the umu sandbox with the game's environment. Start from the # 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 # host home: inside, that path is the sandbox home, so the working directory
# exists on both sides. # exists on both sides.
def in-sandbox [...command: string] { def --wrapped in-sandbox [...command: string] {
cd $nu.home-dir cd $nu.home-dir
(^umu-sandbox env (^umu-sandbox env
$"WINEPREFIX=($env.WINEPREFIX)" $"WINEPREFIX=($env.WINEPREFIX)"
@@ -177,7 +192,7 @@ def main [...args: string] {
} else { } else {
let target = ($dir | path join ($setup | path basename)) let target = ($dir | path join ($setup | path basename))
reject-symlink $target "installer copy" reject-symlink $target "installer copy"
cp $setup $target save-in-sandbox $target --from $setup
sandbox-path $target sandbox-path $target
}) })
@@ -204,7 +219,7 @@ def main [...args: string] {
# a game could rewrite it. umu-uninstall.nu replaces it. # a game could rewrite it. umu-uninstall.nu replaces it.
let uninstall = ($dir | path join "uninstall") let uninstall = ($dir | path join "uninstall")
if ((try { $uninstall | path type } catch { "missing" }) != "missing") { 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))" print $"done. launch with: umu-sandbox (sandbox-path ($dir | path join run))"
} }
@@ -1,8 +1,9 @@
#!/usr/bin/env nu #!/usr/bin/env nu
# #
# Remove a game installed by umu-install.nu: drop its desktop entry, then, # Remove a game installed by umu-install.nu: drop the desktop entry it wrote,
# after confirmation, delete ~/Gaming/Games/<name> (prefix and game files). It # then, after confirmation, delete ~/Gaming/Games/<name> (prefix and game
# runs from this repo because anything under ~/Gaming is writable by the games. # 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 # 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) let dir = ((games-dir) | path join $name)
reject-symlink $dir "game directory" 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") let entry = ($nu.home-dir | path join ".local/share/applications" $"($name).desktop")
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 rm --force $entry
print "desktop entry removed" print "desktop entry removed"
} else {
print $"kept ($entry): its Exec is not ($exec)"
}
if not ($dir | path exists) { if not ($dir | path exists) {
print $"nothing to delete at ($dir)" 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-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-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-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-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-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 | | 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 |
+33 -16
View File
@@ -33,7 +33,7 @@ workflow.
explicitly; other files use the document portal. Every app also has private writable state. explicitly; other files use the document portal. Every app also has private writable state.
| App | Network | Writable paths | Grants | | 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 | | 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 | | 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 | | Chrome | Isolated | `~/.config/google-chrome`, Downloads | Media, browserpass, MPRIS, idle, FIDO2 key, v12 Secret |
@@ -53,7 +53,9 @@ 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 | | 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 | | 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 | | 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 | | 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. 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
Game launchers (umu now; Heroic and Steam to follow) share Steam, Heroic and umu share [gaming-policy.nix](nixpaks/modules/gaming-policy.nix). The goal is that
[gaming-policy.nix](nixpaks/modules/gaming-policy.nix). The goal is that games cannot read host games cannot read host data, not that they are isolated from each other:
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 - `~/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 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 - 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. 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 - `~/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 - 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, keyboard) into `/dev/gamepad-input`, mounted as the sandbox `/dev/input`. hidraw (Steam Input's
SDL's HIDAPI drivers) and `/dev/uinput` are not granted; uinput would let a game inject input into gyro/touchpad remapping, SDL's HIDAPI drivers) and `/dev/uinput` are not granted; uinput would let
the host session. Like the FIDO2 directory, `/dev/gamepad-input` is created at boot. 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 - 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. `/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). - 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`; 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. 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 - Portals see processes in the FHS layer as host apps, so no file chooser or document portal mount
return host paths), and GameMode is unavailable: `gamemoded` cannot reach PIDs in the sandbox's is granted (they would return host paths and every exported document), and GameMode is
namespace. 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 ## Limits
- X11: Zoom and WeChat have shared-display trust; portal permissions cannot enforce portal-only - X11: Zoom, WeChat and the game launchers have shared-display trust; portal permissions cannot
capture. enforce portal-only capture.
- PulseAudio grants recording authority. Read-only GPG sockets still permit signing and decryption. - PulseAudio grants recording authority. Read-only GPG sockets still permit signing and decryption.
- pasta isolates loopback, not LAN access. Chromecast discovery is unverified. - pasta isolates loopback, not LAN access. Chromecast discovery is unverified.
- Brave Origin and Edge intentionally have no PulseAudio, Camera, ScreenCast or MPRIS grants. They - 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. - [nixpaks/](nixpaks/): wrappers and Home Manager package overlay.
- [desktop-base.nix](nixpaks/modules/desktop-base.nix), - [desktop-base.nix](nixpaks/modules/desktop-base.nix),
[xdg-storage.nix](nixpaks/modules/xdg-storage.nix): shared runtime and per-app XDG storage. [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 - [gaming-policy.nix](nixpaks/modules/gaming-policy.nix), [nixpaks/umu.nix](nixpaks/umu.nix),
launcher sandbox and the umu wrapper. [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), - [portal-groups.nix](nixpaks/modules/portal-groups.nix),
[desktop-integration.nix](nixpaks/modules/desktop-integration.nix): portal and desktop service [desktop-integration.nix](nixpaks/modules/desktop-integration.nix): portal and desktop service
grants. grants.
+2
View File
@@ -64,6 +64,8 @@ in
imv = wrapper super ./imv.nix; imv = wrapper super ./imv.nix;
wechat = wrapper pkgs-master ./wechat.nix; wechat = wrapper pkgs-master ./wechat.nix;
umu = wrapper super ./umu.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
];
}
+10 -9
View File
@@ -48,15 +48,19 @@
pulse = lib.mkDefault true; pulse = lib.mkDefault true;
pipewire = lib.mkDefault false; pipewire = lib.mkDefault false;
}; };
bind.rw = with sloth; [ bind.rw =
(concat' runtimeDir "/doc") with sloth;
[
# Chrome/Firefox/Telegram/etc. use /tmp for single-instance IPC. Share # Chrome/Firefox/Telegram/etc. use /tmp for single-instance IPC. Share
# this directory across launches of the same app only; app IDs differ. # this directory across launches of the same app only; app IDs differ.
[ [
(mkdir (concat' runtimeDir "/nixpak-${config.flatpak.appId}-tmp")) (mkdir (concat' runtimeDir "/nixpak-${config.flatpak.appId}-tmp"))
"/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 = [ bind.ro = [
# Reuse the host's NixOS fontconfig instead of maintaining a second font # Reuse the host's NixOS fontconfig instead of maintaining a second font
# set in every sandbox. Nixpak's own font module generates an isolated # set in every sandbox. Nixpak's own font module generates an isolated
@@ -69,10 +73,6 @@
] ]
"/etc/localtime" "/etc/localtime"
"/etc/zoneinfo" "/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" "/sys/devices/platform"
] ]
++ lib.optionals config.gpu.enable [ ++ lib.optionals config.gpu.enable [
@@ -111,8 +111,9 @@
pkgs.shared-mime-info pkgs.shared-mime-info
]; ];
XCURSOR_PATH = "${pkgs.bibata-cursors}/share/icons:${pkgs.bibata-cursors}/share/pixmaps"; XCURSOR_PATH = "${pkgs.bibata-cursors}/share/icons:${pkgs.bibata-cursors}/share/pixmaps";
# Host NixOS fontconfig, bind-mounted above. # Host NixOS fontconfig, bind-mounted above. A policy whose app builds
FONTCONFIG_FILE = "/etc/fonts/fonts.conf"; # 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"; NIXOS_XDG_OPEN_USE_PORTAL = "1";
} }
// lib.genAttrs [ // lib.genAttrs [
@@ -5,9 +5,29 @@
# visible. All launchers share that data, so they are one trust domain: keep # visible. All launchers share that data, so they are one trust domain: keep
# their grants identical here rather than per launcher. # their grants identical here rather than per launcher.
{ {
lib,
pkgs,
sloth, 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 = [ imports = [
./desktop-base.nix ./desktop-base.nix
@@ -17,6 +37,10 @@
# per-app XDG directories would silently discard on exit. # per-app XDG directories would silently discard on exit.
xdgStorage.enable = false; xdgStorage.enable = false;
pasta.mode = "isolate"; 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 = { portalGroups = {
openUri = true; openUri = true;
inhibit = true; inhibit = true;
@@ -61,6 +85,16 @@
env = { env = {
DISPLAY = sloth.envOr "DISPLAY" ""; DISPLAY = sloth.envOr "DISPLAY" "";
XAUTHORITY = sloth.envOr "XAUTHORITY" (sloth.concat' sloth.homeDir "/.Xauthority"); 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
];
}; };
}; };
} }
+13 -3
View File
@@ -13,9 +13,10 @@
// { // {
default = true; default = true;
}; };
config.bubblewrap.bind.rw = lib.mkIf config.xdgStorage.enable ( config.bubblewrap.bind = lib.mkIf config.xdgStorage.enable (
with sloth; with sloth;
[ {
rw = [
[ [
(mkdir appCacheDir) (mkdir appCacheDir)
xdgCacheHome xdgCacheHome
@@ -32,6 +33,15 @@
(mkdir (concat' appDir "/state")) (mkdir (concat' appDir "/state"))
xdgStateHome 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, # 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 # 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. # 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, steam,
umu-launcher-unwrapped, umu-launcher-unwrapped,
mangohud, mangohud,
winetricks, 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, bbe,
gamescope, gamescope,
dwproton-bin, dwproton-bin,
@@ -25,6 +31,9 @@ let
umu-launcher-unwrapped umu-launcher-unwrapped
mangohud mangohud
winetricks winetricks
protontricks
wqy_zenhei
noto-fonts-cjk-sans
bbe bbe
gamescope gamescope
]; ];
+45
View File
@@ -133,6 +133,50 @@ let
else else
!app.desktopIntegration.tray; !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 = mkAppPolicy =
name: spec: name: spec:
let let
@@ -204,6 +248,7 @@ in
load load
mountStrings mountStrings
renderBind renderBind
gamingChecks
withoutSecretPortal withoutSecretPortal
mkAppPolicy mkAppPolicy
globalChecks 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, lib,
load, load,
mkAppPolicy, mkAppPolicy,
renderBind, gamingChecks,
... ...
}: }:
let let
app = load "umu"; app = load "umu";
rw = map renderBind app.bubblewrap.bind.rw;
source = entry: if builtins.isList entry then builtins.head entry else entry;
in in
mkAppPolicy "umu" { mkAppPolicy "umu" {
network = "pasta"; network = "pasta";
media = false; media = false;
pulse = true; pulse = true;
x11 = true; x11 = true;
tray = true;
notifications = false; notifications = false;
extraChecks = { extraChecks = gamingChecks "umu" // {
# ~/Gaming is the sandbox home and the only writable host path besides # DW-Proton comes from the store, which a game can read but not modify.
# 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; 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 | | mpv | Pass | Interactive validation pending |
| imv | Pass | Interactive validation pending | | imv | Pass | Interactive validation pending |
| umu | Pass | Wuthering Waves and Star Rail from their desktop entries | | 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: Intentional exceptions:
@@ -59,8 +61,8 @@ Known packaging limitations:
## Game launcher checklist ## Game launcher checklist
For umu now, and Heroic and Steam when they move into the sandbox. Run each with the game installed For umu, Steam and Heroic. Run umu with a game installed through the `nix-config-umu-game` skill and
through the `nix-config-umu-game` skill and launched from its desktop entry: 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 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). 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 5. Inside `umu-sandbox bash`: `ls -A ~` shows the contents of the host's `~/Gaming` and nothing else
from the host home. from the host home.
6. Running `~/Gaming/Games/<name>/run` from a host shell refuses to start. 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 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, controller works. Wuthering Waves' `PROTON_LOG` shows pressure-vessel starting inside the sandbox,
+10 -11
View File
@@ -20,11 +20,11 @@ in
# Other Optimizations # Other Optimizations
# Usage: # Usage:
# UMU game launchers use MangoHud by default; set ENABLE_MANGOHUD=0 in their conf to disable. # 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 = home.packages = with pkgs; [
(with pkgs; [
# https://github.com/flightlessmango/MangoHud # https://github.com/flightlessmango/MangoHud
# a simple overlay program for monitoring FPS, temperature, CPU and GPU load, and more. # a simple overlay program for monitoring FPS, temperature, CPU and GPU load, and more.
mangohud mangohud
@@ -35,16 +35,15 @@ in
# bbe and mangohud live inside it; the nix-config-umu-game skill and # bbe and mangohud live inside it; the nix-config-umu-game skill and
# the launchers it generates run through `umu-sandbox`. # the launchers it generates run through `umu-sandbox`.
nixpaks.umu nixpaks.umu
])
++ (with pkgs; [ # 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 # Heroic Games Launcher - primarily for Epic Games & GOG
# https://heroicgameslauncher.com/ # https://heroicgameslauncher.com/
(heroic.override { nixpaks.heroic
extraPkgs = _pkgs: [
pkgs.gamescope
]; ];
})
]);
}; };
} }
+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 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. `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: Steam libraries live under the persisted `~/Gaming`, the game sandbox's home, so they survive the
`~/Games/SteamLibrary` is the library that used to sit on the `/data` disk. Steam finds a moved tmpfs root: `~/Gaming/Games/SteamLibrary` (`~/Games/SteamLibrary` inside the sandbox) is the library
library when it is added once through _Settings → Storage → Add Drive_, or by starting Steam with that used to sit on the `/data` disk. Steam finds a moved library when it is added once through
the `libraryfolders.vdf` already listing it. _Settings → Storage → Add Drive_, or by starting Steam with the `libraryfolders.vdf` already listing
it.
## 4090 water block ## 4090 water block
+8 -16
View File
@@ -299,21 +299,19 @@ in
# Games / Media # Games / Media
# ====================================== # ======================================
# Home of the game sandboxes (hardening/nixpaks/modules/gaming-policy.nix); # Home of the game sandboxes (hardening/nixpaks/modules/gaming-policy.nix).
# installed games are in ~/Gaming/Games. # 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" "Gaming"
# .desktop entries created by games/installers (including the umu # .desktop entries created on the host for sandboxed launchers. Home
# launchers the nix-config-umu-game skill generates). Home Manager # Manager rewrites its mimeapps.list symlink into the bind mount at
# rewrites its mimeapps.list symlink into the bind mount at activation, # activation, so that survives too.
# so that survives too.
".local/share/applications" ".local/share/applications"
".steam"
".config/MangoHud" ".config/MangoHud"
".config/blender" ".config/blender"
".local/share/umu"
".local/share/Steam"
# WiVRn atomically replaces its settings and pairing files, so preserve # WiVRn atomically replaces its settings and pairing files, so preserve
# the directory rather than individual files. # the directory rather than individual files.
{ {
@@ -324,12 +322,6 @@ in
directory = ".android"; # ADB host keys for Quest USB authorization directory = ".android"; # ADB host keys for Quest USB authorization
mode = "0700"; mode = "0700";
} }
".local/state/Heroic"
".config/heroic"
".local/share/GOG.com"
".local/share/StardewValley"
".local/share/feral-interactive"
# ====================================== # ======================================
# Meeting / Remote Desktop / Recording # Meeting / Remote Desktop / Recording
+11 -29
View File
@@ -23,35 +23,17 @@ in
# Begineer Guide: <https://www.reddit.com/r/linux_gaming/wiki/faq/> # Begineer Guide: <https://www.reddit.com/r/linux_gaming/wiki/faq/>
# ========================================================================== # ==========================================================================
# Games installed by Steam works fine on NixOS, no other configuration needed. # Steam runs in a nixpak sandbox (hardening/nixpaks/steam.nix) that the
# https://github.com/NixOS/nixpkgs/blob/master/doc/packages/steam.section.md # home manager installs, so `programs.steam` is off: its `package` option
programs.steam = { # calls `.override` on whatever it is given, which a sandbox script cannot
# Some location that should be persistent: # answer, and it would install a second, unsandboxed steam plus `steam-run`.
# ~/.local/share/Steam - The default Steam install location # What is kept from it: 32-bit graphics, which the game sandboxes mount as
# ~/.local/share/Steam/steamapps/common - The default Game install location # /run/opengl-driver-32, and the controller/VR udev rules. Dropped with it:
# ~/.steam/root - A symlink to ~/.local/share/Steam # `extest` (needs /dev/uinput, which no sandbox gets) and the host
# ~/.steam - Some Symlinks & user info # protontricks (it would run Wine on game-writable prefixes; the umu
enable = true; # sandbox carries one instead).
package = pkgs.steam; hardware.graphics.enable32Bit = true;
# https://github.com/Winetricks/winetricks hardware.steam-hardware.enable = true;
# 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
];
};
# Expose DW-Proton to umu-run: umu ignores Steam's # Expose DW-Proton to umu-run: umu ignores Steam's
# STEAM_EXTRA_COMPAT_TOOLS_PATHS, and a store path baked into a generated # STEAM_EXTRA_COMPAT_TOOLS_PATHS, and a store path baked into a generated