mirror of
https://github.com/ryan4yin/nix-config.git
synced 2026-11-13 12:13:46 +01:00
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:
1 parent
08d2ff7578
commit
b726c28c14
25 files changed
+467
-161
No files matched your search
@@ -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")
|
||||
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
@@ -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 |
|
||||
|
||||
+33
-16
@@ -33,7 +33,7 @@ workflow.
|
||||
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 |
|
||||
@@ -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 |
|
||||
| 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 |
|
||||
| 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.
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
})
|
||||
];
|
||||
|
||||
@@ -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
|
||||
];
|
||||
}
|
||||
@@ -48,15 +48,19 @@
|
||||
pulse = lib.mkDefault true;
|
||||
pipewire = lib.mkDefault false;
|
||||
};
|
||||
bind.rw = with sloth; [
|
||||
(concat' runtimeDir "/doc")
|
||||
bind.rw =
|
||||
with sloth;
|
||||
[
|
||||
# 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
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -13,9 +13,10 @@
|
||||
// {
|
||||
default = true;
|
||||
};
|
||||
config.bubblewrap.bind.rw = lib.mkIf config.xdgStorage.enable (
|
||||
config.bubblewrap.bind = lib.mkIf config.xdgStorage.enable (
|
||||
with sloth;
|
||||
[
|
||||
{
|
||||
rw = [
|
||||
[
|
||||
(mkdir appCacheDir)
|
||||
xdgCacheHome
|
||||
@@ -32,6 +33,15 @@
|
||||
(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")
|
||||
];
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
];
|
||||
}
|
||||
@@ -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
|
||||
];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
mkAppPolicy,
|
||||
gamingChecks,
|
||||
...
|
||||
}:
|
||||
mkAppPolicy "heroic" {
|
||||
network = "pasta";
|
||||
media = false;
|
||||
pulse = true;
|
||||
x11 = true;
|
||||
tray = true;
|
||||
notifications = false;
|
||||
extraChecks = gamingChecks "heroic";
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
mkAppPolicy,
|
||||
gamingChecks,
|
||||
...
|
||||
}:
|
||||
mkAppPolicy "steam" {
|
||||
network = "pasta";
|
||||
media = false;
|
||||
pulse = true;
|
||||
x11 = true;
|
||||
tray = true;
|
||||
notifications = false;
|
||||
extraChecks = gamingChecks "steam";
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -20,11 +20,11 @@ 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; [
|
||||
home.packages = with pkgs; [
|
||||
# https://github.com/flightlessmango/MangoHud
|
||||
# a simple overlay program for monitoring FPS, temperature, CPU and GPU load, and more.
|
||||
mangohud
|
||||
@@ -35,16 +35,15 @@ in
|
||||
# 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; [
|
||||
|
||||
# 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/
|
||||
(heroic.override {
|
||||
extraPkgs = _pkgs: [
|
||||
pkgs.gamescope
|
||||
nixpaks.heroic
|
||||
];
|
||||
})
|
||||
]);
|
||||
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user