mirror of
https://github.com/ryan4yin/nix-config.git
synced 2026-11-13 12:13:46 +01:00
feat(nixpak): sandbox umu-launcher with ~/Gaming as its home (#519)
* feat(nixpak): sandbox umu-launcher with ~/Games as its home umu-run, winetricks and bbe leave the host PATH, so umu launchers generated before this stop working until rewritten with `umu-install.nu <name> - <launcher> <gameid>`. Assisted-by: Claude Agent SDK (claude-opus-5-5) * refactor(nixpak): mount ~/Gaming as the game sandbox home idols-ai now preserves ~/Gaming instead of ~/Games, so existing games vanish from the host after deploy until the preserved ~/Games is moved to ~/Gaming/Games. Assisted-by: Claude Agent SDK (claude-opus-5-5) * docs(nixpak): record umu runtime validation Assisted-by: Claude Agent SDK (claude-opus-5-5) Assisted-by: DeepSeek Harness (deepseek-flash)
This commit is contained in:
1 parent
0704ba5e21
commit
08d2ff7578
21 files changed
+802
-251
No files matched your search
@@ -12,34 +12,63 @@ description:
|
||||
The bundled `scripts/umu-install.nu` renders the files in `scripts/templates/`. It creates a prefix,
|
||||
runs the installer, and writes launchers. Use the Nix DW-Proton default unless research identifies a
|
||||
specific installed release; then select it with `PROTONPATH` and pin that path in the game's `conf`.
|
||||
**Every per-game detail lives under `~/Games/`**, never in this repo.
|
||||
**Every per-game detail lives under `~/Gaming/Games/`**, never in this repo.
|
||||
|
||||
## The sandbox
|
||||
|
||||
Games run only inside the umu nixpak sandbox (`hardening/nixpaks/umu.nix`, policy in
|
||||
`hardening/nixpaks/modules/gaming-policy.nix`). It mounts `~/Gaming` over `$HOME`; no other host
|
||||
file is visible or writable. `umu-run`, `winetricks`, `bbe`, `mangohud` and `gamescope` exist only
|
||||
inside it, and `umu-sandbox <command>` runs a command there.
|
||||
|
||||
**Two views of one path:** `~/Gaming/<path>` on the host is `~/<path>` in the sandbox, so a game
|
||||
lives at `~/Gaming/Games/<name>` on the host and at `~/Games/<name>` inside. The host has no
|
||||
`~/Games`: a path written that way is a sandbox path. Typed after `umu-sandbox` in a host shell,
|
||||
`~/Games/<name>/run` expands to exactly that sandbox path. Host environment variables do not reach
|
||||
the sandbox; put them in `conf`.
|
||||
|
||||
Everything under `~/Gaming` is writable by the games, so **the host never executes a file from it**:
|
||||
|
||||
- launch with `umu-sandbox ~/Games/<name>/run` and use `umu-sandbox ~/Games/<name>/exec ...`; the
|
||||
generated desktop entry does the former. Running `run` or `exec` directly refuses to start.
|
||||
- the installer and `prelaunch` also run through `umu-sandbox`;
|
||||
- `umu-install.nu` refuses to write through a symlink in the game directory, and uninstalling uses
|
||||
the repo's `umu-uninstall.nu`, not a script in `~/Gaming`.
|
||||
|
||||
## Bundled files
|
||||
|
||||
- `scripts/umu-install.nu`: installer and template renderer
|
||||
- `scripts/umu-uninstall.nu`: removes a game's desktop entry and, after confirmation, its directory
|
||||
- `scripts/common.nu`: name, symlink and sandbox-path helpers shared by both
|
||||
- `scripts/templates/*.tpl`: non-executable templates; placeholders use `@NAME@`
|
||||
- `scripts/tests/test_install.py`: offline regression tests
|
||||
|
||||
## Layout
|
||||
|
||||
- `~/Games/global.conf`: optional, sourced by every game before its own `conf`
|
||||
- `~/Games/<name>/prefix`: the Wine prefix (`WINEPREFIX`)
|
||||
- `~/Games/<name>/conf`: optional per-game overrides (`ENABLE_MANGOHUD=0`, ...)
|
||||
- `~/Games/<name>/prelaunch`: optional executable, run before every launch
|
||||
- `~/Games/<name>/run`: generated launcher
|
||||
- `~/Games/<name>/exec`: generated helper: `exec <exe-or-tool> [args...]`
|
||||
- `~/Games/<name>/<name>.desktop`: generated desktop entry
|
||||
- `~/Games/<name>/uninstall`: generated: drop the desktop entry and (with `y`) the dir
|
||||
Host paths; inside the sandbox drop the `Gaming/`.
|
||||
|
||||
- `~/Gaming`: the sandbox's `$HOME`, shared with the other game launchers (umu runtime, Steam,
|
||||
Heroic, saves written to `$HOME`)
|
||||
- `~/Gaming/Games/global.conf`: optional, sourced by every game before its own `conf`
|
||||
- `~/Gaming/Games/<name>/prefix`: the Wine prefix (`WINEPREFIX`)
|
||||
- `~/Gaming/Games/<name>/conf`: optional per-game overrides (`ENABLE_MANGOHUD=0`, ...)
|
||||
- `~/Gaming/Games/<name>/prelaunch`: optional executable, run before every launch
|
||||
- `~/Gaming/Games/<name>/run`: generated launcher
|
||||
- `~/Gaming/Games/<name>/exec`: generated helper: `umu-sandbox ~/Games/<name>/exec <exe-or-tool>`
|
||||
- `~/Gaming/Games/<name>/<name>.desktop`: generated desktop entry
|
||||
|
||||
Run, from the repo root:
|
||||
|
||||
`nu .agents/skills/nix-config-umu-game/scripts/umu-install.nu <name> <setup.exe> <launcher> [gameid] [setup-args...]`
|
||||
|
||||
`<launcher>` is relative to the prefix and `GAMEID` defaults to `umu-default`. At install,
|
||||
`PROTONPATH` resolves from `$PROTONPATH`, `$UMU_PROTONPATH` (the Nix-provided DW-Proton), then
|
||||
Steam's per-user `compatibilitytools.d`. At launch, the generated `run` script checks the current
|
||||
`$PROTONPATH` and `$UMU_PROTONPATH` before its saved default. A game's `conf` is sourced after those
|
||||
defaults, so set `PROTONPATH` there to pin a particular installed version across launches.
|
||||
`<launcher>` is relative to the prefix and `GAMEID` defaults to `umu-default`. A `<setup.exe>`
|
||||
outside `~/Gaming` is copied into the game directory first; `-` skips the installer and only
|
||||
rewrites `run`, `exec` and the desktop entry. At install, `PROTONPATH` resolves from `$PROTONPATH`,
|
||||
`$UMU_PROTONPATH` (the Nix-provided DW-Proton), then Steam's per-user `compatibilitytools.d` in the
|
||||
sandbox home; it must be a store path or under `~/Gaming`. `run`, `exec` and the desktop entry hold
|
||||
sandbox paths. At launch, the generated `run` script checks the current `$PROTONPATH` and
|
||||
`$UMU_PROTONPATH` before its saved default. A game's `conf` is sourced after those defaults, so set
|
||||
`PROTONPATH` there to pin a particular installed version across launches.
|
||||
|
||||
## Procedure
|
||||
|
||||
@@ -50,21 +79,24 @@ defaults, so set `PROTONPATH` there to pin a particular installed version across
|
||||
(`protondb.com/app/<id>`), Steam Community / r/linux_gaming threads, and GitHub issues for the
|
||||
launcher and Proton. Record whether reports require a Proton family and exact release. If no
|
||||
exact release is required, use the Nix-provided DW-Proton default. If one is required, inspect
|
||||
`$UMU_PROTONPATH` and installed Steam tools under `~/.local/share/Steam/compatibilitytools.d/` or
|
||||
`~/.steam/root/compatibilitytools.d/`; select the matching installed path with `PROTONPATH`.
|
||||
Prefix the installer command with `PROTONPATH="<selected-tool-directory>"` and set the same value
|
||||
in the game's `conf` to pin it across launches; otherwise the runtime Nix `UMU_PROTONPATH` may
|
||||
take precedence. If the required release is absent from Nix and Steam, stop and ask the user
|
||||
before downloading it from elsewhere. Do not substitute a different release or use
|
||||
ProtonPlus/Lutris to fetch one.
|
||||
`$UMU_PROTONPATH`, the Nix GE-Proton
|
||||
(`nix build --no-link --print-out-paths .#nixosConfigurations.<host>.pkgs.proton-ge-bin.steamcompattool`)
|
||||
and installed Steam tools under `~/Gaming/.local/share/Steam/compatibilitytools.d/` (the
|
||||
sandbox's `~/.local/share/Steam/compatibilitytools.d/`); select the matching installed path with
|
||||
`PROTONPATH`. Prefix the installer command with `PROTONPATH="<selected-tool-directory>"` and set
|
||||
the same value in the game's `conf` to pin it across launches; otherwise the runtime Nix
|
||||
`UMU_PROTONPATH` may take precedence. If the required release is absent from Nix and Steam, stop
|
||||
and ask the user before downloading it from elsewhere. Do not substitute a different release or
|
||||
use ProtonPlus/Lutris to fetch one: a host-side downloader writes outside `~/Gaming`, where the
|
||||
sandbox cannot see it, and a store path cannot be modified by a game.
|
||||
2. **Slug.** Pick a lowercase `<name>` (e.g. `wuthering-waves`).
|
||||
3. **GAMEID.** The umu database maps a title to a `GAMEID` whose protonfixes add CJK fonts, drop the
|
||||
`SteamOS`/`SteamDeck` vars, and keep Wine's `Documents` inside the prefix. **Without it some
|
||||
games save into the host home or the wrong prefix.** Fetch
|
||||
`https://raw.githubusercontent.com/Open-Wine-Components/umu-database/main/umu-database.csv`,
|
||||
match the title, and use its `umu-<id>`; if nothing matches, leave the default.
|
||||
4. **Download.** `curl -fL <url> -o ~/Games/<name>/setup.exe`. The CN store pages are JS/token
|
||||
driven, so the user normally supplies the URL or the file itself.
|
||||
4. **Download.** `curl -fL <url> -o ~/Gaming/Games/<name>/setup.exe`. The CN store pages are
|
||||
JS/token driven, so the user normally supplies the URL or the file itself.
|
||||
5. **Install.** Run the bundled script. The first run also downloads umu's Steam runtime and the
|
||||
protonfix fonts, so it is slow. The installer is usually a GUI the user clicks through, but
|
||||
**extra args after `[gameid]` go to `setup.exe`**, so try a silent flag first (`/S`, `/quiet`,
|
||||
@@ -72,21 +104,23 @@ defaults, so set `PROTONPATH` there to pin a particular installed version across
|
||||
auto-starts the launcher. The script writes `run`/`exec` even if the installer exits non-zero, so
|
||||
a killed installer is not fatal.
|
||||
6. **Find the launcher.** If `<launcher>` is only known after install, locate it under
|
||||
`~/Games/<name>/prefix/drive_c` and set `LAUNCHER` in `~/Games/<name>/conf`. Re-running the
|
||||
installer script invokes `setup.exe` again; only do so if that installer is known to handle
|
||||
reruns.
|
||||
7. **Per-game fix.** Port step 1 findings into `~/Games/<name>/prelaunch` (bash, `chmod +x`):
|
||||
`~/Gaming/Games/<name>/prefix/drive_c` and set `LAUNCHER` in `~/Gaming/Games/<name>/conf`.
|
||||
Re-running the installer script invokes `setup.exe` again; only do so if that installer is known
|
||||
to handle reruns.
|
||||
7. **Per-game fix.** Port step 1 findings into `~/Gaming/Games/<name>/prelaunch` (bash, `chmod +x`):
|
||||
`winetricks` verbs via `~/Games/<name>/exec winetricks <verbs>`, file patches or registry tweaks
|
||||
via `~/Games/<name>/exec`. Never commit the fix to this repo.
|
||||
via `~/Games/<name>/exec`. `prelaunch` runs in the sandbox, so it uses sandbox paths and calls
|
||||
`exec` directly; from a host shell, prefix it with `umu-sandbox`. Never commit the fix to this
|
||||
repo.
|
||||
8. **Re-run every launch, not once.** A launcher that self-updates overwrites files it patches, so
|
||||
those patches belong in `prelaunch`.
|
||||
9. **Verify.** `~/Games/<name>/run` must open a visible launcher window. MangoHud is enabled by
|
||||
default with FPS and frame time at the top-left; set `ENABLE_MANGOHUD=0` in `conf` to disable it,
|
||||
or override `MANGOHUD_CONFIG`. Re-running `prelaunch` must be idempotent. The launcher may then
|
||||
download the game body itself (tens of GB) -- that is the launcher's job, not this skill's, so
|
||||
the install is **done** once the window is usable. A crawl usually means the proxy: the launcher
|
||||
probes the CDN itself and mihomo routes that probe through a distant node, so the download starts
|
||||
from a far mirror; routing is in the
|
||||
9. **Verify.** `umu-sandbox ~/Games/<name>/run` must open a visible launcher window. MangoHud is
|
||||
enabled by default with FPS and frame time at the top-left; set `ENABLE_MANGOHUD=0` in `conf` to
|
||||
disable it, or override `MANGOHUD_CONFIG`. Re-running `prelaunch` must be idempotent. The
|
||||
launcher may then download the game body itself (tens of GB) -- that is the launcher's job, not
|
||||
this skill's, so the install is **done** once the window is usable. A crawl usually means the
|
||||
proxy: the launcher probes the CDN itself and mihomo routes that probe through a distant node, so
|
||||
the download starts from a far mirror; routing is in the
|
||||
[mihomo README](../../../modules/nixos/desktop/networking/mihomo/README.md). `ENABLE_LOG=1`
|
||||
captures `last-run.log` when something misbehaves.
|
||||
|
||||
@@ -95,12 +129,13 @@ defaults, so set `PROTONPATH` there to pin a particular installed version across
|
||||
A WebView2/.NET (WPF) launcher that shows an empty or fully transparent window is rendering with
|
||||
`AllowsTransparency`. Fix the DLL, not Wine:
|
||||
|
||||
1. `find ~/Games/<name>/prefix/drive_c -iname 'launcher_main.dll'` (usually under a `X.Y.Z.W`
|
||||
1. `find ~/Gaming/Games/<name>/prefix/drive_c -iname 'launcher_main.dll'` (usually under a `X.Y.Z.W`
|
||||
version dir).
|
||||
2. Rename that WPF property in place, keeping the byte length so PE offsets stay valid:
|
||||
`bbe -e 's/\x12AllowsTransparency/\x09IsEnabled\x1bA\x00\x03AAAAA/' <dll>`.
|
||||
3. Put it in `~/Games/<name>/prelaunch` so it is re-applied on every launch, because a launcher
|
||||
self-update restores the original DLL. Restart the launcher after patching.
|
||||
`bbe -e 's/\x12AllowsTransparency/\x09IsEnabled\x1bA\x00\x03AAAAA/' <dll>`. `bbe` exists only in
|
||||
the sandbox: prefix it with `umu-sandbox` from a host shell.
|
||||
3. Put it in `~/Gaming/Games/<name>/prelaunch` so it is re-applied on every launch, because a
|
||||
launcher self-update restores the original DLL. Restart the launcher after patching.
|
||||
|
||||
## Half-width tile instead of fullscreen
|
||||
|
||||
@@ -139,8 +174,8 @@ The string itself is fine and so are the fonts: the same launcher
|
||||
renders 请输入手机号 and 登录 correctly, and the garbled label exists as correct UTF-16 in
|
||||
`KRSDKEx.dll`. The bytes are simply decoded with a single-byte Western codepage.
|
||||
|
||||
**This is not the prefix locale.** Setting `LANG=zh_CN.UTF-8` in `~/Games/<name>/conf` does change
|
||||
the prefix (verified in this repo: `"ACP"` went `1252` -> `936` and `"LocaleName"` `en-US` ->
|
||||
**This is not the prefix locale.** Setting `LANG=zh_CN.UTF-8` in `~/Gaming/Games/<name>/conf` does
|
||||
change the prefix (verified in this repo: `"ACP"` went `1252` -> `936` and `"LocaleName"` `en-US` ->
|
||||
`zh-CN`), and the garbling stayed **byte-for-byte identical**. The launcher decodes those bytes as
|
||||
Latin-1 no matter what Windows codepage it is told to use, so this is an upstream bug in its own
|
||||
text pipeline: report it, or accept it -- the launcher still works. `LANG` is nevertheless the lever
|
||||
@@ -156,40 +191,64 @@ installers honour it; try `/quiet` or `--silent` too, then fall back to the GUI.
|
||||
## Desktop entry and persistence
|
||||
|
||||
The script also writes `~/.local/share/applications/<name>.desktop` (and a copy at
|
||||
`~/Games/<name>/<name>.desktop`), so the game shows up in the desktop launcher. Under an
|
||||
impermanence setup both `~/Games` and `~/.local/share/applications` must be in the host's
|
||||
`~/Gaming/Games/<name>/<name>.desktop`), so the game shows up in the desktop launcher. The host copy
|
||||
is written by the installer on the host; the sandbox cannot see or change it. Under an impermanence
|
||||
setup both `~/Gaming` and `~/.local/share/applications` must be in the host's
|
||||
`preservation.preserveAt` list, in `hosts/<dir>/preservation.nix`.
|
||||
|
||||
## Uninstall
|
||||
|
||||
Run `~/Games/<name>/uninstall`: it removes the desktop entry and then asks before deleting
|
||||
`~/Games/<name>` (prefix + game files). To keep the game and only drop the menu entry, delete the
|
||||
`.desktop` file by hand.
|
||||
From the repo root:
|
||||
|
||||
`nu .agents/skills/nix-config-umu-game/scripts/umu-uninstall.nu <name>`
|
||||
|
||||
It removes the desktop entry and then asks before deleting `~/Gaming/Games/<name>` (prefix + game
|
||||
files). To keep the game and only drop the menu entry, delete the `.desktop` file by hand.
|
||||
|
||||
## Games installed before the sandbox
|
||||
|
||||
Move `~/Games` to `~/Gaming/Games` and the old `~/.local/share/umu` to `~/Gaming/.local/share/umu`
|
||||
(on the same filesystem, so both are renames; umu can also download its runtime again). Paths inside
|
||||
the game directories stay valid, because the sandbox sees them at the old `~/Games/<name>`. The old
|
||||
desktop entries execute `run` on the host, and an old `uninstall` script is host-executed too, so
|
||||
rewrite the launchers without re-running the installer, passing the `LAUNCHER` and `GAMEID` from the
|
||||
old `run`:
|
||||
|
||||
`nu .agents/skills/nix-config-umu-game/scripts/umu-install.nu <name> - <launcher> <gameid>`
|
||||
|
||||
This also deletes the old `uninstall`.
|
||||
|
||||
## Optimize the launch
|
||||
|
||||
`~/Games/<name>/conf` (or `~/Games/global.conf`) is a shell file the generated `run` sources.
|
||||
Defaults are `ENABLE_MANGOHUD=1`, `MANGOHUD_CONFIG="fps=1,frametime=1,position=top-left"`,
|
||||
`~/Gaming/Games/<name>/conf` (or `~/Gaming/Games/global.conf`) is a shell file the generated `run`
|
||||
sources. Defaults are `ENABLE_MANGOHUD=1`, `MANGOHUD_CONFIG="fps=1,frametime=1,position=top-left"`,
|
||||
`ENABLE_GAMEMODE=0`, `ENABLE_GAMESCOPE=0`, `GAMESCOPE_ARGS="-f"`, and `ENABLE_LOG=0`. Set
|
||||
`ENABLE_MANGOHUD=0` to hide the overlay, `ENABLE_GAMESCOPE=1` +
|
||||
`GAMESCOPE_ARGS="-f -w 3840 -h 2160"` for the handheld / multi-monitor case, `ENABLE_GAMEMODE=1` for
|
||||
GameMode, and `ENABLE_LOG=1` to capture the run. A dGPU wrapper still goes outside:
|
||||
`nvidia-offload ~/Games/<name>/run`. Shader caches are kept in `~/Games/<name>/shader-cache`.
|
||||
`GAMESCOPE_ARGS="-f -w 3840 -h 2160"` for the handheld / multi-monitor case, and `ENABLE_LOG=1` to
|
||||
capture the run. Gamescope runs inside the sandbox without `CAP_SYS_NICE`, so it cannot raise its
|
||||
own priority. `ENABLE_GAMEMODE=1` has no effect: `gamemoderun` is not in the sandbox, whose PID
|
||||
namespace and D-Bus filter keep `gamemoded` from managing the game. A host wrapper such as
|
||||
`nvidia-offload` does not work either, because the sandbox clears the environment; export its
|
||||
variables (`__NV_PRIME_RENDER_OFFLOAD=1`, ...) in `conf` instead. Shader caches are kept in
|
||||
`~/Gaming/Games/<name>/shader-cache`.
|
||||
|
||||
## Escape hatches
|
||||
|
||||
- `~/Games/<name>/exec <exe-or-tool> [args...]` runs anything in the prefix with the right env: an
|
||||
absolute path to a game exe or repair tool, or a bare Wine tool (`winecfg`, `explorer`, `regedit`,
|
||||
`uninstaller`), which it runs as `umu-run <Proton's wine> <tool>`. Both forms are handed to
|
||||
`umu-run`, so they execute inside umu's Steam runtime container -- that is what makes them work on
|
||||
a non-FHS distro like NixOS, where Proton's wine cannot start on its own.
|
||||
- `~/Games/<name>/run <game args>` passes extra args to the launcher.
|
||||
- `umu-sandbox ~/Games/<name>/exec <exe-or-tool> [args...]` runs anything in the prefix with the
|
||||
right env: an absolute path to a game exe or repair tool, or a bare Wine tool (`winecfg`,
|
||||
`explorer`, `regedit`, `uninstaller`), which it runs as `umu-run <Proton's wine> <tool>`. Both
|
||||
forms are handed to `umu-run`, so they execute inside umu's Steam runtime container -- that is
|
||||
what makes them work on a non-FHS distro like NixOS, where Proton's wine cannot start on its own.
|
||||
- `umu-sandbox ~/Games/<name>/run <game args>` passes extra args to the launcher.
|
||||
- Kill a stuck prefix with `pkill -f '/Games/<name>/prefix'`, or
|
||||
`rm -f ~/Games/<name>/prefix/pfx.lock`.
|
||||
`rm -f ~/Gaming/Games/<name>/prefix/pfx.lock`.
|
||||
|
||||
## Common mistakes
|
||||
|
||||
- Committing a game name, URL, or patch to this repo instead of `~/Games/<name>/`.
|
||||
- Launching again while the first launch after a Proton update or a moved umu runtime shows no
|
||||
window: it is preparing steamrt4 and upgrading the prefix, which can take a minute, and a second
|
||||
launch blocks on the same locks. Wait, or `pkill -f '/Games/<name>/prefix'` and launch once.
|
||||
- Committing a game name, URL, or patch to this repo instead of `~/Gaming/Games/<name>/`.
|
||||
- Patching once instead of in `prelaunch` -- the next launcher self-update undoes it.
|
||||
- Dropping the `GAMEID` -- the in-game CJK fonts and the save location go wrong.
|
||||
- Installing more CJK fonts when the text is mojibake -- decode the bytes first (see above).
|
||||
@@ -198,7 +257,12 @@ GameMode, and `ENABLE_LOG=1` to capture the run. A dGPU wrapper still goes outsi
|
||||
- Running a Wine tool without umu. `umu-run winecfg` does not work -- umu only special-cases
|
||||
`winetricks` -- and `$PROTONPATH/files/bin/wine winecfg` fails on any non-FHS distro (on NixOS the
|
||||
32-bit loader it needs lives only inside the Steam runtime container). Use
|
||||
`~/Games/<name>/exec winecfg`; `exec` goes through `umu-run`, which provides that container.
|
||||
`umu-sandbox ~/Games/<name>/exec winecfg`; `exec` goes through `umu-run`, which provides that
|
||||
container.
|
||||
- Running `run`, `exec` or `prelaunch` from a host shell, or adding a host-side helper that executes
|
||||
a file under `~/Gaming`: games can rewrite those files. Go through `umu-sandbox`.
|
||||
- Pointing `PROTONPATH`, a launcher or an installer at a host path outside `~/Gaming`: the sandbox
|
||||
cannot see it. Inside `conf` and `prelaunch`, write sandbox paths (`~/Games/<name>`).
|
||||
- Waiting for the game download before calling the install done: the launcher's own download is out
|
||||
of scope here.
|
||||
- On NixOS a game needing 32-bit or Vulkan needs `hardware.graphics.enable32Bit`, and Steam is a
|
||||
@@ -212,8 +276,9 @@ From the repo root:
|
||||
python3 .agents/skills/nix-config-umu-game/scripts/tests/test_install.py
|
||||
```
|
||||
|
||||
Requires `python3`, `nu`, `bash`, and `shellcheck`. Uses temporary homes and a stub `umu-run`; no
|
||||
game downloads, Wine windows, or changes to existing games. It checks template permissions, rendered
|
||||
shell syntax and lint, installer arguments and failures, configuration precedence, prelaunch,
|
||||
logging, launch wrappers, literal paths, runtime Proton overrides, tool routing, and uninstall
|
||||
confirmation.
|
||||
Requires `python3`, `nu`, `bash`, and `shellcheck`. Uses temporary homes and stub `umu-run` and
|
||||
`umu-sandbox`; no game downloads, Wine windows, or changes to existing games. It checks template
|
||||
permissions, rendered shell syntax and lint, installer arguments and failures, configuration
|
||||
precedence, prelaunch, logging, launch wrappers, literal paths, runtime Proton overrides, tool
|
||||
routing, uninstall confirmation, the sandbox guard, host-to-sandbox path translation, and refusal to
|
||||
write through planted symlinks.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Helpers shared by umu-install.nu and umu-uninstall.nu.
|
||||
#
|
||||
# Games only run in the umu sandbox (hardening/nixpaks/umu.nix), which mounts
|
||||
# ~/Gaming over $HOME: a game installed at ~/Gaming/Games/<name> on the host is
|
||||
# ~/Games/<name> inside. Everything under ~/Gaming is writable by the games, so
|
||||
# these scripts, which run on the host, treat it as untrusted: they refuse
|
||||
# symlinks before writing or deleting there, and never execute a file from it.
|
||||
|
||||
# The sandbox home, as seen from the host.
|
||||
export def gaming-dir [] {
|
||||
$nu.home-dir | path join "Gaming"
|
||||
}
|
||||
|
||||
# Where games are installed, as seen from the host.
|
||||
export def games-dir [] {
|
||||
gaming-dir | path join "Games"
|
||||
}
|
||||
|
||||
export def reject-symlink [path: string, label: string] {
|
||||
let kind = (try { $path | path type } catch { "missing" })
|
||||
if $kind == "symlink" {
|
||||
print --stderr $"error: ($label) must not be a symlink: ($path)"
|
||||
exit 2
|
||||
}
|
||||
}
|
||||
|
||||
# Both roots must be real directories before anything below them is touched.
|
||||
export def reject-symlinked-roots [] {
|
||||
reject-symlink (gaming-dir) "Gaming directory"
|
||||
reject-symlink (games-dir) "Games directory"
|
||||
}
|
||||
|
||||
export def check-name [name: string] {
|
||||
if not ($name =~ '^[a-z0-9]+(-[a-z0-9]+)*$') {
|
||||
print --stderr "error: name must be a lowercase slug (letters, digits, hyphens)"
|
||||
exit 2
|
||||
}
|
||||
}
|
||||
|
||||
# The path as the sandbox sees it, or null if the sandbox cannot see it:
|
||||
# ~/Gaming becomes $HOME, and the Nix store is read-only.
|
||||
export def sandbox-path [path: string] {
|
||||
let full = ($path | path expand)
|
||||
let store = ($env.NIX_STORE_DIR? | default "/nix/store")
|
||||
let gaming = (gaming-dir)
|
||||
if $full == $gaming or ($full | str starts-with $"($gaming)/") {
|
||||
$full | str replace $gaming $nu.home-dir
|
||||
} else if ($full | str starts-with $"($store)/") {
|
||||
$full
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
# Generated by the nix-config-umu-game skill -- run a tool in this prefix.
|
||||
# exec <exe-or-tool> [args...] e.g. exec winecfg / exec explorer
|
||||
# umu-sandbox ~/Games/<name>/exec <exe-or-tool> [args...] e.g. winecfg
|
||||
set -euo pipefail
|
||||
# The host must never execute this game-writable file: launch it with
|
||||
# `umu-sandbox <this file>`, as the desktop entry does. This check only catches
|
||||
# mistakes; the sandbox is the boundary.
|
||||
if [ "${UMU_SANDBOX:-}" != 1 ]; then
|
||||
echo "run this inside the umu sandbox: umu-sandbox $0" >&2
|
||||
exit 1
|
||||
fi
|
||||
export WINEPREFIX=@WINEPREFIX@
|
||||
DEFAULT_PROTONPATH=@PROTONPATH@
|
||||
export PROTONPATH="${PROTONPATH:-${UMU_PROTONPATH:-$DEFAULT_PROTONPATH}}"
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
Type=Application
|
||||
Name=@DISPLAY_NAME@
|
||||
Comment=Windows game launcher installed via umu
|
||||
Exec=@RUN@
|
||||
Path=@DIR@
|
||||
Exec=umu-sandbox @RUN@
|
||||
Terminal=false
|
||||
Categories=Game;
|
||||
StartupNotify=false
|
||||
@@ -1,6 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
# Generated by the nix-config-umu-game skill -- re-run its script to regenerate.
|
||||
set -euo pipefail
|
||||
# The host must never execute this game-writable file: launch it with
|
||||
# `umu-sandbox <this file>`, as the desktop entry does. This check only catches
|
||||
# mistakes; the sandbox is the boundary.
|
||||
if [ "${UMU_SANDBOX:-}" != 1 ]; then
|
||||
echo "run this inside the umu sandbox: umu-sandbox $0" >&2
|
||||
exit 1
|
||||
fi
|
||||
HERE="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)"
|
||||
# Defaults; ~/Games/global.conf and this game conf/ override them.
|
||||
WINEPREFIX=@WINEPREFIX@
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Generated by the nix-config-umu-game skill.
|
||||
set -euo pipefail
|
||||
GAMES_DIR_PATH=@GAMES_DIR@
|
||||
if [[ -L "$GAMES_DIR_PATH" ]]; then
|
||||
echo "refusing symlinked Games directory: $GAMES_DIR_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
GAMES_DIR="$(cd "$GAMES_DIR_PATH" && pwd -P)"
|
||||
NAME=@NAME@
|
||||
HERE="$(cd "$(dirname "$(readlink -f "$0")")" && pwd -P)"
|
||||
if [[ "$HERE" != "$GAMES_DIR/$NAME" ]]; then
|
||||
echo "refusing to remove path outside $GAMES_DIR/$NAME: $HERE" >&2
|
||||
exit 1
|
||||
fi
|
||||
rm -f @DESKTOP_FILE@
|
||||
echo "desktop entry removed"
|
||||
printf "Delete %s and all its files? [y/N] " "$HERE"
|
||||
read -r ans
|
||||
if [ "$ans" = y ] || [ "$ans" = Y ]; then
|
||||
cd /
|
||||
rm -rf "$HERE"
|
||||
echo "removed $HERE"
|
||||
else
|
||||
echo "kept $HERE"
|
||||
fi
|
||||
@@ -20,7 +20,7 @@ class InstallerTests(unittest.TestCase):
|
||||
bin_dir = root / "bin"
|
||||
bin_dir.mkdir()
|
||||
self.bin_dir = bin_dir
|
||||
self.proton = root / "proton"
|
||||
self.proton = root / "store/proton"
|
||||
(self.proton / "files/bin").mkdir(parents=True)
|
||||
(self.proton / "proton").touch()
|
||||
self.log = root / "call"
|
||||
@@ -31,6 +31,14 @@ printf "%s\n" "$WINEPREFIX" "$PROTONPATH" "$GAMEID" "$@" > "$CALL_LOG"
|
||||
exit "${STUB_EXIT:-0}"
|
||||
""")
|
||||
stub.chmod(0o755)
|
||||
self.sandbox_log = root / "sandbox-call"
|
||||
sandbox = bin_dir / "umu-sandbox"
|
||||
sandbox.write_text("""#!/usr/bin/env bash
|
||||
pwd >> "$SANDBOX_LOG"
|
||||
export UMU_SANDBOX=1
|
||||
exec "$@"
|
||||
""")
|
||||
sandbox.chmod(0o755)
|
||||
self.mangohud_log = root / "mangohud-call"
|
||||
hud = bin_dir / "mangohud"
|
||||
hud.write_text("""#!/usr/bin/env bash
|
||||
@@ -43,22 +51,39 @@ exec "$@"
|
||||
HOME=str(home),
|
||||
PROTONPATH=str(self.proton),
|
||||
CALL_LOG=str(self.log),
|
||||
SANDBOX_LOG=str(self.sandbox_log),
|
||||
# Stands in for /nix/store, which the sandbox can always read.
|
||||
NIX_STORE_DIR=str(root / "store"),
|
||||
MANGOHUD_LOG=str(self.mangohud_log),
|
||||
PATH=str(bin_dir) + os.pathsep + os.environ["PATH"],
|
||||
)
|
||||
for key in ("XDG_DATA_HOME", "BASH_ENV", "ENV"):
|
||||
for key in ("XDG_DATA_HOME", "BASH_ENV", "ENV", "UMU_PROTONPATH", "UMU_SANDBOX"):
|
||||
self.env.pop(key, None)
|
||||
self.game = home / "Games/test-game"
|
||||
self.use_home(home)
|
||||
self.launcher = "drive_c/Program Files/Test/launcher.exe"
|
||||
self.gameid = "umu-test"
|
||||
|
||||
def invoke_install(self, name, **extra):
|
||||
def use_home(self, home):
|
||||
self.home = home
|
||||
self.env["HOME"] = str(home)
|
||||
# The host side: umu-install.nu writes here.
|
||||
self.games = home / "Gaming/Games"
|
||||
self.game = self.games / "test-game"
|
||||
# The sandbox mounts ~/Gaming over $HOME. The stub umu-sandbox cannot
|
||||
# remap paths, so a symlink makes the sandbox paths written into the
|
||||
# launchers resolve on the host.
|
||||
self.sandbox_game = home / "Games/test-game"
|
||||
(home / "Games").symlink_to(self.games, target_is_directory=True)
|
||||
|
||||
def invoke_install(self, name, setup="setup.exe", **extra):
|
||||
if setup == "setup.exe":
|
||||
(self.home / setup).touch()
|
||||
return subprocess.run(
|
||||
[
|
||||
"nu",
|
||||
str(SCRIPTS / "umu-install.nu"),
|
||||
name,
|
||||
"setup.exe",
|
||||
setup,
|
||||
self.launcher,
|
||||
self.gameid,
|
||||
"/S",
|
||||
@@ -74,9 +99,19 @@ exec "$@"
|
||||
result = self.invoke_install("test-game", **extra)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
def uninstall(self, name="test-game", input="n\n"):
|
||||
return subprocess.run(
|
||||
["nu", str(SCRIPTS / "umu-uninstall.nu"), name],
|
||||
env=self.env,
|
||||
input=input,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
def call(self, name, *args, input=None):
|
||||
result = subprocess.run(
|
||||
[str(self.game / name), *args],
|
||||
[str(self.bin_dir / "umu-sandbox"), str(self.sandbox_game / name), *args],
|
||||
env=self.env,
|
||||
input=input,
|
||||
capture_output=True,
|
||||
@@ -87,7 +122,7 @@ exec "$@"
|
||||
return self.log.read_text().splitlines()
|
||||
|
||||
def test_name_must_be_one_slug_component_before_any_install_work(self):
|
||||
games = self.home / "Games"
|
||||
games = self.home / "Gaming"
|
||||
outside = self.home / "escaped"
|
||||
invalid_names = [str(outside), "../escaped", "nested/game", ".."]
|
||||
|
||||
@@ -100,40 +135,37 @@ exec "$@"
|
||||
self.assertFalse(games.exists())
|
||||
self.assertFalse(outside.exists())
|
||||
|
||||
def test_install_rejects_symlinked_games_root(self):
|
||||
outside = self.home / "outside-games"
|
||||
outside.mkdir()
|
||||
(self.home / "Games").symlink_to(outside, target_is_directory=True)
|
||||
def test_install_rejects_symlinked_roots(self):
|
||||
for root in (self.home / "Gaming", self.games):
|
||||
with self.subTest(root=root.name):
|
||||
root.parent.mkdir(parents=True, exist_ok=True)
|
||||
outside = self.home / f"outside-{root.name}"
|
||||
outside.mkdir()
|
||||
root.symlink_to(outside, target_is_directory=True)
|
||||
|
||||
result = self.invoke_install("test-game")
|
||||
result = self.invoke_install("test-game")
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("symlink", result.stderr)
|
||||
self.assertFalse(self.log.exists())
|
||||
self.assertEqual(list(outside.iterdir()), [])
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("symlink", result.stderr)
|
||||
self.assertFalse(self.log.exists())
|
||||
self.assertEqual(list(outside.iterdir()), [])
|
||||
root.unlink()
|
||||
|
||||
def test_uninstall_refuses_games_root_replaced_by_symlink(self):
|
||||
self.install()
|
||||
games = self.home / "Games"
|
||||
games = self.games
|
||||
relocated = self.home / "relocated-games"
|
||||
games.rename(relocated)
|
||||
games.symlink_to(relocated, target_is_directory=True)
|
||||
|
||||
result = subprocess.run(
|
||||
[str(self.game / "uninstall")],
|
||||
env=self.env,
|
||||
input="y\n",
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
result = self.uninstall(input="y\n")
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertTrue((relocated / "test-game/run").exists())
|
||||
|
||||
def test_install_rejects_symlinked_game_directory(self):
|
||||
games = self.home / "Games"
|
||||
games.mkdir()
|
||||
games = self.games
|
||||
games.mkdir(parents=True)
|
||||
outside = self.home / "outside"
|
||||
outside.mkdir()
|
||||
(games / "test-game").symlink_to(outside, target_is_directory=True)
|
||||
@@ -146,7 +178,7 @@ exec "$@"
|
||||
self.assertEqual(list(outside.iterdir()), [])
|
||||
|
||||
def test_install_rejects_symlinked_prefix(self):
|
||||
game = self.home / "Games/test-game"
|
||||
game = self.game
|
||||
game.mkdir(parents=True)
|
||||
outside = self.home / "outside"
|
||||
outside.mkdir()
|
||||
@@ -160,7 +192,7 @@ exec "$@"
|
||||
self.assertEqual(list(outside.iterdir()), [])
|
||||
|
||||
def test_install_rejects_dangling_symlink_prefix(self):
|
||||
game = self.home / "Games/test-game"
|
||||
game = self.game
|
||||
game.mkdir(parents=True)
|
||||
outside = self.home / "missing-prefix-target"
|
||||
(game / "prefix").symlink_to(outside, target_is_directory=True)
|
||||
@@ -178,14 +210,7 @@ exec "$@"
|
||||
self.game.rename(outside)
|
||||
self.game.symlink_to(outside, target_is_directory=True)
|
||||
|
||||
result = subprocess.run(
|
||||
[str(self.game / "uninstall")],
|
||||
env=self.env,
|
||||
input="y\n",
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
result = self.uninstall(input="y\n")
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertTrue(outside.exists())
|
||||
@@ -195,7 +220,6 @@ exec "$@"
|
||||
for name in (
|
||||
"run.sh.tpl",
|
||||
"exec.sh.tpl",
|
||||
"uninstall.sh.tpl",
|
||||
"launcher.desktop.tpl",
|
||||
):
|
||||
path = SCRIPTS / "templates" / name
|
||||
@@ -210,7 +234,7 @@ exec "$@"
|
||||
hud_call = self.mangohud_log.read_text().splitlines()
|
||||
self.assertEqual(hud_call[0], "fps=1,frametime=1,position=top-left")
|
||||
self.assertEqual(
|
||||
hud_call[1:], ["umu-run", str(self.game / "prefix" / self.launcher)]
|
||||
hud_call[1:], ["umu-run", str(self.sandbox_game / "prefix" / self.launcher)]
|
||||
)
|
||||
|
||||
def test_run_can_disable_mangohud_per_game(self):
|
||||
@@ -220,19 +244,25 @@ exec "$@"
|
||||
values = self.call("run")
|
||||
|
||||
self.assertFalse(self.mangohud_log.exists())
|
||||
self.assertEqual(values[3:], [str(self.game / "prefix" / self.launcher)])
|
||||
self.assertEqual(values[3:], [str(self.sandbox_game / "prefix" / self.launcher)])
|
||||
|
||||
def test_generation_and_setup_arguments(self):
|
||||
self.install()
|
||||
self.assertEqual(self.log.read_text().splitlines()[3:], ["setup.exe", "/S"])
|
||||
for name in ("run", "exec", "uninstall"):
|
||||
self.assertEqual(
|
||||
self.log.read_text().splitlines()[3:],
|
||||
[str(self.sandbox_game / "setup.exe"), "/S"],
|
||||
)
|
||||
self.assertFalse((self.game / "uninstall").exists())
|
||||
for name in ("run", "exec"):
|
||||
path = self.game / name
|
||||
self.assertTrue(os.access(path, os.X_OK))
|
||||
subprocess.run(["bash", "-n", str(path)], check=True)
|
||||
subprocess.run(["shellcheck", str(path)], check=True)
|
||||
desktop = self.home / ".local/share/applications/test-game.desktop"
|
||||
self.assertIn("Name=Test Game", desktop.read_text())
|
||||
self.assertIn("Exec=" + str(self.game / "run"), desktop.read_text())
|
||||
self.assertIn(
|
||||
"Exec=umu-sandbox " + str(self.sandbox_game / "run"), desktop.read_text()
|
||||
)
|
||||
self.assertEqual(
|
||||
desktop.read_bytes(), (self.game / "test-game.desktop").read_bytes()
|
||||
)
|
||||
@@ -252,7 +282,8 @@ exec "$@"
|
||||
values = self.call("run", "--game-arg")
|
||||
self.assertEqual(values[2], "local")
|
||||
self.assertEqual(
|
||||
values[3:], [str(self.game / "prefix" / self.launcher), "--game-arg"]
|
||||
values[3:],
|
||||
[str(self.sandbox_game / "prefix" / self.launcher), "--game-arg"],
|
||||
)
|
||||
self.assertTrue((self.game / "patched").exists())
|
||||
self.assertTrue((self.game / "last-run.log").exists())
|
||||
@@ -261,19 +292,18 @@ exec "$@"
|
||||
self.launcher = "drive_c/quote'\"$HOME/launcher.exe"
|
||||
self.install()
|
||||
values = self.call("run")
|
||||
self.assertEqual(values[3:], [str(self.game / "prefix" / self.launcher)])
|
||||
self.assertEqual(values[3:], [str(self.sandbox_game / "prefix" / self.launcher)])
|
||||
|
||||
def test_values_containing_placeholder_text_are_not_rerendered(self):
|
||||
self.home = self.home.parent / "@GAMEID@"
|
||||
self.home.mkdir()
|
||||
self.env["HOME"] = str(self.home)
|
||||
self.game = self.home / "Games/test-game"
|
||||
home = self.home.parent / "@GAMEID@"
|
||||
home.mkdir()
|
||||
self.use_home(home)
|
||||
self.launcher = "drive_c/@PROTONPATH@/launcher.exe"
|
||||
self.gameid = "$(printf injected)"
|
||||
self.install()
|
||||
values = self.call("run")
|
||||
self.assertEqual(values[0], str(self.game / "prefix"))
|
||||
self.assertEqual(values[3], str(self.game / "prefix" / self.launcher))
|
||||
self.assertEqual(values[0], str(self.sandbox_game / "prefix"))
|
||||
self.assertEqual(values[3], str(self.sandbox_game / "prefix" / self.launcher))
|
||||
|
||||
def test_game_conf_pins_installed_proton_over_system_default(self):
|
||||
self.install()
|
||||
@@ -334,19 +364,99 @@ exec "$@"
|
||||
def test_uninstall_confirmation(self):
|
||||
self.install()
|
||||
desktop = self.home / ".local/share/applications/test-game.desktop"
|
||||
self.call("uninstall", input="n\n")
|
||||
result = self.uninstall(input="n\n")
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertTrue(self.game.exists())
|
||||
self.assertFalse(desktop.exists())
|
||||
subprocess.run(
|
||||
[str(self.game / "uninstall")],
|
||||
env=self.env,
|
||||
input="y\n",
|
||||
text=True,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
result = self.uninstall(input="y\n")
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertFalse(self.game.exists())
|
||||
|
||||
def test_run_refuses_to_start_outside_the_sandbox(self):
|
||||
self.install()
|
||||
self.log.unlink()
|
||||
for name in ("run", "exec"):
|
||||
with self.subTest(name=name):
|
||||
result = subprocess.run(
|
||||
[str(self.game / name), "winecfg"],
|
||||
env=self.env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("umu-sandbox", result.stderr)
|
||||
self.assertFalse(self.log.exists())
|
||||
|
||||
def test_installer_and_prelaunch_run_in_the_sandbox(self):
|
||||
self.game.mkdir(parents=True)
|
||||
prelaunch = self.game / "prelaunch"
|
||||
prelaunch.write_text('#!/usr/bin/env bash\n[ "$UMU_SANDBOX" = 1 ]\n')
|
||||
prelaunch.chmod(0o755)
|
||||
self.install()
|
||||
# Both start in the home, which exists on the host and in the sandbox.
|
||||
self.assertEqual(
|
||||
self.sandbox_log.read_text().splitlines(), [str(self.home), str(self.home)]
|
||||
)
|
||||
|
||||
def test_rewrite_launchers_without_installer(self):
|
||||
result = self.invoke_install("test-game", setup="-")
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertFalse(self.log.exists())
|
||||
self.assertTrue((self.game / "run").exists())
|
||||
|
||||
def test_old_uninstall_script_is_removed(self):
|
||||
self.game.mkdir(parents=True)
|
||||
(self.game / "uninstall").write_text("#!/bin/sh\n")
|
||||
self.install()
|
||||
self.assertFalse((self.game / "uninstall").exists())
|
||||
|
||||
def test_install_refuses_to_write_through_planted_symlinks(self):
|
||||
target = self.home / "bashrc"
|
||||
target.write_text("host file\n")
|
||||
for name in ("run", "exec", "test-game.desktop", "setup.exe"):
|
||||
with self.subTest(name=name):
|
||||
self.game.mkdir(parents=True, exist_ok=True)
|
||||
link = self.game / name
|
||||
link.unlink(missing_ok=True)
|
||||
link.symlink_to(target)
|
||||
result = self.invoke_install("test-game")
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("symlink", result.stderr)
|
||||
self.assertEqual(target.read_text(), "host file\n")
|
||||
link.unlink()
|
||||
|
||||
def test_installer_in_the_sandbox_home_is_used_in_place(self):
|
||||
setup = self.home / "Gaming/Downloads/setup.exe"
|
||||
setup.parent.mkdir(parents=True)
|
||||
setup.touch()
|
||||
self.install_from(setup)
|
||||
self.assertEqual(
|
||||
self.log.read_text().splitlines()[3], str(self.home / "Downloads/setup.exe")
|
||||
)
|
||||
self.assertFalse((self.game / "setup.exe").exists())
|
||||
|
||||
def test_steam_proton_fallback_is_translated(self):
|
||||
proton = self.home / "Gaming/.local/share/Steam/compatibilitytools.d/dwproton"
|
||||
(proton / "files/bin").mkdir(parents=True)
|
||||
(proton / "proton").touch()
|
||||
self.env.pop("PROTONPATH")
|
||||
self.install()
|
||||
self.assertEqual(
|
||||
self.log.read_text().splitlines()[1],
|
||||
str(self.home / ".local/share/Steam/compatibilitytools.d/dwproton"),
|
||||
)
|
||||
|
||||
def test_proton_outside_the_sandbox_is_rejected(self):
|
||||
self.env["NIX_STORE_DIR"] = str(self.home / "store")
|
||||
result = self.invoke_install("test-game")
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("cannot see PROTONPATH", result.stderr)
|
||||
self.assertFalse(self.log.exists())
|
||||
|
||||
def install_from(self, setup):
|
||||
result = self.invoke_install("test-game", setup=str(setup))
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,13 +1,13 @@
|
||||
#!/usr/bin/env nu
|
||||
#
|
||||
# Install a Windows game launcher into its own umu-launcher Wine prefix, and
|
||||
# generate launch/exec/desktop/uninstall helpers for it.
|
||||
# generate launch/exec/desktop helpers for it.
|
||||
#
|
||||
# umu-launcher is the Proton backend Lutris/Heroic use: it creates and runs a
|
||||
# detached Wine prefix without Steam, and a GAMEID from the umu database pulls
|
||||
# in that game's protonfixes (fonts, env quirks, save location, ...). This
|
||||
# installer is fully generic -- it knows no game names. Every per-game detail
|
||||
# lives under ~/Games/<name>/:
|
||||
# lives under ~/Gaming/Games/<name>/ (~/Games/<name>/ inside the sandbox):
|
||||
#
|
||||
# prefix the Wine prefix (WINEPREFIX)
|
||||
# prelaunch optional executable, run before every launch
|
||||
@@ -15,15 +15,21 @@
|
||||
# run generated launcher
|
||||
# exec generated helper: run any exe/wine tool in the prefix
|
||||
# <name>.desktop generated desktop entry
|
||||
# uninstall generated: drop the desktop entry and (with confirmation) this dir
|
||||
#
|
||||
# ~/Games/global.conf, if present, is sourced by every run script before the
|
||||
# game's own conf; both may set ENABLE_GAMEMODE, ENABLE_GAMESCOPE,
|
||||
# Games only run in the umu sandbox (hardening/nixpaks/umu.nix), which mounts
|
||||
# ~/Gaming over $HOME. The installer, prelaunch, run and exec all execute inside
|
||||
# it via `umu-sandbox`, so the paths written into run, exec and the desktop
|
||||
# entry are sandbox paths; this script, on the host, only writes files.
|
||||
# umu-uninstall.nu removes a game.
|
||||
#
|
||||
# ~/Gaming/Games/global.conf, if present, is sourced by every run script before
|
||||
# the game's own conf; both may set ENABLE_GAMEMODE, ENABLE_GAMESCOPE,
|
||||
# GAMESCOPE_ARGS, ENABLE_LOG, PROTONPATH, GAMEID, LAUNCHER, ...
|
||||
#
|
||||
# PROTONPATH is resolved from $PROTONPATH, then $UMU_PROTONPATH (exported by
|
||||
# modules/nixos/desktop/gaming.nix from pkgs.dwproton-bin), then Steam's
|
||||
# per-user compatibilitytools.d.
|
||||
# per-user compatibilitytools.d in the sandbox home. It must be visible in the
|
||||
# sandbox: the Nix store or ~/Gaming.
|
||||
#
|
||||
# nu .agents/skills/nix-config-umu-game/scripts/umu-install.nu wuthering-waves \
|
||||
# ~/Downloads/WutheringWaves_setup_3.5.0.exe \
|
||||
@@ -31,11 +37,10 @@
|
||||
# umu-3513350
|
||||
#
|
||||
# Extra arguments after [gameid] are passed to the installer, e.g. "/S" or
|
||||
# "/quiet" for a silent install.
|
||||
# "/quiet" for a silent install. A <setup.exe> of "-" skips the installer and
|
||||
# only rewrites the launchers.
|
||||
|
||||
def games-dir [] {
|
||||
$nu.home-dir | path join "Games"
|
||||
}
|
||||
use common.nu *
|
||||
|
||||
# First candidate that actually contains a proton script.
|
||||
def resolve-protonpath [] {
|
||||
@@ -47,7 +52,7 @@ def resolve-protonpath [] {
|
||||
if (not ($umu | is-empty)) and (($umu | path join "proton") | path exists) {
|
||||
return $umu
|
||||
}
|
||||
let steam = ($nu.home-dir | path join ".local/share/Steam/compatibilitytools.d/dwproton")
|
||||
let steam = ((gaming-dir) | path join ".local/share/Steam/compatibilitytools.d/dwproton")
|
||||
if (($steam | path join "proton") | path exists) {
|
||||
return $steam
|
||||
}
|
||||
@@ -55,14 +60,6 @@ def resolve-protonpath [] {
|
||||
}
|
||||
|
||||
# "wuthering-waves" -> "Wuthering Waves"
|
||||
def reject-symlink [path: string, label: string] {
|
||||
let kind = (try { $path | path type } catch { "missing" })
|
||||
if $kind == "symlink" {
|
||||
print --stderr $"error: ($label) must not be a symlink: ($path)"
|
||||
exit 2
|
||||
}
|
||||
}
|
||||
|
||||
def display-name [name: string] {
|
||||
$name | split row "-" | each { |w| $w | str capitalize } | str join " "
|
||||
}
|
||||
@@ -70,16 +67,17 @@ def display-name [name: string] {
|
||||
def usage [] {
|
||||
print "Usage: .agents/skills/nix-config-umu-game/scripts/umu-install.nu <name> <setup.exe> <launcher> [gameid] [setup-args...]"
|
||||
print ""
|
||||
print " <name> directory under ~/Games (e.g. wuthering-waves)"
|
||||
print " <setup.exe> installer to run through umu (a host path)"
|
||||
print " <name> directory under ~/Gaming/Games (e.g. wuthering-waves)"
|
||||
print " <setup.exe> installer to run through umu (copied into the game directory"
|
||||
print " unless the sandbox sees it), or - to skip it"
|
||||
print " <launcher> launcher to run later, relative to the prefix"
|
||||
print " [gameid] umu database id for protonfixes (default umu-default)"
|
||||
print " [setup-args] extra args for setup.exe, e.g. /S or /quiet"
|
||||
print ""
|
||||
print "Writes ~/Games/<name>/{run,exec,<name>.desktop,uninstall}. A per-game"
|
||||
print "fix goes in ~/Games/<name>/prelaunch; overrides go in ~/Games/<name>/conf"
|
||||
print "or ~/Games/global.conf. PROTONPATH comes from $PROTONPATH or"
|
||||
print "$UMU_PROTONPATH."
|
||||
print "Writes ~/Gaming/Games/<name>/{run,exec,<name>.desktop}; launch with"
|
||||
print "umu-sandbox ~/Games/<name>/run (a sandbox path). A per-game fix goes in"
|
||||
print "prelaunch; overrides go in conf or ~/Gaming/Games/global.conf. PROTONPATH"
|
||||
print "comes from $PROTONPATH or $UMU_PROTONPATH."
|
||||
}
|
||||
|
||||
# Quote substituted shell values as data, including literal apostrophes.
|
||||
@@ -94,41 +92,45 @@ def render-template [name: string, values: record] {
|
||||
}
|
||||
}
|
||||
|
||||
def write-run [name: string, launcher: string] {
|
||||
let dir = ((games-dir) | path join $name)
|
||||
def write-run [dir: string, launcher: string] {
|
||||
let values = {
|
||||
WINEPREFIX: (shell-quote $env.WINEPREFIX)
|
||||
PROTONPATH: (shell-quote $env.PROTONPATH)
|
||||
GAMEID: (shell-quote $env.GAMEID)
|
||||
LAUNCHER: (shell-quote $launcher)
|
||||
}
|
||||
mkdir $dir
|
||||
for file in [run exec] {
|
||||
let target = ($dir | path join $file)
|
||||
reject-symlink $target $file
|
||||
render-template $"($file).sh.tpl" $values | save --force $target
|
||||
^chmod +x $target
|
||||
}
|
||||
}
|
||||
|
||||
def write-integration [name: string] {
|
||||
let dir = ((games-dir) | path join $name)
|
||||
def write-integration [name: string, dir: string] {
|
||||
let apps = ($nu.home-dir | path join ".local/share/applications")
|
||||
let entry = ($apps | path join $"($name).desktop")
|
||||
let copy = ($dir | path join $"($name).desktop")
|
||||
let desktop = (render-template "launcher.desktop.tpl" {
|
||||
DISPLAY_NAME: (display-name $name)
|
||||
RUN: ($dir | path join "run")
|
||||
DIR: $dir
|
||||
RUN: (sandbox-path ($dir | path join "run"))
|
||||
})
|
||||
mkdir $apps
|
||||
$desktop | save --force ($dir | path join $"($name).desktop")
|
||||
reject-symlink $copy "desktop entry copy"
|
||||
$desktop | save --force $copy
|
||||
$desktop | save --force $entry
|
||||
let uninstall = ($dir | path join "uninstall")
|
||||
render-template "uninstall.sh.tpl" {
|
||||
DESKTOP_FILE: (shell-quote $entry)
|
||||
GAMES_DIR: (shell-quote (games-dir))
|
||||
NAME: (shell-quote $name)
|
||||
} | save --force $uninstall
|
||||
^chmod +x $uninstall
|
||||
}
|
||||
|
||||
# Run a command in the umu sandbox with the game's environment. Start from the
|
||||
# host home: inside, that path is the sandbox home, so the working directory
|
||||
# exists on both sides.
|
||||
def in-sandbox [...command: string] {
|
||||
cd $nu.home-dir
|
||||
(^umu-sandbox env
|
||||
$"WINEPREFIX=($env.WINEPREFIX)"
|
||||
$"PROTONPATH=($env.PROTONPATH)"
|
||||
$"GAMEID=($env.GAMEID)"
|
||||
...$command)
|
||||
}
|
||||
|
||||
def main [...args: string] {
|
||||
@@ -137,48 +139,72 @@ def main [...args: string] {
|
||||
exit 1
|
||||
}
|
||||
let name = ($args | get 0)
|
||||
if not ($name =~ '^[a-z0-9]+(-[a-z0-9]+)*$') {
|
||||
print --stderr "error: name must be a lowercase slug (letters, digits, hyphens)"
|
||||
exit 2
|
||||
}
|
||||
check-name $name
|
||||
let setup = ($args | get 1)
|
||||
let launcher = ($args | get 2)
|
||||
let gameid = (if ($args | length) >= 4 { $args | get 3 } else { "umu-default" })
|
||||
let setupArgs = (if ($args | length) >= 5 { $args | skip 4 } else { [] })
|
||||
let root = (games-dir)
|
||||
reject-symlink $root "Games directory"
|
||||
let dir = ($root | path join $name)
|
||||
reject-symlinked-roots
|
||||
let dir = ((games-dir) | path join $name)
|
||||
let prefix = ($dir | path join "prefix")
|
||||
reject-symlink $dir "game directory"
|
||||
reject-symlink $prefix "Wine prefix"
|
||||
if $setup != "-" and not ($setup | path exists) {
|
||||
print --stderr $"error: installer not found: ($setup)"
|
||||
exit 2
|
||||
}
|
||||
|
||||
let protonpath = (resolve-protonpath)
|
||||
if $protonpath == null {
|
||||
print --stderr "error: no Proton found. Set PROTONPATH or UMU_PROTONPATH, or rebuild NixOS so modules/nixos/desktop/gaming.nix exports UMU_PROTONPATH."
|
||||
exit 1
|
||||
}
|
||||
$env.PROTONPATH = $protonpath
|
||||
let visibleProton = (sandbox-path $protonpath)
|
||||
if $visibleProton == null {
|
||||
print --stderr $"error: the umu sandbox cannot see PROTONPATH ($protonpath); use a Nix store path or one under ~/Gaming"
|
||||
exit 2
|
||||
}
|
||||
$env.PROTONPATH = $visibleProton
|
||||
|
||||
$env.WINEPREFIX = $prefix
|
||||
mkdir $dir
|
||||
$env.WINEPREFIX = (sandbox-path $prefix)
|
||||
$env.GAMEID = $gameid
|
||||
|
||||
print $"installing '($name)' into ($env.WINEPREFIX)"
|
||||
print $" PROTONPATH = ($env.PROTONPATH)"
|
||||
print $" GAMEID = ($env.GAMEID)"
|
||||
print "run the installer, then exit it (do not start the game)."
|
||||
if $setup != "-" {
|
||||
# The sandbox sees only ~/Gaming and the store, so bring the installer in.
|
||||
let installer = (if ((sandbox-path $setup) != null) {
|
||||
sandbox-path $setup
|
||||
} else {
|
||||
let target = ($dir | path join ($setup | path basename))
|
||||
reject-symlink $target "installer copy"
|
||||
cp $setup $target
|
||||
sandbox-path $target
|
||||
})
|
||||
|
||||
let inst = (^umu-run $setup ...$setupArgs | complete)
|
||||
if $inst.exit_code != 0 {
|
||||
print --stderr $"installer exited ($inst.exit_code); writing the launchers anyway"
|
||||
print $"installing '($name)' into ($prefix)"
|
||||
print $" PROTONPATH = ($env.PROTONPATH)"
|
||||
print $" GAMEID = ($env.GAMEID)"
|
||||
print "run the installer, then exit it (do not start the game)."
|
||||
|
||||
let inst = (in-sandbox umu-run $installer ...$setupArgs | complete)
|
||||
if $inst.exit_code != 0 {
|
||||
print --stderr $"installer exited ($inst.exit_code); writing the launchers anyway"
|
||||
}
|
||||
|
||||
let prelaunch = ($dir | path join "prelaunch")
|
||||
if ($prelaunch | path exists) {
|
||||
print $"running prelaunch in the sandbox: ($prelaunch)"
|
||||
in-sandbox (sandbox-path $prelaunch)
|
||||
}
|
||||
}
|
||||
|
||||
let prelaunch = ($dir | path join "prelaunch")
|
||||
if ($prelaunch | path exists) {
|
||||
print $"running prelaunch: ($prelaunch)"
|
||||
run-external $prelaunch
|
||||
write-run $dir $launcher
|
||||
write-integration $name $dir
|
||||
# Older versions generated an uninstall script here for the host to run;
|
||||
# a game could rewrite it. umu-uninstall.nu replaces it.
|
||||
let uninstall = ($dir | path join "uninstall")
|
||||
if ((try { $uninstall | path type } catch { "missing" }) != "missing") {
|
||||
rm --force $uninstall
|
||||
}
|
||||
|
||||
write-run $name $launcher
|
||||
write-integration $name
|
||||
print $"done. launch with: ($dir)/run"
|
||||
print $"done. launch with: umu-sandbox (sandbox-path ($dir | path join run))"
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env nu
|
||||
#
|
||||
# Remove a game installed by umu-install.nu: drop its desktop entry, then,
|
||||
# after confirmation, delete ~/Gaming/Games/<name> (prefix and game files). It
|
||||
# runs from this repo because anything under ~/Gaming is writable by the games.
|
||||
#
|
||||
# nu .agents/skills/nix-config-umu-game/scripts/umu-uninstall.nu wuthering-waves
|
||||
|
||||
use common.nu *
|
||||
|
||||
def main [name: string] {
|
||||
check-name $name
|
||||
reject-symlinked-roots
|
||||
let dir = ((games-dir) | path join $name)
|
||||
reject-symlink $dir "game directory"
|
||||
|
||||
let entry = ($nu.home-dir | path join ".local/share/applications" $"($name).desktop")
|
||||
rm --force $entry
|
||||
print "desktop entry removed"
|
||||
|
||||
if not ($dir | path exists) {
|
||||
print $"nothing to delete at ($dir)"
|
||||
return
|
||||
}
|
||||
print --no-newline $"Delete ($dir) and all its files? [y/N] "
|
||||
# nu's `input` ignores piped stdin; head reads it either way.
|
||||
let answer = (^head -n 1 | str trim)
|
||||
if $answer in [y Y] {
|
||||
rm --recursive --force $dir
|
||||
print $"removed ($dir)"
|
||||
} else {
|
||||
print $"kept ($dir)"
|
||||
}
|
||||
}
|
||||
+35
-3
@@ -53,6 +53,7 @@ explicitly; other files use the document portal. Every app also has private writ
|
||||
| MuseScore | Isolated | `~/.config/MuseScore`, `~/.local/share/MuseScore`, Documents/Downloads/Music/Pictures | ALSA via PipeWire, ALSA sequencer, X11/XWayland, print, notifications, IME, accessibility |
|
||||
| mpv | Isolated | `~/.config/mpv`, `~/.cache/mpv`, `~/.local/state/mpv`; media dirs read-only | MPRIS, file chooser, OpenURI |
|
||||
| imv | None | `$HOME` read-only | File chooser, OpenURI |
|
||||
| umu | Isolated | `~/Gaming` only, mounted as the sandbox `$HOME` | X11/XWayland, PulseAudio, 32-bit GPU drivers, joystick evdev, sysfs, OpenURI, inhibit, IME |
|
||||
|
||||
All browsers get read-only NSS certificates, printing, native notifications and accessibility.
|
||||
|
||||
@@ -69,6 +70,32 @@ All browsers get read-only NSS certificates, printing, native notifications and
|
||||
- Bookmarks, history, localStorage, IndexedDB and extension data remain readable by the browser.
|
||||
nixpak isolates them from other apps, not from browser compromise.
|
||||
|
||||
## Game launchers
|
||||
|
||||
Game launchers (umu now; Heroic and Steam to follow) share
|
||||
[gaming-policy.nix](nixpaks/modules/gaming-policy.nix). The goal is that games cannot read host
|
||||
data, not that they are isolated from each other:
|
||||
|
||||
- `~/Gaming` is mounted over `$HOME`; nothing else in the host home is visible. Installed games are
|
||||
in `~/Gaming/Games`, the sandbox's `~/Games`, which is where launchers install by default. The
|
||||
per-app XDG directories are off because games write saves straight into `$HOME`.
|
||||
- All launchers share that data, so they form one trust domain: a compromised game can change
|
||||
another launcher's configuration. Their grants therefore stay identical.
|
||||
- `~/Gaming` is game-writable, so the host never executes a file from it. Generated umu launchers
|
||||
run through `umu-sandbox`; see the `nix-config-umu-game` skill.
|
||||
- Controllers: host udev rules hard-link joystick evdev nodes (not ones that also claim to be a
|
||||
keyboard) into `/dev/gamepad-input`, mounted as the sandbox `/dev/input`. hidraw (Steam Input,
|
||||
SDL's HIDAPI drivers) and `/dev/uinput` are not granted; uinput would let a game inject input into
|
||||
the host session. Like the FIDO2 directory, `/dev/gamepad-input` is created at boot.
|
||||
- The sandbox gets Flatpak's `--device=all` sysfs set without `/sys/block`, the udev database and
|
||||
`/etc/passwd`, `/etc/group` and `/etc/machine-id`, which pressure-vessel and Wine need.
|
||||
- Proton's pressure-vessel container is a third bwrap layer (nixpak, buildFHSEnv, pressure-vessel).
|
||||
This relies on unprivileged user namespaces and on the inner FHS root having no `/.flatpak-info`;
|
||||
with it, pressure-vessel would switch to a Flatpak sub-sandbox it cannot get.
|
||||
- Portals see processes in the FHS layer as host apps, so no file chooser is granted (it would
|
||||
return host paths), and GameMode is unavailable: `gamemoded` cannot reach PIDs in the sandbox's
|
||||
namespace.
|
||||
|
||||
## Limits
|
||||
|
||||
- X11: Zoom and WeChat have shared-display trust; portal permissions cannot enforce portal-only
|
||||
@@ -121,8 +148,10 @@ All browsers get read-only NSS certificates, printing, native notifications and
|
||||
desktop/microphone audio, local NVENC H.264 MP4 recording and local RTMP streaming. External
|
||||
provider authentication and browser sources remain unvalidated.
|
||||
- Remmina, Blender, mpv and imv build and pass the policy suite; interactive validation is still
|
||||
pending. MuseScore passed launch, XWayland and ALSA-to-PipeWire routing checks; audible playback
|
||||
and user workflows still need confirmation.
|
||||
pending. umu runs Wuthering Waves and Star Rail from their desktop entries on the RTX 4090, with a
|
||||
working controller; audio and the in-sandbox isolation check are pending. MuseScore passed launch,
|
||||
XWayland and ALSA-to-PipeWire routing checks; audible playback and user workflows still need
|
||||
confirmation.
|
||||
- Chromium smoke test passed: renderer sandbox, environment clearing, bind scope, disposable-profile
|
||||
persistence and loopback policy.
|
||||
- FIDO2 browser passthrough is enabled for Firefox/Chromium/Chrome only. WebAuthn with the CanoKey
|
||||
@@ -135,7 +164,10 @@ All browsers get read-only NSS certificates, printing, native notifications and
|
||||
## Code map
|
||||
|
||||
- [nixpaks/](nixpaks/): wrappers and Home Manager package overlay.
|
||||
- [desktop-base.nix](nixpaks/modules/desktop-base.nix): shared runtime and XDG storage.
|
||||
- [desktop-base.nix](nixpaks/modules/desktop-base.nix),
|
||||
[xdg-storage.nix](nixpaks/modules/xdg-storage.nix): shared runtime and per-app XDG storage.
|
||||
- [gaming-policy.nix](nixpaks/modules/gaming-policy.nix), [nixpaks/umu.nix](nixpaks/umu.nix): game
|
||||
launcher sandbox and the umu wrapper.
|
||||
- [portal-groups.nix](nixpaks/modules/portal-groups.nix),
|
||||
[desktop-integration.nix](nixpaks/modules/desktop-integration.nix): portal and desktop service
|
||||
grants.
|
||||
|
||||
@@ -24,10 +24,20 @@ in
|
||||
# authoritative, and removal on unplug leaves no stale node for a reused
|
||||
# hidraw minor. 60-fido-id.rules sets ID_FIDO_TOKEN from the report
|
||||
# descriptor, so the CanoKey's keyboard interface is never linked.
|
||||
systemd.tmpfiles.rules = [ "d /dev/fido-hidraw 0755 root root -" ];
|
||||
#
|
||||
# Game sandboxes (see modules/gaming-policy.nix) get joysticks the same way:
|
||||
# their evdev nodes are linked into /dev/gamepad-input, which the sandbox
|
||||
# mounts as /dev/input. A device that also claims to be a keyboard is left
|
||||
# out, so key events never reach a game.
|
||||
systemd.tmpfiles.rules = [
|
||||
"d /dev/fido-hidraw 0755 root root -"
|
||||
"d /dev/gamepad-input 0755 root root -"
|
||||
];
|
||||
services.udev.extraRules = ''
|
||||
ACTION=="add", SUBSYSTEM=="hidraw", ENV{ID_FIDO_TOKEN}=="1", RUN+="${pkgs.coreutils}/bin/ln -f /dev/%k /dev/fido-hidraw/%k"
|
||||
ACTION=="remove", SUBSYSTEM=="hidraw", RUN+="${pkgs.coreutils}/bin/rm -f /dev/fido-hidraw/%k"
|
||||
ACTION=="add", SUBSYSTEM=="input", KERNEL=="event*|js*", ENV{ID_INPUT_JOYSTICK}=="1", ENV{ID_INPUT_KEYBOARD}!="1", RUN+="${pkgs.coreutils}/bin/ln -f $devnode /dev/gamepad-input/%k"
|
||||
ACTION=="remove", SUBSYSTEM=="input", KERNEL=="event*|js*", RUN+="${pkgs.coreutils}/bin/rm -f /dev/gamepad-input/%k"
|
||||
'';
|
||||
|
||||
# Add nixpaked Apps into nixpkgs, and reference them in home-manager or other nixos modules
|
||||
@@ -53,6 +63,7 @@ in
|
||||
mpv = wrapper super ./mpv.nix;
|
||||
imv = wrapper super ./imv.nix;
|
||||
wechat = wrapper pkgs-master ./wechat.nix;
|
||||
umu = wrapper super ./umu.nix;
|
||||
};
|
||||
})
|
||||
];
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
imports = [
|
||||
./portal-groups.nix
|
||||
./desktop-integration.nix
|
||||
./xdg-storage.nix
|
||||
];
|
||||
gpu = {
|
||||
enable = lib.mkDefault true;
|
||||
@@ -48,22 +49,6 @@
|
||||
pipewire = lib.mkDefault false;
|
||||
};
|
||||
bind.rw = with sloth; [
|
||||
[
|
||||
(mkdir appCacheDir)
|
||||
xdgCacheHome
|
||||
]
|
||||
[
|
||||
(mkdir appConfigDir)
|
||||
xdgConfigHome
|
||||
]
|
||||
[
|
||||
(mkdir appDataDir)
|
||||
xdgDataHome
|
||||
]
|
||||
[
|
||||
(mkdir (concat' appDir "/state"))
|
||||
xdgStateHome
|
||||
]
|
||||
(concat' runtimeDir "/doc")
|
||||
# Chrome/Firefox/Telegram/etc. use /tmp for single-instance IPC. Share
|
||||
# this directory across launches of the same app only; app IDs differ.
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# Shared policy for game launchers. Every launcher sees the same home: ~/Gaming
|
||||
# is mounted over $HOME, so game installs (~/Gaming/Games, the sandbox's
|
||||
# ~/Games where launchers put them by default), Proton builds, the umu runtime
|
||||
# and saves all live in that one directory. Nothing else in the host home is
|
||||
# visible. All launchers share that data, so they are one trust domain: keep
|
||||
# their grants identical here rather than per launcher.
|
||||
{
|
||||
sloth,
|
||||
...
|
||||
}:
|
||||
{
|
||||
imports = [
|
||||
./desktop-base.nix
|
||||
./network.nix
|
||||
];
|
||||
# Games write saves straight into $HOME (~/.factorio, ~/.renpy, ...), which
|
||||
# per-app XDG directories would silently discard on exit.
|
||||
xdgStorage.enable = false;
|
||||
pasta.mode = "isolate";
|
||||
portalGroups = {
|
||||
openUri = true;
|
||||
inhibit = true;
|
||||
inputMethod = true;
|
||||
};
|
||||
bubblewrap = {
|
||||
bind.rw = [
|
||||
[
|
||||
(sloth.mkdir (sloth.concat' sloth.homeDir "/Gaming"))
|
||||
sloth.homeDir
|
||||
]
|
||||
];
|
||||
# Wine and the Steam client talk X11 (XWayland).
|
||||
sockets.x11 = true;
|
||||
bind.ro = [
|
||||
# 32-bit games and Proton's 32-bit half; nixpak maps only the 64-bit
|
||||
# /run/opengl-driver.
|
||||
"/run/opengl-driver-32"
|
||||
# pressure-vessel resolves the user and D-Bus needs a machine ID. The
|
||||
# buildFHSEnv layer takes both from this /etc.
|
||||
"/etc/passwd"
|
||||
"/etc/group"
|
||||
"/etc/machine-id"
|
||||
# Flatpak's --device=all sysfs set minus /sys/block: Wine reads the CPU
|
||||
# topology from /sys/devices/system/cpu, and SDL, Wine's winebus and
|
||||
# pressure-vessel enumerate GPUs and controllers through sysfs and udev.
|
||||
"/sys/bus"
|
||||
"/sys/class"
|
||||
"/sys/dev"
|
||||
"/sys/devices"
|
||||
"/run/udev/data"
|
||||
"/run/udev/control"
|
||||
];
|
||||
# Joystick evdev nodes only, hard-linked by the host udev rules in
|
||||
# ../default.nix; the whole /dev/input would expose every keyboard.
|
||||
bind.dev = [
|
||||
[
|
||||
"/dev/gamepad-input"
|
||||
"/dev/input"
|
||||
]
|
||||
];
|
||||
env = {
|
||||
DISPLAY = sloth.envOr "DISPLAY" "";
|
||||
XAUTHORITY = sloth.envOr "XAUTHORITY" (sloth.concat' sloth.homeDir "/.Xauthority");
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
# Private XDG directories under ~/.var/app/<appId>, mounted over the host's.
|
||||
# Game launchers turn this off and mount a whole private home instead (see
|
||||
# gaming-policy.nix): games also write outside XDG, straight into $HOME.
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
sloth,
|
||||
...
|
||||
}:
|
||||
{
|
||||
options.xdgStorage.enable =
|
||||
lib.mkEnableOption "private per-app XDG cache, config, data and state directories"
|
||||
// {
|
||||
default = true;
|
||||
};
|
||||
config.bubblewrap.bind.rw = lib.mkIf config.xdgStorage.enable (
|
||||
with sloth;
|
||||
[
|
||||
[
|
||||
(mkdir appCacheDir)
|
||||
xdgCacheHome
|
||||
]
|
||||
[
|
||||
(mkdir appConfigDir)
|
||||
xdgConfigHome
|
||||
]
|
||||
[
|
||||
(mkdir appDataDir)
|
||||
xdgDataHome
|
||||
]
|
||||
[
|
||||
(mkdir (concat' appDir "/state"))
|
||||
xdgStateHome
|
||||
]
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
# umu-launcher sandbox for Windows games outside Steam. The launchers that the
|
||||
# nix-config-umu-game skill writes to ~/Gaming/Games/<name> are game-writable,
|
||||
# so the host never executes them: `umu-sandbox ~/Games/<name>/run` (a sandbox
|
||||
# path) runs the whole script, conf and prelaunch included, inside the sandbox.
|
||||
{
|
||||
steam,
|
||||
umu-launcher-unwrapped,
|
||||
mangohud,
|
||||
winetricks,
|
||||
bbe,
|
||||
gamescope,
|
||||
dwproton-bin,
|
||||
writeShellScript,
|
||||
mkNixPak,
|
||||
...
|
||||
}:
|
||||
let
|
||||
appId = "org.openwinecomponents.umu";
|
||||
# pkgs/by-name/um/umu-launcher, with a generic entry point and the tools the
|
||||
# generated launchers call.
|
||||
package = steam.buildRuntimeEnv {
|
||||
pname = "umu-sandbox";
|
||||
inherit (umu-launcher-unwrapped) version;
|
||||
extraPkgs = _: [
|
||||
umu-launcher-unwrapped
|
||||
mangohud
|
||||
winetricks
|
||||
bbe
|
||||
gamescope
|
||||
];
|
||||
executableName = "umu-sandbox";
|
||||
runScript = writeShellScript "umu-sandbox" ''exec "$@"'';
|
||||
# As upstream: Legendary spawns umu and exits without waiting for it.
|
||||
dieWithParent = false;
|
||||
meta = umu-launcher-unwrapped.meta // {
|
||||
mainProgram = "umu-sandbox";
|
||||
};
|
||||
};
|
||||
wrapped = mkNixPak {
|
||||
config = {
|
||||
imports = [ ./modules/gaming-policy.nix ];
|
||||
app = {
|
||||
inherit package;
|
||||
binPath = "bin/umu-sandbox";
|
||||
};
|
||||
flatpak = { inherit appId; };
|
||||
bubblewrap.env = {
|
||||
# The session's UMU_PROTONPATH does not survive clearEnv; this path
|
||||
# also tracks every dwproton-bin update.
|
||||
UMU_PROTONPATH = "${dwproton-bin.steamcompattool}";
|
||||
# Lets the generated launchers refuse to start outside the sandbox.
|
||||
UMU_SANDBOX = "1";
|
||||
};
|
||||
};
|
||||
};
|
||||
in
|
||||
wrapped.config.script
|
||||
@@ -18,6 +18,24 @@ let
|
||||
mountStrings =
|
||||
app: builtins.filter builtins.isString (lib.flatten (with app.bubblewrap.bind; rw ++ ro ++ dev));
|
||||
|
||||
# Render a sloth value as the launcher would, with $VAR for environment
|
||||
# lookups, so suites can assert on paths built from $HOME and friends.
|
||||
renderSloth =
|
||||
value:
|
||||
if builtins.isString value then
|
||||
value
|
||||
else if value.type == "env" then
|
||||
"$" + value.key
|
||||
else if value.type == "concat" then
|
||||
renderSloth value.a + renderSloth value.b
|
||||
else if value.type == "mkdir" then
|
||||
renderSloth value.dir
|
||||
else
|
||||
throw "cannot render sloth value of type ${value.type}";
|
||||
|
||||
# A bind entry is a path, or a [ source destination ] pair.
|
||||
renderBind = entry: if builtins.isList entry then map renderSloth entry else renderSloth entry;
|
||||
|
||||
forbiddenDbus =
|
||||
argument:
|
||||
lib.any (name: lib.hasInfix name argument) [
|
||||
@@ -41,6 +59,7 @@ let
|
||||
path == "/dev"
|
||||
|| lib.hasPrefix "/dev/video" path
|
||||
|| lib.hasPrefix "/dev/bus/usb" path
|
||||
|| path == "/dev/uinput"
|
||||
|| path == "/run/dbus"
|
||||
|| path == "/run/dbus/system_bus_socket"
|
||||
|| path == "/sys"
|
||||
@@ -184,6 +203,7 @@ in
|
||||
appNames
|
||||
load
|
||||
mountStrings
|
||||
renderBind
|
||||
withoutSecretPortal
|
||||
mkAppPolicy
|
||||
globalChecks
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
lib,
|
||||
load,
|
||||
mkAppPolicy,
|
||||
renderBind,
|
||||
...
|
||||
}:
|
||||
let
|
||||
app = load "umu";
|
||||
rw = map renderBind app.bubblewrap.bind.rw;
|
||||
source = entry: if builtins.isList entry then builtins.head entry else entry;
|
||||
in
|
||||
mkAppPolicy "umu" {
|
||||
network = "pasta";
|
||||
media = false;
|
||||
pulse = true;
|
||||
x11 = true;
|
||||
notifications = false;
|
||||
extraChecks = {
|
||||
# ~/Gaming is the sandbox home and the only writable host path besides
|
||||
# per-app runtime state.
|
||||
sharedHome = lib.elem [
|
||||
"$HOME/Gaming"
|
||||
"$HOME"
|
||||
] rw;
|
||||
noXdgStorage = !app.xdgStorage.enable;
|
||||
writableScope = lib.all (
|
||||
entry:
|
||||
let
|
||||
path = source entry;
|
||||
in
|
||||
path == "$HOME/Gaming" || lib.hasPrefix "$XDG_RUNTIME_DIR/" path
|
||||
) rw;
|
||||
gamepadsOnly =
|
||||
lib.elem [
|
||||
"/dev/gamepad-input"
|
||||
"/dev/input"
|
||||
] app.bubblewrap.bind.dev
|
||||
&& !lib.elem "/dev/input" app.bubblewrap.bind.dev
|
||||
&& !lib.any (path: lib.hasPrefix "/dev/hidraw" path) (lib.flatten app.bubblewrap.bind.dev);
|
||||
driver32 = lib.elem "/run/opengl-driver-32" app.bubblewrap.bind.ro;
|
||||
protonPath = lib.hasPrefix builtins.storeDir app.bubblewrap.env.UMU_PROTONPATH;
|
||||
openUri = app.portalGroups.openUri;
|
||||
noFileChooser = !app.portalGroups.fileChooser;
|
||||
};
|
||||
}
|
||||
@@ -32,6 +32,7 @@ Nixpak wrappers are accepted in three layers:
|
||||
| MuseScore | Pass | Interactive launch pass; ALSA routed to default PipeWire sink |
|
||||
| mpv | Pass | Interactive validation pending |
|
||||
| imv | Pass | Interactive validation pending |
|
||||
| umu | Pass | Wuthering Waves and Star Rail from their desktop entries |
|
||||
|
||||
Intentional exceptions:
|
||||
|
||||
@@ -56,6 +57,30 @@ Known packaging limitations:
|
||||
Launch and audio routing pass, but audible playback and score/print/export workflows still need
|
||||
user confirmation.
|
||||
|
||||
## Game launcher checklist
|
||||
|
||||
For umu now, and Heroic and Steam when they move into the sandbox. Run each with the game installed
|
||||
through the `nix-config-umu-game` skill and launched from its desktop entry:
|
||||
|
||||
1. The launcher window opens and the nested pressure-vessel container starts (`ENABLE_LOG=1`, then
|
||||
check `last-run.log` for bwrap or `/.flatpak-info` errors).
|
||||
2. Frame rate matches an unsandboxed run of the same scene with the MangoHud overlay; the game uses
|
||||
the NVIDIA GPU, and a 32-bit game renders.
|
||||
3. Audio plays; a USB or Bluetooth controller connected before launch works, and a keyboard does not
|
||||
appear under `/dev/input` inside the sandbox.
|
||||
4. The launcher downloads at the usual speed through pasta, and online play connects.
|
||||
5. Inside `umu-sandbox bash`: `ls -A ~` shows the contents of the host's `~/Gaming` and nothing else
|
||||
from the host home.
|
||||
6. Running `~/Gaming/Games/<name>/run` from a host shell refuses to start.
|
||||
|
||||
umu on dwproton-11.0-15: Wuthering Waves and Star Rail both launch from their desktop entries, and a
|
||||
controller works. Wuthering Waves' `PROTON_LOG` shows pressure-vessel starting inside the sandbox,
|
||||
DXVK and vkd3d-proton on the RTX 4090, NVAPI, fsync and pasta networking, with nothing denied by the
|
||||
sandbox. Still to check: audio, a 32-bit game, the keyboard exclusion and item 5. Wuthering Waves'
|
||||
ACE anti-cheat driver crashes `winedevice.exe` on an unimplemented `ntoskrnl.exe` function, a Wine
|
||||
gap; the game runs regardless. The first launch after a Proton update or a moved umu runtime can
|
||||
show no window for a minute while steamrt4 is prepared and the prefix upgraded.
|
||||
|
||||
## Browser smoke test
|
||||
|
||||
Build a wrapper from the locked inputs:
|
||||
|
||||
@@ -23,24 +23,18 @@ in
|
||||
# Steam - add this as a launch option: `mangohud %command%` / `gamemoderun %command%`
|
||||
# ==========================================================================
|
||||
|
||||
# The nix-config-umu-game skill and the launchers it generates rely on
|
||||
# umu-launcher, mangohud, winetricks and bbe from this list.
|
||||
|
||||
home.packages =
|
||||
(with pkgs; [
|
||||
# https://github.com/flightlessmango/MangoHud
|
||||
# a simple overlay program for monitoring FPS, temperature, CPU and GPU load, and more.
|
||||
mangohud
|
||||
|
||||
# Script to install various redistributable runtime libraries in Wine.
|
||||
winetricks
|
||||
# https://github.com/Open-Wine-Components/umu-launcher
|
||||
# a unified launcher for Windows games on Linux
|
||||
umu-launcher
|
||||
|
||||
# Sed-like editor for binary files
|
||||
# required by some games to fix problems
|
||||
bbe
|
||||
# a unified launcher for Windows games on Linux, sandboxed with
|
||||
# ~/Gaming as its home and only writable host directory. umu-run, winetricks,
|
||||
# bbe and mangohud live inside it; the nix-config-umu-game skill and
|
||||
# the launchers it generates run through `umu-sandbox`.
|
||||
nixpaks.umu
|
||||
])
|
||||
++ (with pkgs; [
|
||||
# Heroic Games Launcher - primarily for Epic Games & GOG
|
||||
|
||||
@@ -299,7 +299,9 @@ in
|
||||
# Games / Media
|
||||
# ======================================
|
||||
|
||||
"Games"
|
||||
# Home of the game sandboxes (hardening/nixpaks/modules/gaming-policy.nix);
|
||||
# installed games are in ~/Gaming/Games.
|
||||
"Gaming"
|
||||
# .desktop entries created by games/installers (including the umu
|
||||
# launchers the nix-config-umu-game skill generates). Home Manager
|
||||
# rewrites its mimeapps.list symlink into the bind mount at activation,
|
||||
|
||||
@@ -56,7 +56,8 @@ in
|
||||
# Expose DW-Proton to umu-run: umu ignores Steam's
|
||||
# STEAM_EXTRA_COMPAT_TOOLS_PATHS, and a store path baked into a generated
|
||||
# launcher goes stale on every dwproton-bin update. The nix-config-umu-game
|
||||
# skill and the run/exec scripts it generates read this first.
|
||||
# installer reads this on the host; the umu sandbox
|
||||
# (hardening/nixpaks/umu.nix) sets the same path for the run/exec scripts.
|
||||
environment.variables.UMU_PROTONPATH = "${pkgs.dwproton-bin.steamcompattool}";
|
||||
|
||||
# GameScope: run a game in its own nested compositor and let it renice
|
||||
|
||||
Reference in new issue
Block a user