From 9e2633f4961c9495041f668e20c778c8d30a9284 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?ryan4yin=20=7C=20=E4=BA=8C=E8=8A=B1?= Date: Thu, 8 Oct 2026 14:59:11 +0800 Subject: [PATCH] feat(gaming): generic umu game installer, skill, and desktop entries (#411) --- .agents/skills/nix-config-umu-game/SKILL.md | 226 +++++++++++ .../scripts/templates/exec.sh.tpl | 15 + .../scripts/templates/launcher.desktop.tpl | 9 + .../scripts/templates/run.sh.tpl | 36 ++ .../scripts/templates/uninstall.sh.tpl | 26 ++ .../scripts/tests/test_install.py | 352 ++++++++++++++++++ .../scripts/umu-install.nu | 184 +++++++++ AGENTS.md | 3 + home/linux/gui/base/gaming.nix | 33 +- home/linux/gui/niri/conf/windowrules.kdl | 34 +- hosts/idols-ai/preservation.nix | 7 +- modules/nixos/desktop/gaming.nix | 8 +- .../nixos/desktop/networking/mihomo/README.md | 6 + .../desktop/networking/mihomo/policy.yaml | 1 + 14 files changed, 901 insertions(+), 39 deletions(-) create mode 100644 .agents/skills/nix-config-umu-game/SKILL.md create mode 100644 .agents/skills/nix-config-umu-game/scripts/templates/exec.sh.tpl create mode 100644 .agents/skills/nix-config-umu-game/scripts/templates/launcher.desktop.tpl create mode 100644 .agents/skills/nix-config-umu-game/scripts/templates/run.sh.tpl create mode 100644 .agents/skills/nix-config-umu-game/scripts/templates/uninstall.sh.tpl create mode 100644 .agents/skills/nix-config-umu-game/scripts/tests/test_install.py create mode 100644 .agents/skills/nix-config-umu-game/scripts/umu-install.nu diff --git a/.agents/skills/nix-config-umu-game/SKILL.md b/.agents/skills/nix-config-umu-game/SKILL.md new file mode 100644 index 00000000..66a38c74 --- /dev/null +++ b/.agents/skills/nix-config-umu-game/SKILL.md @@ -0,0 +1,226 @@ +--- +name: nix-config-umu-game +description: + Use when installing a Windows game launcher (二次元 / gacha or any non-Steam game) on a NixOS + desktop via umu-launcher, given an installer URL or an .exe, or when such a launcher opens an + invisible, transparent, black, or empty window under Wine. +--- + +# Installing a Windows game launcher with umu + +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. + +## Bundled files + +| Path | What | +| ------------------------------- | --------------------------------------------------- | +| `scripts/umu-install.nu` | installer and template renderer | +| `scripts/templates/*.tpl` | non-executable templates; placeholders use `@NAME@` | +| `scripts/tests/test_install.py` | offline regression tests | + +## Layout + +| Path | What | +| ------------------------------- | -------------------------------------------------------- | +| `~/Games/global.conf` | optional, sourced by every game before its own `conf` | +| `~/Games//prefix` | the Wine prefix (`WINEPREFIX`) | +| `~/Games//conf` | optional per-game overrides (`ENABLE_MANGOHUD=0`, ...) | +| `~/Games//prelaunch` | optional executable, run before every launch | +| `~/Games//run` | generated launcher | +| `~/Games//exec` | generated helper: `exec [args...]` | +| `~/Games//.desktop` | generated desktop entry | +| `~/Games//uninstall` | generated: drop the desktop entry and (with `y`) the dir | + +Run, from the repo root: + +`nu .agents/skills/nix-config-umu-game/scripts/umu-install.nu [gameid] [setup-args...]` + +`` 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. + +## Procedure + +1. **Research first.** Mandatory before inventing any fix: a documented fix beats a custom patch + every time. Check the game's Lutris installer YAML as data only + (`https://lutris.net/api/installers/` encodes `winetricks` verbs, `write_file` + content, and `prelaunch_command`; do not install or run Lutris), then ProtonDB + (`protondb.com/app/`), 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=""` 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. +2. **Slug.** Pick a lowercase `` (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-`; if nothing matches, leave the default. +4. **Download.** `curl -fL -o ~/Games//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`, + `--silent`); if it ignores them, fall back to clicking. On some CN installers the last page + 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 `` is only known after install, locate it under + `~/Games//prefix/drive_c` and set `LAUNCHER` in `~/Games//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//prelaunch` (bash, `chmod +x`): + `winetricks` verbs via `~/Games//exec winetricks `, file patches or registry tweaks + via `~/Games//exec`. 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//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. `ENABLE_LOG=1` captures `last-run.log` when + something misbehaves. + +## Invisible / transparent launcher window + +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//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/' `. +3. Put it in `~/Games//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 + +Niri tiles a new window into a column of `default-column-width { proportion 0.500000; }` (50%), and +a launcher that only opens a large borderless window cannot take over the screen by itself: with a +4K panel at `scale 1.5` Niri's logical space is 2560x1440, so a window asking for the physical +3840x2160 becomes an ordinary half-width tile. Nothing is broken -- the umu app-id is just not +covered by a rule. + +Every umu/Proton game reports `steam_app_`. In this repo +`home/linux/gui/niri/conf/windowrules.kdl` matches `app-id="^steam_app_[0-9]+$"` and sets +`open-fullscreen true`, so games open fullscreen without a per-game rule. `niri validate` checks the +live config, `Mod+Shift+F` (`fullscreen-window`) toggles an already-open window, and setting the +in-game resolution to the compositor's logical size (2560x1440 here) avoids the mismatch too. + +A launcher shares that app id with its game, so the rule also catches the launcher's own windows: a +helper that is still untitled while it maps, and the announcement popup (公告 / Announcement), which +on this launcher renders as a pure white rectangle. Fullscreening either one buries the UI that +opened it, so both are excluded. A window rule applies when a window opens, so an already-open +offender needs `Mod+Shift+F` on it or a relaunch; drop `open-fullscreen true` altogether if a +launcher keeps surprising you -- the game then costs one `Mod+Shift+F` per session instead. + +## Mojibake that is not a missing font + +Latin-looking garbage such as `我已阅读` where the launcher should say 我已阅读并同意, while +other Chinese on the same window renders fine, is UTF-8 decoded as Windows-1252. Confirm the +pattern: + +```bash +python3 -c "print('我已阅读并同意'.encode('utf-8').decode('cp1252','replace'))" +``` + +If that prints exactly what the launcher shows, the bug is identified and you can stop here. + +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//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 +for the prefix locale, not `LC_ALL`: Proton clears `LC_ALL` unless `HOST_LC_ALL` is set. + +So: check the bytes first, do not install more CJK fonts, and do not promise a locale fix. + +## Silent install + +`umu-install.nu /S` passes `/S` to the installer. Not all +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/.desktop` (and a copy at +`~/Games//.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 +`preservation.preserveAt` list; in this repo `~/Games` and `.local/share/umu` already are, and +`hosts/idols-ai/preservation.nix` now persists `.local/share/applications` too. + +## Uninstall + +Run `~/Games//uninstall`: it removes the desktop entry and then asks before deleting +`~/Games/` (prefix + game files). To keep the game and only drop the menu entry, delete the +`.desktop` file by hand. + +## Optimize the launch + +`~/Games//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"`, +`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//run`. Shader caches are kept in `~/Games//shader-cache`. + +## Escape hatches + +- `~/Games//exec [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 `. 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//run ` passes extra args to the launcher. +- Kill a stuck prefix with `pkill -f '/Games//prefix'`, or + `rm -f ~/Games//prefix/pfx.lock`. + +## Common mistakes + +- Committing a game name, URL, or patch to this repo instead of `~/Games//`. +- 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). +- Expecting umu to fetch DW-Proton: it only auto-manages GE-Proton / UMU-Proton; use Nix or Steam + installed paths. Ask the user before obtaining a required release from elsewhere. +- 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//exec winecfg`; `exec` goes through `umu-run`, which provides that container. +- Waiting for the game download before calling the install done: the launcher's own download is out + of scope here. +- On NixOS a game needing 32-bit or Vulkan needs `hardware.graphics.enable32Bit`, and Steam is a + module rather than a package -- see the `nix-config-desktop` skill. + +## Prior art + +`ESKAP3/umu-skeleton` (filesystem-as-game-manager, global.conf, tools), `gizmo-ds/endfield-run` +(`-exec` installer), `Septa-Serpenta-Seraph/Vesper` (umu + GAMEID notes), `joshsymonds/nix-config` +(research-first debugging), `olafkfreund/nixarchy` (NixOS gaming skill). + +## Regression tests + +From the repo root: + +```bash +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. diff --git a/.agents/skills/nix-config-umu-game/scripts/templates/exec.sh.tpl b/.agents/skills/nix-config-umu-game/scripts/templates/exec.sh.tpl new file mode 100644 index 00000000..4e810f83 --- /dev/null +++ b/.agents/skills/nix-config-umu-game/scripts/templates/exec.sh.tpl @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Generated by the nix-config-umu-game skill -- run a tool in this prefix. +# exec [args...] e.g. exec winecfg / exec explorer +set -euo pipefail +export WINEPREFIX=@WINEPREFIX@ +DEFAULT_PROTONPATH=@PROTONPATH@ +export PROTONPATH="${PROTONPATH:-${UMU_PROTONPATH:-$DEFAULT_PROTONPATH}}" +export GAMEID=@GAMEID@ +# Bare wine tools are not executables on disk; run them through Proton wine. +case "${1:-}" in + winecfg|wineboot|explorer|regedit|control|uninstaller|taskmgr) + exec umu-run "$PROTONPATH/files/bin/wine" "$@" ;; + *) + exec umu-run "$@" ;; +esac diff --git a/.agents/skills/nix-config-umu-game/scripts/templates/launcher.desktop.tpl b/.agents/skills/nix-config-umu-game/scripts/templates/launcher.desktop.tpl new file mode 100644 index 00000000..9393ac01 --- /dev/null +++ b/.agents/skills/nix-config-umu-game/scripts/templates/launcher.desktop.tpl @@ -0,0 +1,9 @@ +[Desktop Entry] +Type=Application +Name=@DISPLAY_NAME@ +Comment=Windows game launcher installed via umu +Exec=@RUN@ +Path=@DIR@ +Terminal=false +Categories=Game; +StartupNotify=false diff --git a/.agents/skills/nix-config-umu-game/scripts/templates/run.sh.tpl b/.agents/skills/nix-config-umu-game/scripts/templates/run.sh.tpl new file mode 100644 index 00000000..4e1ae9d4 --- /dev/null +++ b/.agents/skills/nix-config-umu-game/scripts/templates/run.sh.tpl @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Generated by the nix-config-umu-game skill -- re-run its script to regenerate. +set -euo pipefail +HERE="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)" +# Defaults; ~/Games/global.conf and this game conf/ override them. +WINEPREFIX=@WINEPREFIX@ +DEFAULT_PROTONPATH=@PROTONPATH@ +PROTONPATH="${PROTONPATH:-${UMU_PROTONPATH:-$DEFAULT_PROTONPATH}}" +GAMEID=@GAMEID@ +LAUNCHER=@LAUNCHER@ +ENABLE_GAMEMODE=0 +ENABLE_GAMESCOPE=0 +GAMESCOPE_ARGS="-f" +ENABLE_LOG=0 +ENABLE_MANGOHUD=1 +MANGOHUD_CONFIG="fps=1,frametime=1,position=top-left" + +# shellcheck source=/dev/null +if [ -f "$HERE/../global.conf" ]; then . "$HERE/../global.conf"; fi + +# shellcheck source=/dev/null +if [ -f "$HERE/conf" ]; then . "$HERE/conf"; fi +export WINEPREFIX PROTONPATH GAMEID MANGOHUD_CONFIG +export VKD3D_SHADER_CACHE_PATH="$HERE/shader-cache" +mkdir -p "$VKD3D_SHADER_CACHE_PATH" +PRE="" +if [ "$ENABLE_GAMEMODE" = "1" ] && command -v gamemoderun >/dev/null 2>&1; then PRE="gamemoderun"; fi +if [ "$ENABLE_GAMESCOPE" = "1" ] && command -v gamescope >/dev/null 2>&1; then PRE="$PRE gamescope $GAMESCOPE_ARGS --"; fi +if [ "$ENABLE_MANGOHUD" = "1" ] && command -v mangohud >/dev/null 2>&1; then PRE="$PRE mangohud"; fi +if [ -x "$HERE/prelaunch" ]; then "$HERE/prelaunch"; fi +if [ "$ENABLE_LOG" = "1" ]; then + set -o pipefail + $PRE umu-run "$WINEPREFIX/$LAUNCHER" "$@" 2>&1 | tee "$HERE/last-run.log" +else + exec $PRE umu-run "$WINEPREFIX/$LAUNCHER" "$@" +fi diff --git a/.agents/skills/nix-config-umu-game/scripts/templates/uninstall.sh.tpl b/.agents/skills/nix-config-umu-game/scripts/templates/uninstall.sh.tpl new file mode 100644 index 00000000..5e8e9ef8 --- /dev/null +++ b/.agents/skills/nix-config-umu-game/scripts/templates/uninstall.sh.tpl @@ -0,0 +1,26 @@ +#!/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 diff --git a/.agents/skills/nix-config-umu-game/scripts/tests/test_install.py b/.agents/skills/nix-config-umu-game/scripts/tests/test_install.py new file mode 100644 index 00000000..668172d3 --- /dev/null +++ b/.agents/skills/nix-config-umu-game/scripts/tests/test_install.py @@ -0,0 +1,352 @@ +"""Offline regression tests; needs nu, bash and shellcheck, but no Wine.""" + +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + +SCRIPTS = Path(__file__).resolve().parents[1] + + +class InstallerTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="umu-test-") + self.addCleanup(self.temp.cleanup) + root = Path(self.temp.name) + home = root / "home" + home.mkdir() + self.home = home + bin_dir = root / "bin" + bin_dir.mkdir() + self.bin_dir = bin_dir + self.proton = root / "proton" + (self.proton / "files/bin").mkdir(parents=True) + (self.proton / "proton").touch() + self.log = root / "call" + stub = bin_dir / "umu-run" + stub.write_text("""#!/usr/bin/env bash +mkdir -p "$WINEPREFIX" +printf "%s\n" "$WINEPREFIX" "$PROTONPATH" "$GAMEID" "$@" > "$CALL_LOG" +exit "${STUB_EXIT:-0}" +""") + stub.chmod(0o755) + self.mangohud_log = root / "mangohud-call" + hud = bin_dir / "mangohud" + hud.write_text("""#!/usr/bin/env bash +printf "%s\n" "${MANGOHUD_CONFIG-unset}" "$@" > "$MANGOHUD_LOG" +exec "$@" +""") + hud.chmod(0o755) + self.env = dict( + os.environ, + HOME=str(home), + PROTONPATH=str(self.proton), + CALL_LOG=str(self.log), + MANGOHUD_LOG=str(self.mangohud_log), + PATH=str(bin_dir) + os.pathsep + os.environ["PATH"], + ) + for key in ("XDG_DATA_HOME", "BASH_ENV", "ENV"): + self.env.pop(key, None) + self.game = home / "Games/test-game" + self.launcher = "drive_c/Program Files/Test/launcher.exe" + self.gameid = "umu-test" + + def invoke_install(self, name, **extra): + return subprocess.run( + [ + "nu", + str(SCRIPTS / "umu-install.nu"), + name, + "setup.exe", + self.launcher, + self.gameid, + "/S", + ], + env=self.env | extra, + cwd=self.home, + capture_output=True, + text=True, + check=False, + ) + + def install(self, **extra): + result = self.invoke_install("test-game", **extra) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def call(self, name, *args, input=None): + result = subprocess.run( + [str(self.game / name), *args], + env=self.env, + input=input, + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + return self.log.read_text().splitlines() + + def test_name_must_be_one_slug_component_before_any_install_work(self): + games = self.home / "Games" + outside = self.home / "escaped" + invalid_names = [str(outside), "../escaped", "nested/game", ".."] + + for name in invalid_names: + with self.subTest(name=name): + result = self.invoke_install(name) + self.assertNotEqual(result.returncode, 0) + self.assertIn("lowercase slug", result.stderr) + self.assertFalse(self.log.exists()) + 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) + + 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()), []) + + def test_uninstall_refuses_games_root_replaced_by_symlink(self): + self.install() + games = self.home / "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, + ) + + 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() + outside = self.home / "outside" + outside.mkdir() + (games / "test-game").symlink_to(outside, target_is_directory=True) + + 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()), []) + + def test_install_rejects_symlinked_prefix(self): + game = self.home / "Games/test-game" + game.mkdir(parents=True) + outside = self.home / "outside" + outside.mkdir() + (game / "prefix").symlink_to(outside, target_is_directory=True) + + 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()), []) + + def test_install_rejects_dangling_symlink_prefix(self): + game = self.home / "Games/test-game" + game.mkdir(parents=True) + outside = self.home / "missing-prefix-target" + (game / "prefix").symlink_to(outside, target_is_directory=True) + + result = self.invoke_install("test-game") + + self.assertNotEqual(result.returncode, 0) + self.assertIn("symlink", result.stderr) + self.assertFalse(self.log.exists()) + self.assertFalse(outside.exists()) + + def test_uninstall_refuses_directory_moved_outside_games(self): + self.install() + outside = self.home / "outside" + 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, + ) + + self.assertNotEqual(result.returncode, 0) + self.assertTrue(outside.exists()) + self.assertTrue((outside / "run").exists()) + + def test_templates_are_separate_and_non_executable(self): + for name in ( + "run.sh.tpl", + "exec.sh.tpl", + "uninstall.sh.tpl", + "launcher.desktop.tpl", + ): + path = SCRIPTS / "templates" / name + self.assertTrue(path.is_file(), str(path)) + self.assertFalse(os.access(path, os.X_OK), str(path)) + + def test_run_enables_minimal_top_left_mangohud_by_default(self): + self.install() + + self.call("run") + + 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)] + ) + + def test_run_can_disable_mangohud_per_game(self): + self.install() + (self.game / "conf").write_text("ENABLE_MANGOHUD=0\n") + + values = self.call("run") + + self.assertFalse(self.mangohud_log.exists()) + self.assertEqual(values[3:], [str(self.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"): + 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.assertEqual( + desktop.read_bytes(), (self.game / "test-game.desktop").read_bytes() + ) + + def test_nonzero_installer_still_generates_launchers(self): + self.install(STUB_EXIT="7") + self.assertTrue((self.game / "run").exists()) + + def test_config_prelaunch_and_launch_arguments(self): + self.install() + (self.game.parent / "global.conf").write_text('GAMEID="global"\n') + (self.game / "conf").write_text('GAMEID="local"\nENABLE_LOG=1\n') + prelaunch = self.game / "prelaunch" + prelaunch.write_text('#!/usr/bin/env bash\ntouch "$WINEPREFIX/../patched"\n') + prelaunch.chmod(0o755) + self.install() + values = self.call("run", "--game-arg") + self.assertEqual(values[2], "local") + self.assertEqual( + values[3:], [str(self.game / "prefix" / self.launcher), "--game-arg"] + ) + self.assertTrue((self.game / "patched").exists()) + self.assertTrue((self.game / "last-run.log").exists()) + + def test_launcher_path_is_quoted_as_data(self): + self.launcher = "drive_c/quote'\"$HOME/launcher.exe" + self.install() + values = self.call("run") + self.assertEqual(values[3:], [str(self.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" + 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)) + + def test_game_conf_pins_installed_proton_over_system_default(self): + self.install() + selected = self.home / "steam-dwproton" + system_default = self.home / "system-dwproton" + (selected / "files/bin").mkdir(parents=True) + (selected / "proton").touch() + (system_default / "files/bin").mkdir(parents=True) + (system_default / "proton").touch() + (self.game / "conf").write_text(f"PROTONPATH='{selected}'\n") + self.env.pop("PROTONPATH") + self.env["UMU_PROTONPATH"] = str(system_default) + + self.assertEqual(self.call("run")[1], str(selected)) + + def test_runtime_proton_override(self): + self.install() + self.env["PROTONPATH"] = str(self.home / "override") + self.assertEqual(self.call("run")[1], self.env["PROTONPATH"]) + self.assertEqual( + self.call("exec", "winecfg")[3], str(self.home / "override/files/bin/wine") + ) + + def test_gamemode_and_gamescope_wrappers(self): + self.install() + gamemode_log = self.home / "gamemode.log" + gamescope_log = self.home / "gamescope.log" + self.env.update(WRAPPER_LOG=str(gamemode_log), GAMESCOPE_LOG=str(gamescope_log)) + wrappers = { + "gamemoderun": '#!/usr/bin/env bash\nprintf "called\\n" > "$WRAPPER_LOG"\nexec "$@"\n', + "gamescope": '#!/usr/bin/env bash\nprintf "%s\\n" "$@" > "$GAMESCOPE_LOG"\nwhile [ "$1" != -- ]; do shift; done\nshift\nexec "$@"\n', + } + for name, script in wrappers.items(): + path = self.bin_dir / name + path.write_text(script) + path.chmod(0o755) + (self.game / "conf").write_text( + 'ENABLE_GAMEMODE=1\nENABLE_GAMESCOPE=1\nGAMESCOPE_ARGS="-f -w 1920"\n' + ) + self.call("run") + self.assertEqual(gamemode_log.read_text(), "called\n") + self.assertEqual( + gamescope_log.read_text().splitlines()[:6], + ["-f", "-w", "1920", "--", "mangohud", "umu-run"], + ) + + def test_exec_routes_wine_tools(self): + self.install() + self.assertEqual( + self.call("exec", "winecfg")[3:], + [str(self.proton / "files/bin/wine"), "winecfg"], + ) + self.assertEqual( + self.call("exec", "/tmp/tool.exe", "argument")[3:], + ["/tmp/tool.exe", "argument"], + ) + + def test_uninstall_confirmation(self): + self.install() + desktop = self.home / ".local/share/applications/test-game.desktop" + self.call("uninstall", input="n\n") + 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, + ) + self.assertFalse(self.game.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.agents/skills/nix-config-umu-game/scripts/umu-install.nu b/.agents/skills/nix-config-umu-game/scripts/umu-install.nu new file mode 100644 index 00000000..fb7c3e1d --- /dev/null +++ b/.agents/skills/nix-config-umu-game/scripts/umu-install.nu @@ -0,0 +1,184 @@ +#!/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. +# +# 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//: +# +# 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 +# .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, +# 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. +# +# 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. + +def games-dir [] { + $nu.home-dir | path join "Games" +} + +# 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 = ($nu.home-dir | path join ".local/share/Steam/compatibilitytools.d/dwproton") + if (($steam | path join "proton") | path exists) { + return $steam + } + null +} + +# "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 " " +} + +def usage [] { + print "Usage: .agents/skills/nix-config-umu-game/scripts/umu-install.nu [gameid] [setup-args...]" + print "" + print " directory under ~/Games (e.g. wuthering-waves)" + print " installer to run through umu (a host path)" + print " 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//{run,exec,.desktop,uninstall}. A per-game" + print "fix goes in ~/Games//prelaunch; overrides go in ~/Games//conf" + print "or ~/Games/global.conf. PROTONPATH comes from $PROTONPATH or" + print "$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 + } +} + +def write-run [name: string, launcher: string] { + let dir = ((games-dir) | path join $name) + 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) + render-template $"($file).sh.tpl" $values | save --force $target + ^chmod +x $target + } +} + +def write-integration [name: string] { + let dir = ((games-dir) | path join $name) + let apps = ($nu.home-dir | path join ".local/share/applications") + let entry = ($apps | path join $"($name).desktop") + let desktop = (render-template "launcher.desktop.tpl" { + DISPLAY_NAME: (display-name $name) + RUN: ($dir | path join "run") + DIR: $dir + }) + mkdir $apps + $desktop | save --force ($dir | path join $"($name).desktop") + $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 +} + +def main [...args: string] { + if ($args | length) < 3 { + usage + 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 + } + 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) + let prefix = ($dir | path join "prefix") + reject-symlink $dir "game directory" + reject-symlink $prefix "Wine prefix" + + 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 + + $env.WINEPREFIX = $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)." + + let inst = (^umu-run $setup ...$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: ($prelaunch)" + run-external $prelaunch + } + + write-run $name $launcher + write-integration $name + print $"done. launch with: ($dir)/run" +} diff --git a/AGENTS.md b/AGENTS.md index 4d12340b..d9a65adb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -109,6 +109,9 @@ is discovered by OpenCode and compatible tools. wiring a new host into `hosts/`, `outputs/`, networking, secrets, and the eval tests. - [`.agents/skills/nix-config-debug/SKILL.md`](./.agents/skills/nix-config-debug/SKILL.md) — mapping a broken eval, build, activation, unit, host, or MicroVM guest to the command that localizes it. +- [`.agents/skills/nix-config-umu-game/SKILL.md`](./.agents/skills/nix-config-umu-game/SKILL.md) — + installing a Windows game launcher via the umu installer it bundles; per-game fixes stay under + `~/Games/`. - [`.agents/skills/nixpkgs-review/SKILL.md`](./.agents/skills/nixpkgs-review/SKILL.md) — reviewing nixpkgs PRs locally first, with GHA for cross-architecture or larger reviews. - [`.agents/skills/nixpkgs-patched/SKILL.md`](./.agents/skills/nixpkgs-patched/SKILL.md) — carrying diff --git a/home/linux/gui/base/gaming.nix b/home/linux/gui/base/gaming.nix index 1f04110b..291a9858 100644 --- a/home/linux/gui/base/gaming.nix +++ b/home/linux/gui/base/gaming.nix @@ -1,6 +1,5 @@ { pkgs, - osConfig, config, lib, ... @@ -12,7 +11,7 @@ in { options.modules.desktop = { gaming = { - enable = mkEnableOption "Install Game Suite(steam, lutris, etc)"; + enable = mkEnableOption "Enable desktop gaming support"; }; }; @@ -20,19 +19,19 @@ in # ========================================================================== # Other Optimizations # Usage: - # Lutris - enable advanced options, go to the System options -> Command prefix, add: `mangohud` - # Steam - add this as a launch option: `mangohud %command%` / `gamemoderun %command%` + # UMU game launchers use MangoHud by default; set ENABLE_MANGOHUD=0 in their conf to disable. + # Steam - add this as a launch option: `mangohud %command%` / `gamemoderun %command%` # ========================================================================== + # The nix-config-umu-game skill and the launchers it generates rely on + # umu-launcher, mangohud, winetricks and bbe from this list. + home.packages = (with pkgs; [ # https://github.com/flightlessmango/MangoHud # a simple overlay program for monitoring FPS, temperature, CPU and GPU load, and more. mangohud - # GUI for installing custom Proton versions like GE_Proton - # proton - a Wine distribution aimed at gaming - protonplus # Script to install various redistributable runtime libraries in Wine. winetricks # https://github.com/Open-Wine-Components/umu-launcher @@ -53,25 +52,5 @@ in }) ]); - # Game launchers for Epic/GOG/Ubisoft/etc. (use Steam + DWProton for common games) - - # a GUI game launcher for Steam/GoG/Epic/Ubisoft - # https://lutris.net/games?ordering=-popularity - programs.lutris = { - enable = true; - defaultWinePackage = pkgs.proton-ge-bin; - steamPackage = osConfig.programs.steam.package; - protonPackages = [ pkgs.proton-ge-bin ]; - winePackages = with pkgs; [ - wineWow64Packages.full - ]; - extraPackages = with pkgs; [ - winetricks - gamescope - gamemode - mangohud - umu-launcher - ]; - }; }; } diff --git a/home/linux/gui/niri/conf/windowrules.kdl b/home/linux/gui/niri/conf/windowrules.kdl index b214f2a6..7a7e3b62 100644 --- a/home/linux/gui/niri/conf/windowrules.kdl +++ b/home/linux/gui/niri/conf/windowrules.kdl @@ -56,25 +56,41 @@ window-rule { // Entertainment applications - open in workspace 4 window-rule { - match app-id="steam" + // niri matches app-id anywhere in the string, so a bare "steam" would also + // catch the `steam_app_` game windows below and hand them + // `open-maximized`, which fights their fullscreen rule. Anchor it so this + // only affects the Steam client window itself. + match app-id="^steam$" open-on-workspace "4entertainment" + open-maximized true } window-rule { match app-id="steam_app_default" open-on-workspace "4entertainment" } +window-rule { + // Games launched through umu/Proton -- including the launchers the + // nix-config-umu-game skill generates -- report `steam_app_`. + // Without a rule Niri tiles them at the default 50% column width, and a + // DXGI "fullscreen" window larger than the output (a 4K panel here runs at + // scale 1.5, so Niri's logical space is 2560x1440) cannot take over the + // screen by itself. + // + // The launcher shares this app id with the game, so the rule also catches + // its own windows: a helper that is still untitled while it maps, and the + // announcement popup (公告), which on this launcher renders as a pure white + // rectangle. Fullscreening either one buries the UI that opened it, so + // both are excluded here. + match app-id="^steam_app_[0-9]+$" + exclude title="^$" + exclude title="^(公告|Announcement|Notice)$" + open-on-workspace "4entertainment" + open-fullscreen true +} window-rule { match app-id="heroic" open-on-workspace "4entertainment" } -window-rule { - match app-id="net.lutris.Lutris" - open-on-workspace "4entertainment" -} -window-rule { - match app-id="com.vysp3r.ProtonPlus" - open-on-workspace "4entertainment" -} window-rule { // Run anime games on Linux match app-id="^moe.launcher" diff --git a/hosts/idols-ai/preservation.nix b/hosts/idols-ai/preservation.nix index 383c65b8..03cbc8fc 100644 --- a/hosts/idols-ai/preservation.nix +++ b/hosts/idols-ai/preservation.nix @@ -264,6 +264,11 @@ in # ====================================== "Games" + # .desktop entries created by games/installers (including the umu + # launchers the nix-config-umu-game skill generates). Home Manager + # rewrites its mimeapps.list symlink into the bind mount at activation, + # so that survives too. + ".local/share/applications" ".steam" ".config/MangoHud" ".config/blender" @@ -273,8 +278,6 @@ in ".local/share/Steam" ".local/state/Heroic" ".config/heroic" - ".config/lutris" - ".local/share/lutris" ".local/share/GOG.com" ".local/share/StardewValley" diff --git a/modules/nixos/desktop/gaming.nix b/modules/nixos/desktop/gaming.nix index 317f110f..d6027f09 100644 --- a/modules/nixos/desktop/gaming.nix +++ b/modules/nixos/desktop/gaming.nix @@ -17,7 +17,7 @@ in options.modules.desktop = { gaming = { - enable = mkEnableOption "Install Game Suite(steam, lutris, etc)"; + enable = mkEnableOption "Enable desktop gaming support"; }; }; @@ -59,6 +59,12 @@ in ]; }; + # Expose DW-Proton to umu-run: umu ignores Steam's + # STEAM_EXTRA_COMPAT_TOOLS_PATHS, and a store path baked into a generated + # launcher goes stale on every dwproton-bin update. The nix-config-umu-game + # skill and the run/exec scripts it generates read this first. + environment.variables.UMU_PROTONPATH = "${pkgs.dwproton-bin.steamcompattool}"; + # GameScope: run a game in its own nested compositor and let it renice # itself for steadier frame pacing. GPU-specific PRIME render offload belongs # to the host, not here (see programs.gamescope.env in diff --git a/modules/nixos/desktop/networking/mihomo/README.md b/modules/nixos/desktop/networking/mihomo/README.md index 281a91f2..00f4e1fc 100644 --- a/modules/nixos/desktop/networking/mihomo/README.md +++ b/modules/nixos/desktop/networking/mihomo/README.md @@ -51,6 +51,12 @@ targets instead of writing a config the core rejects. the IP rules are not decoration, Steam dials cached CM addresses without a lookup. Not `GEOSITE,steam` -- that category includes the community. Sources and how to verify: "Steam download region" below. +- CDN selection depends on launcher probes, not the patch download route. On this host, + `launcher-webstatic.hoyoverse.com` went through a US proxy node while `autopatchcn.bhsr.com` was + DIRECT; the download rose from ~0.5 to 102 MB/s after the proxy group was switched to DIRECT. + `policy.yaml` bypasses proxy only for that observed probe, not game CDNs or broad vendor domains. + Verify future probes in `/connections` and add only confirmed hosts. Reference: + [HoyoPlay API endpoints](https://gist.github.com/DynamiByte/0ad250bbe1930e2736a6d4e6e842bcb2). - Rule payloads are bare domains: `DOMAIN-SUFFIX,https://qlogo.cn` is invalid. - On a systemd-resolved host, sing-tun normally points resolved at the TUN DNS with `resolvectl`. The nixpkgs `DynamicUser` sandbox cannot make those privileged D-Bus changes, so resolved's own diff --git a/modules/nixos/desktop/networking/mihomo/policy.yaml b/modules/nixos/desktop/networking/mihomo/policy.yaml index dbd26ff0..bedc0b98 100644 --- a/modules/nixos/desktop/networking/mihomo/policy.yaml +++ b/modules/nixos/desktop/networking/mihomo/policy.yaml @@ -102,6 +102,7 @@ rules: - IP-CIDR,205.196.6.0/24,DIRECT,no-resolve - IP-CIDR,208.64.200.0/22,DIRECT,no-resolve - IP-CIDR,208.78.164.0/22,DIRECT,no-resolve + - DOMAIN,launcher-webstatic.hoyoverse.com,DIRECT # CN consumer services: proxied they fail in ways that look like app bugs -- # WeChat pictures spin, JD and Taobao product images go blank. Much of it is