mirror of
https://github.com/ryan4yin/nix-config.git
synced 2026-11-13 12:13:36 +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)"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user