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:
ryan4yin | 二花 authored and GitHub committed 2026-11-05 22:39:48 -06:00
1 parent 0704ba5e21
commit 08d2ff7578
21 files changed
+802 -251

No files matched your search

+130 -65
View File
@@ -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)"
}
}