Files
nix-config/.agents/skills/nix-config-umu-game/scripts/umu-install.nu
T
ryan4yin | 二花 b726c28c14 feat(nixpak): sandbox Steam and Heroic in ~/Gaming (#520)
* feat(nixpak): sandbox Steam and Heroic in ~/Gaming

idols-ai stops preserving ~/.local/share/Steam, ~/.steam, ~/.config/heroic
and their game data, which must be moved into ~/Gaming before deploying.

Assisted-by: DeepSeek Harness (deepseek-flash)

* refactor(nixpak): replace steam-fix.nu with sandboxed protontricks

Assisted-by: DeepSeek Harness (claude-opus-5-5)

* fix(nixpak): hide host documents and GTK config from game sandboxes

Assisted-by: DeepSeek Harness (claude-opus-5-5)

* docs(ai): locate the Steam library under ~/Gaming

Assisted-by: DeepSeek Harness (claude-opus-5-5)

* fix(umu-game): confine writes to ~/Gaming and keep foreign entries

Assisted-by: DeepSeek Harness (claude-opus-5-5)

* fix(nixpak): keep Steam and Heroic in the application launcher

The wrappers only carried bin/, so neither launcher had a desktop entry
after replacing programs.steam.

Assisted-by: DeepSeek Harness (deepseek-flash)

* fix(nixpak): give the game sandboxes a fontconfig that sees CJK fonts

Steam rendered Chinese as tofu: the FHS layers build their own /etc/fonts,
whose config lists only DejaVu and the XDG font directory, so neither the
host fonts nor the FHS /usr/share/fonts were ever scanned. Point
FONTCONFIG_FILE at a small config that includes /usr/share/fonts, and ship
WenQuanYi Zen Hei and Noto Sans CJK in all three FHS layers.

Assisted-by: DeepSeek Harness (deepseek-flash)

* fix(nixpak): grant the game launchers a StatusNotifier tray icon

Steam and Heroic register a tray icon over D-Bus, and the game sandboxes
granted no StatusNotifierWatcher access, so the desktop shell had nothing
to show for them. Enable desktopIntegration.tray for all three launchers
and teach the policy suites to expect it.

Assisted-by: DeepSeek Harness (deepseek-flash)

* fix(nixpak): drop the duplicate host CJK font and tidy the font overrides

Steam reads the fonts inside its own FHS layer, so the system-wide wqy_zenhei
added for it no longer serves any sandbox. Give gaming-policy its own comment
for FONTCONFIG_FILE instead of leaving it under the compat-tool one, make the
desktop-base value a default, and note the SNI tray grant in the README table.

Assisted-by: DeepSeek Harness (deepseek-flash)
2026-11-06 02:18:33 -06:00

226 lines
8.8 KiB
Nu

#!/usr/bin/env nu
#
# Install a Windows game launcher into its own umu-launcher Wine prefix, and
# 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 ~/Gaming/Games/<name>/ (~/Games/<name>/ inside the sandbox):
#
# prefix the Wine prefix (WINEPREFIX)
# prelaunch optional executable, run before every launch
# conf optional shell file overriding the generated defaults
# run generated launcher
# exec generated helper: run any exe/wine tool in the prefix
# <name>.desktop generated desktop entry
#
# 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. Even the files this script writes under ~/Gaming are
# written through `umu-sandbox`; only the host desktop entry is written here.
# umu-uninstall.nu removes a game.
#
# ~/Gaming/Games/global.conf, if present, is sourced by every run script before
# 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 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 \
# "drive_c/Program Files/Wuthering Waves/launcher.exe" \
# umu-3513350
#
# Extra arguments after [gameid] are passed to the installer, e.g. "/S" or
# "/quiet" for a silent install. A <setup.exe> of "-" skips the installer and
# only rewrites the launchers.
use common.nu *
# First candidate that actually contains a proton script.
def resolve-protonpath [] {
let explicit = ($env.PROTONPATH? | default "")
if (not ($explicit | is-empty)) and (($explicit | path join "proton") | path exists) {
return $explicit
}
let umu = ($env.UMU_PROTONPATH? | default "")
if (not ($umu | is-empty)) and (($umu | path join "proton") | path exists) {
return $umu
}
let steam = ((gaming-dir) | path join ".local/share/Steam/compatibilitytools.d/dwproton")
if (($steam | path join "proton") | path exists) {
return $steam
}
null
}
# "wuthering-waves" -> "Wuthering Waves"
def display-name [name: string] {
$name | split row "-" | each { |w| $w | str capitalize } | str join " "
}
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 ~/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 ~/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.
def shell-quote [value: string] {
"'" + ($value | str replace --all "'" "'\\''") + "'"
}
def render-template [name: string, values: record] {
let template = ($env.FILE_PWD | path join "templates" $name)
open --raw $template | str replace --all --regex '@([A-Z_]+)@' { |key|
$values | get $key
}
}
# Write a file under ~/Gaming from inside the sandbox: a running game can swap
# it for a symlink after reject-symlink, and in there that symlink can only
# lead back into ~/Gaming. The content is the input, or the host file --from,
# which is streamed instead of read into memory.
def save-in-sandbox [path: string, --from: string, --executable] {
let data = $in
let source = (if $from != null { $from | path expand })
let script = (if $executable { 'cat > "$1" && chmod +x "$1"' } else { 'cat > "$1"' })
let target = (sandbox-path $path)
cd $nu.home-dir
if $source != null { open --raw $source } else { $data } | ^umu-sandbox sh -c $script sh $target
}
def write-run [dir: string, launcher: string] {
let values = {
WINEPREFIX: (shell-quote $env.WINEPREFIX)
PROTONPATH: (shell-quote $env.PROTONPATH)
GAMEID: (shell-quote $env.GAMEID)
LAUNCHER: (shell-quote $launcher)
GAME_DIR: (shell-quote (sandbox-path $dir))
}
for file in [run exec] {
let target = ($dir | path join $file)
reject-symlink $target $file
render-template $"($file).sh.tpl" $values | save-in-sandbox --executable $target
}
}
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: (sandbox-path ($dir | path join "run"))
})
mkdir $apps
reject-symlink $copy "desktop entry copy"
$desktop | save-in-sandbox $copy
# Not game-writable, so the host writes it directly.
$desktop | save --force $entry
}
# Run a command in the umu sandbox with the game's environment. Start from the
# host home: inside, that path is the sandbox home, so the working directory
# exists on both sides.
def --wrapped 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] {
if ($args | length) < 3 {
usage
exit 1
}
let name = ($args | get 0)
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 { [] })
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
}
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
mkdir $dir
$env.WINEPREFIX = (sandbox-path $prefix)
$env.GAMEID = $gameid
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"
save-in-sandbox $target --from $setup
sandbox-path $target
})
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)
}
}
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") {
in-sandbox rm -f (sandbox-path $uninstall)
}
print $"done. launch with: umu-sandbox (sandbox-path ($dir | path join run))"
}