From f780daecadc163348ee7d3e8c00aff766fffa3d2 Mon Sep 17 00:00:00 2001 From: jschantli Date: Mon, 21 Sep 2026 19:01:19 -0700 Subject: [PATCH] Initial commit with nexus updates and streamlined UI --- .gitignore | 4 + .idea/.gitignore | 10 + .idea/inspectionProfiles/Project_Default.xml | 12 + .idea/misc.xml | 6 + .idea/modules.xml | 8 + MHW-Mod-Manager.iml | 8 + README.md | 48 ++ bin/mhwmm | 9 + data/ca.schantli.MHWildsModManager.desktop | 10 + plan.md | 229 +++++++ pyproject.toml | 16 + src/mhwmm/__init__.py | 2 + src/mhwmm/__main__.py | 17 + src/mhwmm/core/__init__.py | 0 src/mhwmm/core/archive.py | 144 ++++ src/mhwmm/core/config.py | 30 + src/mhwmm/core/deploy.py | 485 +++++++++++++ src/mhwmm/core/desktop.py | 44 ++ src/mhwmm/core/layout.py | 110 +++ src/mhwmm/core/library.py | 163 +++++ src/mhwmm/core/loader.py | 136 ++++ src/mhwmm/core/manager.py | 269 ++++++++ src/mhwmm/core/modinfo.py | 83 +++ src/mhwmm/core/nexus.py | 213 ++++++ src/mhwmm/core/paks.py | 217 ++++++ src/mhwmm/core/paths.py | 65 ++ src/mhwmm/core/profiles.py | 103 +++ src/mhwmm/core/steam.py | 234 +++++++ src/mhwmm/core/util.py | 69 ++ src/mhwmm/ui/__init__.py | 0 src/mhwmm/ui/app.py | 47 ++ src/mhwmm/ui/details.py | 207 ++++++ src/mhwmm/ui/dialogs.py | 294 ++++++++ src/mhwmm/ui/downloads.py | 71 ++ src/mhwmm/ui/tasks.py | 19 + src/mhwmm/ui/window.py | 673 +++++++++++++++++++ tests/__init__.py | 0 tests/helpers.py | 70 ++ tests/test_deploy.py | 211 ++++++ tests/test_integrations.py | 165 +++++ tests/test_library.py | 167 +++++ 41 files changed, 4668 insertions(+) create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 MHW-Mod-Manager.iml create mode 100644 README.md create mode 100755 bin/mhwmm create mode 100644 data/ca.schantli.MHWildsModManager.desktop create mode 100644 plan.md create mode 100644 pyproject.toml create mode 100644 src/mhwmm/__init__.py create mode 100644 src/mhwmm/__main__.py create mode 100644 src/mhwmm/core/__init__.py create mode 100644 src/mhwmm/core/archive.py create mode 100644 src/mhwmm/core/config.py create mode 100644 src/mhwmm/core/deploy.py create mode 100644 src/mhwmm/core/desktop.py create mode 100644 src/mhwmm/core/layout.py create mode 100644 src/mhwmm/core/library.py create mode 100644 src/mhwmm/core/loader.py create mode 100644 src/mhwmm/core/manager.py create mode 100644 src/mhwmm/core/modinfo.py create mode 100644 src/mhwmm/core/nexus.py create mode 100644 src/mhwmm/core/paks.py create mode 100644 src/mhwmm/core/paths.py create mode 100644 src/mhwmm/core/profiles.py create mode 100644 src/mhwmm/core/steam.py create mode 100644 src/mhwmm/core/util.py create mode 100644 src/mhwmm/ui/__init__.py create mode 100644 src/mhwmm/ui/app.py create mode 100644 src/mhwmm/ui/details.py create mode 100644 src/mhwmm/ui/dialogs.py create mode 100644 src/mhwmm/ui/downloads.py create mode 100644 src/mhwmm/ui/tasks.py create mode 100644 src/mhwmm/ui/window.py create mode 100644 tests/__init__.py create mode 100644 tests/helpers.py create mode 100644 tests/test_deploy.py create mode 100644 tests/test_integrations.py create mode 100644 tests/test_library.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a5f38aa --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +.venv/ +*.egg-info/ diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..30cf57e --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..3dce9c6 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,12 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..4b151ab --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..e8cbfbb --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/MHW-Mod-Manager.iml b/MHW-Mod-Manager.iml new file mode 100644 index 0000000..9a5cfce --- /dev/null +++ b/MHW-Mod-Manager.iml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..0fffb3b --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# MH Wilds Mod Manager (Linux) + +Native GTK4/libadwaita mod manager for Monster Hunter Wilds: checkbox mod list, screenshots, categories, +search, conflict detection, load order, profiles, archive import (zip/7z/rar), `nxm://` downloads, REFramework +setup, and safe recovery when a title update raises the vanilla PAK ceiling. + +No pip packages: it needs only system PyGObject (GTK 4, libadwaita ≥ 1.4), plus `7z` / `unrar` for those formats. + +## Run + +```bash +./bin/mhwmm +``` + +Tests (headless, stdlib `unittest`): + +```bash +PYTHONPATH=src python3 -m unittest discover -s tests -t . +``` + +A dev venv must use `--system-site-packages`, otherwise `gi` disappears. + +## How it works + +- Mods are extracted into `~/.local/share/mhwilds-mod-manager/mods//files`; the game folder is only touched + by **Deploy**, which symlinks (or hardlinks/copies) files and records each one in `state/deployed.json`. +- PAK mods get slots above the vanilla ceiling of each chain (`re_chunk_000.pak.patch_NNN.pak` and the + `.sub_000` chain). On every start the manager compares against `state/pak-baseline.json`; if Steam added or + changed PAKs it removes its own PAKs, re-derives the ceiling and re-slots. +- Safety: only files in the manifest that still match what was recorded are ever deleted; foreign PAKs are never + touched; `reframework/data/` is never managed; destinations are restricted to `natives/`, `reframework/` and + PAK/DLL names at the game root; failed deploys roll back. **Undeploy All** returns the game folder to its + pre-mod state (shadowed files restored, directories the manager created pruned). +- REFramework: `dinput8.dll`, `LooseFileLoader_Enabled` in `re2_fw_config.txt`, and the Proton launch option + `WINEDLLOVERRIDES="dinput8.dll=n,b" %command%`. The launch option is verified and shown with a Copy button; + writing `localconfig.vdf` is opt-in, backed up, and refused while Steam runs. + +## nxm:// links + +Preferences → Nexus Mods: paste an API key and press *Register* (writes a user-level `.desktop` file and runs +`xdg-mime default`). Links are forwarded to the running instance over D-Bus. + +## Known limits + +- PAK↔PAK / PAK↔loose conflict detection parses the KPKA entry table and hashes paths with murmur3; it is + best-effort and shows nothing if a PAK cannot be parsed. +- A PAK you copy into the game folder by hand above the ceiling is indistinguishable from a game update and will + be adopted as vanilla. diff --git a/bin/mhwmm b/bin/mhwmm new file mode 100755 index 0000000..46223ae --- /dev/null +++ b/bin/mhwmm @@ -0,0 +1,9 @@ +#!/usr/bin/env python3 +"""Run from a checkout: ./bin/mhwmm""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src")) +from mhwmm.__main__ import main + +sys.exit(main()) diff --git a/data/ca.schantli.MHWildsModManager.desktop b/data/ca.schantli.MHWildsModManager.desktop new file mode 100644 index 0000000..8da0c83 --- /dev/null +++ b/data/ca.schantli.MHWildsModManager.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Type=Application +Name=MH Wilds Mod Manager +Comment=Manage mods for Monster Hunter Wilds +Exec=mhwmm %u +Icon=applications-games +Terminal=false +Categories=Game;Utility; +MimeType=x-scheme-handler/nxm; +StartupNotify=true diff --git a/plan.md b/plan.md new file mode 100644 index 0000000..88c545f --- /dev/null +++ b/plan.md @@ -0,0 +1,229 @@ +# Native Linux Mod Manager for Monster Hunter Wilds + +## Context + +There is no native Linux mod manager for Monster Hunter Wilds. Fluffy Manager 5000 is the de-facto +standard but is Windows-only, so Linux players run it under a second Wine prefix or hand-copy files +into the game directory. This project builds a native GTK4 desktop app that reproduces Fluffy's +workflow — a checkbox mod list with screenshots, categories, search, conflict detection and archive +import — while handling the things Fluffy does not: Proton/Steam integration, and safe recovery when +a title update shifts the vanilla PAK ceiling. + +The project directory is empty apart from an IntelliJ `GENERAL_MODULE` stub (`MHW-Mod-Manager.iml`, +no SDK pinned), so this is greenfield. + +### Verified facts this design rests on + +Gathered from the live system and from research this session: + +| Fact | Source | +|---|---| +| Installed game is **Wilds**, app `2246340`, at `~/.steam/steam/steamapps/common/MonsterHunterWilds` | `appmanifest_2246340.acf` | +| Two parallel PAK chains, both currently ending at **015**: `re_chunk_000.pak.patch_NNN.pak` and `re_chunk_000.pak.sub_000.pak.patch_NNN.pak` | directory listing | +| Higher patch number wins; mods slot **above** the vanilla ceiling, which rises with every title update | community guides | +| Loose mods live in `natives/STM/…`, loaded by REFramework's loose file loader | REFramework docs | +| Loose file loader is toggled by `LooseFileLoader_Enabled` in **`re2_fw_config.txt`** in the game root — the filename keeps its RE2 origin across all RE Engine games — and must be set *before* launch | REFramework issue tracker | +| REFramework = `dinput8.dll` in game root; on Proton it needs `WINEDLLOVERRIDES="dinput8.dll=n,b" %command%` | REFramework docs | +| Lua mods → `reframework/autorun/`, DLL plugins → `reframework/plugins/`, `reframework/data/` holds user settings and must be preserved | cleanup guides | +| No `libraryfolders.vdf` extra libraries, no REFramework, no `natives/` present yet — clean slate | system inspection | + +### Decisions taken with the user + +- **Target:** Monster Hunter Wilds only (not MH:World). +- **Stack:** Python 3.14 + GTK4/libadwaita via system PyGObject. +- **v1 extras:** load order UI, mod loader setup, profiles, `nxm://` integration. + +### Constraint: zero third-party runtime dependencies + +Bazzite is an immutable distro, so the app must run without `pip install`. Everything needed is +already present and verified on this machine: + +- UI — system PyGObject, `Gtk 4.0` + `Adw 1` (`/usr/lib64/python3.14/site-packages/gi`) +- zip — stdlib `zipfile`; **7z** — `/usr/bin/7z` (26.02); **rar** — `/usr/bin/unrar` +- HTTP — stdlib `urllib.request`; config — stdlib `json` / `configparser` + +`pyproject.toml` declares no runtime deps. A venv is only needed for dev tooling, and must be +created with `--system-site-packages` or `gi` disappears. + +--- + +## Architecture + +``` +src/mhwmm/ +├── __main__.py entry point +├── app.py Adw.Application; HANDLES_COMMAND_LINE for nxm:// single-instance forwarding +├── core/ ← pure logic, no GTK imports, fully unit-testable +│ ├── paths.py XDG dirs + library layout +│ ├── config.py settings.json, atomic save +│ ├── steam.py VDF parser, app discovery, launch-option read/verify +│ ├── modinfo.py Fluffy-compatible modinfo.ini + screenshot discovery +│ ├── archive.py format sniffing, extraction, path-traversal rejection +│ ├── layout.py classify an extracted tree → deployment entries +│ ├── library.py Mod model; import / scan / rename / delete +│ ├── deploy.py link engine, manifest, conflicts, transactional apply/rollback +│ ├── paks.py vanilla baseline, slot allocation, title-update re-slotting +│ ├── profiles.py named enabled-sets + load order +│ ├── loader.py REFramework detect/install, re2_fw_config.txt, Proton wiring +│ └── nexus.py nxm:// parse, Nexus API client, downloads +└── ui/ window, mod_list, details, setup, conflicts, prefs +``` + +The `core` / `ui` split is deliberate: every risky behaviour (file deletion, PAK slotting, archive +extraction) lives in `core` with no GTK dependency, so it is testable headlessly. + +### On-disk state + +``` +~/.local/share/mhwilds-mod-manager/ +├── mods// extracted mod trees (the library) +├── downloads/ nxm:// downloads +└── state/ + ├── deployed.json manifest: every path we created in the game dir + ├── pak-baseline.json vanilla PAK snapshot (names + size + mtime_ns) + └── backup/ pre-existing real files we shadowed +~/.config/mhwilds-mod-manager/ +├── settings.json +└── profiles.json +``` + +--- + +## Core subsystems + +### `layout.py` — classifying a mod + +Mods are packaged inconsistently, so the payload root must be discovered rather than assumed. Walk +the extracted tree and emit a list of `(kind, source_abspath, dest_relpath)` entries. A single mod +can produce several kinds at once. Detection, in priority order: + +- dir named `natives` (case-insensitive, any depth) → loose assets, payload root is its parent +- dir named `reframework` → Lua/plugin mod, preserving `autorun/` and `plugins/` +- bare `STM/` dir → wrap as `natives/STM` +- `*.pak` at or near root → PAK mod (gets a slot, see below) +- loose `*.lua` at root → `reframework/autorun/` +- loose `*.dll` at root → ambiguous; surface a prompt rather than guessing, since this could be a + plugin *or* a root-level `dinput8.dll` + +### `deploy.py` — the link engine + +Deployment is a pure function from (enabled mods, load order) to a desired `dest_relpath → +source_abspath` map, diffed against the last manifest. + +- **Later mod in load order wins** a contested path; every contested path is recorded as a conflict + with its winner and losers, which is what feeds the conflict UI. +- **Link mode** defaults to **symlink** — instant, no disk cost next to a ~100 GB install, works + across filesystems, and `ls -l` makes managed files self-evident during debugging. Wine resolves + symlinks transparently on read. Hardlink and copy are available in preferences. +- **Shadowing:** if an unmanaged real file already occupies a destination, move it to + `state/backup/` and record it; restore on undeploy. +- **Transactional:** build plan → apply → write manifest via temp-file + `os.replace`. Roll back on + mid-apply failure. + +### Safety rules (non-negotiable — this writes into a ~100 GB game install) + +1. Never delete anything in the game directory that is not in our manifest **and** whose on-disk + state still matches what we recorded. +2. Never delete a PAK we did not create. +3. Every destructive operation is preceded by a "Preview changes" plan the user can inspect. +4. `reframework/data/` is never touched — it holds user presets and script settings. + +### `paks.py` — surviving title updates + +This is the standout feature and the main thing Fluffy handles badly. Leftover mod PAKs numbered +above the vanilla ceiling are the single most common cause of the community's spurious +"`re_chunk_000.pak.patch_NNN.pak` is corrupted" reports. + +- Snapshot the vanilla ceiling per chain at setup (currently `015` for both chains) along with each + vanilla PAK's size and mtime. +- Allocate mod slots from `ceiling + 1` upward, in load order. +- **On every startup**, re-scan. If a PAK appears inside our slot range that we do not own, Steam + has patched the game: undeploy mod PAKs, re-derive the ceiling, re-slot, redeploy, and tell the + user plainly ("Game updated — mod PAKs re-slotted to 017+"). + +Note that loose files and PAKs conflict if they supply the same asset, so the conflict checker spans +both delivery mechanisms rather than treating them separately. + +### `steam.py` — library and launch options + +A ~40-line text-VDF parser handles `libraryfolders.vdf` and `localconfig.vdf`. Launch options live +at `userdata//config/localconfig.vdf` → `…/apps/2246340/LaunchOptions`. + +**Steam rewrites `localconfig.vdf` on exit, so writing it while Steam runs silently loses the +change.** The app therefore *reads and verifies* by default, showing the exact required string with +a Copy button. Writing it is an explicit opt-in action, gated on Steam not running, and backs the +file up first. + +### `loader.py` — REFramework setup + +Status panel with a per-check ✓/✗: `dinput8.dll` present · `LooseFileLoader_Enabled=true` · +launch-option override present. One-click install fetches the latest `MHWILDS.zip` from the +`praydog/REFramework-nightly` GitHub releases API and extracts `dinput8.dll` to the game root. +`re2_fw_config.txt` is patched key-wise, preserving unrelated settings, and created if absent. + +### `nexus.py` — nxm:// handling + +- Handler desktop file registers `x-scheme-handler/nxm`; `Gio.ApplicationFlags.HANDLES_COMMAND_LINE` + forwards the URL to the already-running instance over D-Bus. +- Parse `nxm://monsterhunterwilds/mods//files/?key=…&expires=…`, then + `GET /v1/games/monsterhunterwilds/mods/{mod}/files/{file}/download_link.json?key=…&expires=…` + with an `apikey` header. The `key`/`expires` pair is what lets **non-premium** accounts download, + so it must be passed through rather than dropped. +- Also fetch `/v1/games/monsterhunterwilds/mods/{mod}.json` to prefill name, version, summary and + thumbnail. Surface the rate-limit response headers. +- API key stored in libsecret when `gi.repository.Secret` is importable, else a `0600` JSON file. + +### UI + +`Adw.NavigationSplitView`. Sidebar: search, category filter, profile selector, and the mod list — +each row a `Gtk.CheckButton`, name, version, category badge and a conflict-warning icon. Load order +by drag-and-drop with up/down keyboard fallback. Content pane: auto-detected `screenshot.png|jpg` +(Fluffy-compatible), metadata, description, and Files / Conflicts tabs. Header: Import Mod, Deploy, +overflow menu. Archives can be dropped onto the window to import. `Adw.StatusPage` covers first-run +and game-not-found. Toasts report results. + +Toggling a checkbox deploys immediately, matching Fluffy; a batched "pending changes" mode is a +preference. + +--- + +## Implementation phases + +Each phase ends in something runnable. + +1. **Skeleton + game discovery** — `pyproject.toml`, `paths`, `config`, `steam`; window that finds + the install and shows both PAK chains and the detected ceiling. +2. **Library import** — `archive`, `modinfo`, `layout`, `library`; import archives, list mods with + screenshots and metadata. No writes to the game dir yet. +3. **Deploy engine** — `deploy` + `paks`; enable/disable, conflict detection, load order, + preview-changes, undeploy round-trip. The correctness core. +4. **Loader setup** — `loader`; REFramework install, `re2_fw_config.txt`, launch-option verification. +5. **Profiles + polish** — `profiles`, preferences, search/categories, drag reorder, toasts. +6. **Nexus** — `nexus`, desktop files, scheme registration, download queue. + +## Verification + +Automated (stdlib `unittest`, no GTK needed, runs headless): + +- **Round-trip invariant — the key test:** snapshot a synthetic game dir, deploy N mods, undeploy, + assert the tree is byte-identical including backups restored and empty dirs pruned. +- Zip-slip and absolute-path entries are rejected by `archive`. +- `layout` classification across the packaging shapes listed above. +- Conflict winner follows load order; reordering flips the winner. +- PAK slot allocation, and re-slotting when a synthetic `patch_016` appears from outside. +- VDF parser against the real `libraryfolders.vdf` and `appmanifest_2246340.acf` on this machine. +- `nxm://` URL parsing including `key`/`expires` passthrough. + +Manual, against the real install: + +1. Launch, confirm it finds `MonsterHunterWilds` and reports ceiling `015` on both chains. +2. Install REFramework via the app; confirm `dinput8.dll` and `LooseFileLoader_Enabled=true`, and + that it reports the missing `WINEDLLOVERRIDES` launch option with the exact string to copy. +3. Import a real Wilds mod, enable it, and verify the symlinks under `natives/STM/` resolve. +4. Enable two mods touching the same asset; confirm the conflict shows and reordering flips the + winner. +5. Disable everything and confirm the game directory returns to exactly its pre-mod state — compare + against a `find`-based snapshot taken before step 3. +6. Launch the game through Steam and confirm the mod loads. + +Step 5 is the acceptance gate: the manager must be able to fully undo itself. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1f86c5f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "mhwilds-mod-manager" +version = "0.1.0" +description = "Native Linux mod manager for Monster Hunter Wilds" +requires-python = ">=3.11" +dependencies = [] # GTK4/libadwaita come from system PyGObject; nothing to pip install + +[project.scripts] +mhwmm = "mhwmm.__main__:main" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/src/mhwmm/__init__.py b/src/mhwmm/__init__.py new file mode 100644 index 0000000..aa9e081 --- /dev/null +++ b/src/mhwmm/__init__.py @@ -0,0 +1,2 @@ +__version__ = "0.1.0" +APP_ID = "ca.schantli.MHWildsModManager" diff --git a/src/mhwmm/__main__.py b/src/mhwmm/__main__.py new file mode 100644 index 0000000..b7df75a --- /dev/null +++ b/src/mhwmm/__main__.py @@ -0,0 +1,17 @@ +import sys + + +def main() -> int: + try: + from .ui.app import App + except (ImportError, ValueError) as e: + sys.stderr.write( + f"Could not load GTK4/libadwaita via PyGObject ({e}).\n" + "Install the system packages (e.g. python3-gobject, gtk4, libadwaita) — no pip needed.\n" + "If you use a venv, create it with --system-site-packages.\n") + return 1 + return App().run(sys.argv) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/mhwmm/core/__init__.py b/src/mhwmm/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/mhwmm/core/archive.py b/src/mhwmm/core/archive.py new file mode 100644 index 0000000..1b5168c --- /dev/null +++ b/src/mhwmm/core/archive.py @@ -0,0 +1,144 @@ +"""Archive sniffing and hardened extraction (zip / 7z / rar).""" +from __future__ import annotations + +import os +import shutil +import stat +import subprocess +import zipfile +from pathlib import Path + +from .util import safe_relpath + +MAX_UNPACKED = 32 * 1024 ** 3 + + +class ArchiveError(Exception): + pass + + +def sniff(path: Path) -> str: + """Return 'zip', '7z', 'rar' based on magic bytes (extensions lie).""" + with open(path, "rb") as fh: + head = fh.read(8) + if head[:4] in (b"PK\x03\x04", b"PK\x05\x06"): + return "zip" + if head[:6] == b"7z\xbc\xaf\x27\x1c": + return "7z" + if head[:4] == b"Rar!": + return "rar" + raise ArchiveError(f"{Path(path).name}: not a zip, 7z or rar archive") + + +def _validate_name(name: str) -> str: + rel = safe_relpath(name) + if rel is None: + raise ArchiveError(f"unsafe path in archive: {name!r}") + return rel + + +def _extract_zip(path: Path, dest: Path) -> None: + try: + zf = zipfile.ZipFile(path) + except zipfile.BadZipFile as e: + raise ArchiveError(f"corrupt zip: {e}") from e + with zf: + infos = zf.infolist() + # validate everything before writing anything + total = 0 + plan = [] + for info in infos: + rel = _validate_name(info.filename) + mode = info.external_attr >> 16 + if stat.S_ISLNK(mode): + raise ArchiveError(f"symlink entries are not allowed: {info.filename!r}") + total += info.file_size + plan.append((info, rel)) + if total > MAX_UNPACKED: + raise ArchiveError("archive unpacks to an unreasonable size") + root = os.path.realpath(dest) + for info, rel in plan: + target = os.path.join(root, rel) + if os.path.commonpath([root, os.path.realpath(target)]) != root: + raise ArchiveError(f"unsafe path in archive: {info.filename!r}") + if info.is_dir(): + os.makedirs(target, exist_ok=True) + continue + os.makedirs(os.path.dirname(target), exist_ok=True) + with zf.open(info) as src, open(target, "wb") as out: + shutil.copyfileobj(src, out) + + +def _tool(*names: str) -> str | None: + for n in names: + p = shutil.which(n) + if p: + return p + return None + + +def _run(cmd: list[str]) -> str: + try: + r = subprocess.run(cmd, capture_output=True, text=True, errors="replace", timeout=3600) + except FileNotFoundError as e: + raise ArchiveError(f"required tool missing: {cmd[0]}") from e + if r.returncode != 0: + raise ArchiveError((r.stderr or r.stdout).strip().splitlines()[-1:][0] if (r.stderr or r.stdout).strip() + else f"{cmd[0]} failed") + return r.stdout + + +def _list_7z(tool: str, path: Path) -> list[str]: + out = _run([tool, "l", "-slt", "-ba", "--", str(path)]) + return [line[7:] for line in out.splitlines() if line.startswith("Path = ")] + + +def _forbid_symlinks(dest: Path) -> None: + for dirpath, dirnames, filenames in os.walk(dest): + for n in dirnames + filenames: + p = os.path.join(dirpath, n) + if os.path.islink(p): + raise ArchiveError(f"symlink found in extracted archive: {n!r}") + + +def _extract_7z(path: Path, dest: Path) -> None: + tool = _tool("7z", "7zz", "7za", "7zr") + if not tool: + raise ArchiveError("7z is not installed") + for n in _list_7z(tool, path): + _validate_name(n) + _run([tool, "x", "-y", f"-o{dest}", "--", str(path)]) + _forbid_symlinks(dest) + + +def _extract_rar(path: Path, dest: Path) -> None: + unrar = _tool("unrar") + if unrar: + names = _run([unrar, "lb", "--", str(path)]).splitlines() + for n in names: + if n.strip(): + _validate_name(n) + _run([unrar, "x", "-y", "-o+", "--", str(path), str(dest) + os.sep]) + _forbid_symlinks(dest) + else: + _extract_7z(path, dest) # 7z can read rar when its codec is present + + +def extract(archive: Path, dest: Path) -> None: + """Extract into an existing empty directory, rejecting traversal and symlinks.""" + archive, dest = Path(archive), Path(dest) + dest.mkdir(parents=True, exist_ok=True) + kind = sniff(archive) + {"zip": _extract_zip, "7z": _extract_7z, "rar": _extract_rar}[kind](archive, dest) + + +def copy_tree(src: Path, dest: Path) -> None: + """Import an already-extracted folder; symlinks are skipped.""" + for dirpath, dirnames, filenames in os.walk(src): + rel = Path(dirpath).relative_to(src) + (dest / rel).mkdir(parents=True, exist_ok=True) + dirnames[:] = [d for d in dirnames if not os.path.islink(os.path.join(dirpath, d))] + for f in filenames: + s = os.path.join(dirpath, f) + if not os.path.islink(s): + shutil.copy2(s, dest / rel / f) diff --git a/src/mhwmm/core/config.py b/src/mhwmm/core/config.py new file mode 100644 index 0000000..83faf52 --- /dev/null +++ b/src/mhwmm/core/config.py @@ -0,0 +1,30 @@ +"""settings.json — user preferences.""" +from __future__ import annotations + +from dataclasses import asdict, dataclass, fields +from pathlib import Path + +from .util import atomic_write_json, read_json + +LINK_MODES = ("symlink", "hardlink", "copy") + + +@dataclass +class Settings: + game_dir: str = "" # manual override; empty = auto-discover + link_mode: str = "symlink" + auto_deploy: bool = True # deploy on every checkbox toggle (Fluffy behaviour) + steam_user_id: str = "" # empty = first userdata dir + reframework_version: str = "" + + @classmethod + def load(cls, path: Path) -> "Settings": + raw = read_json(path, {}) + known = {f.name for f in fields(cls)} + s = cls(**{k: v for k, v in raw.items() if k in known}) if isinstance(raw, dict) else cls() + if s.link_mode not in LINK_MODES: + s.link_mode = "symlink" + return s + + def save(self, path: Path) -> None: + atomic_write_json(path, asdict(self)) diff --git a/src/mhwmm/core/deploy.py b/src/mhwmm/core/deploy.py new file mode 100644 index 0000000..9dbd3cc --- /dev/null +++ b/src/mhwmm/core/deploy.py @@ -0,0 +1,485 @@ +"""The link engine. + +Deployment is a pure function from (enabled mods, load order) to a desired +`dest -> source` map, diffed against the last manifest and applied transactionally. + +Safety rules (this writes into a ~100 GB game install): + 1. Never delete anything that is not in our manifest *and* still matches what we recorded. + 2. Never delete a PAK we did not create (falls out of rule 1). + 3. Every destructive operation is preceded by a Plan the caller can show the user. + 4. reframework/data/ is never touched. +""" +from __future__ import annotations + +import os +import shutil +import uuid +from dataclasses import dataclass, field +from pathlib import Path, PurePosixPath +from typing import Callable + +from . import paks +from .layout import PAK, Entry +from .paths import Paths +from .util import atomic_write_json, read_json + +MANIFEST_VERSION = 1 +ALLOWED_ROOTS = ("natives", "reframework", "pak_mods") + + +class DeployError(Exception): + pass + + +@dataclass(frozen=True) +class DeployMod: + id: str + entries: tuple[Entry, ...] + + +@dataclass(frozen=True) +class Item: + dest: str + src: Path + mod: str + kind: str + + +@dataclass +class Conflict: + key: str # dest path, or a description for PAK asset overlaps + kind: str # loose | pak-pak | pak-loose + mods: list[str] # every mod involved, ascending priority + winner: str + losers: list[str] + count: int = 1 # number of overlapping assets (PAK overlaps are aggregated) + + +@dataclass +class Desired: + items: dict[str, Item] = field(default_factory=dict) + conflicts: list[Conflict] = field(default_factory=list) + slots: list[paks.Slot] = field(default_factory=list) + + +# -- destination validation --------------------------------------------------- + +def validate_dest(dest: str, baseline_names: set[str] = frozenset()) -> str | None: + """Return an error string if `dest` is not somewhere we may write, else None.""" + if not dest or dest.startswith("/") or "\\" in dest or "\0" in dest: + return f"invalid destination {dest!r}" + parts = PurePosixPath(dest).parts + if any(p in ("..", ".") for p in parts): + return f"destination escapes the game directory: {dest}" + low = [p.lower() for p in parts] + if low[0] == "reframework" and len(low) > 1 and low[1] == "data": + return f"reframework/data is never managed: {dest}" + if len(parts) == 1: + if dest in baseline_names: + return f"refusing to shadow vanilla PAK {dest}" + if paks.parse_pak_name(dest) or dest.lower().endswith(".dll"): + return None + return f"root-level file not allowed: {dest}" + if low[0] not in ALLOWED_ROOTS: + return f"destination outside natives/ or reframework/: {dest}" + return None + + +# -- desired state -------------------------------------------------------------- + +def build_desired(mods: list[DeployMod], baseline: paks.Baseline, + hashes: Callable[[Path], frozenset] = paks.pak_hashes) -> Desired: + """`mods` is in load order, lowest priority first; later mods win contested paths.""" + des = Desired() + by_key: dict[str, list[Item]] = {} + for mod in mods: + for e in mod.entries: + if e.kind == PAK: + continue + by_key.setdefault(e.dest.lower(), []).append(Item(e.dest, e.src, mod.id, e.kind)) + for contenders in by_key.values(): + # a mod listing the same dest twice is not a conflict with itself + winner = contenders[-1] + des.items[winner.dest] = winner + ids = list(dict.fromkeys(c.mod for c in contenders)) + if len(ids) > 1: + des.conflicts.append(Conflict(winner.dest, "loose", ids, winner.mod, + [i for i in ids if i != winner.mod])) + + des.slots = paks.allocate([(m.id, list(m.entries)) for m in mods], baseline) + for s in des.slots: + des.items[s.dest] = Item(s.dest, s.src, s.mod_id, PAK) + + des.conflicts.extend(_pak_conflicts(des, mods, hashes)) + des.conflicts.sort(key=lambda c: (c.kind, c.key.lower())) + return des + + +def _pak_conflicts(des: Desired, mods: list[DeployMod], hashes) -> list[Conflict]: + """Asset overlaps involving PAKs, matched by KPKA path hash (best effort).""" + if not des.slots: + return [] + order = {m.id: i for i, m in enumerate(mods)} + owners: dict[tuple[int, int], list[tuple[str, str]]] = {} # hash -> [(mod, "pak"|"loose")] + for s in des.slots: + for h in hashes(s.src): + owners.setdefault(h, []).append((s.mod_id, "pak")) + if not owners: + return [] + for it in des.items.values(): + if it.kind != PAK and it.dest.lower().startswith("natives/"): + h = paks.path_hash(it.dest) + if h in owners: + owners[h].append((it.mod, "loose")) + groups: dict[tuple, dict] = {} + for h, lst in owners.items(): + ids = tuple(sorted(dict.fromkeys(m for m, _ in lst), key=lambda m: order.get(m, 0))) + if len(ids) < 2: + continue + loose = {m for m, k in lst if k == "loose"} + g = groups.setdefault((("pak-loose" if loose else "pak-pak"), ids), {"count": 0, "loose": set()}) + g["count"] += 1 + g["loose"] |= loose + out = [] + for (kind, ids), g in groups.items(): + # the loose file loader takes precedence over PAK contents + winner = next((m for m in reversed(ids) if m in g["loose"]), ids[-1]) + out.append(Conflict(f"{g['count']} shared asset(s)", kind, list(ids), winner, + [i for i in ids if i != winner], g["count"])) + return out + + +# -- plan --------------------------------------------------------------------------- + +@dataclass +class Action: + op: str # add | remove | forget | skip + dest: str + item: Item | None = None + entry: dict | None = None + note: str = "" + shadow: bool = False # add: an unmanaged file occupies dest and will be backed up + + def describe(self) -> str: + if self.op == "add": + extra = " (backs up existing file)" if self.shadow else "" + return f"+ {self.dest}{extra}" + if self.op == "remove": + return f"- {self.dest}" + (" (restores backup)" if self.entry and self.entry.get("backup") else "") + if self.op == "forget": + return f"? {self.dest} {self.note}" + return f"! {self.dest} {self.note}" + + +@dataclass +class Plan: + actions: list[Action] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + + @property + def changes(self) -> list[Action]: + return [a for a in self.actions if a.op in ("add", "remove")] + + @property + def is_empty(self) -> bool: + return not self.actions + + def describe(self) -> list[str]: + return [a.describe() for a in self.actions] + [f"warning: {w}" for w in self.warnings] + + +@dataclass +class ApplyResult: + added: int = 0 + removed: int = 0 + warnings: list[str] = field(default_factory=list) + + +# -- engine --------------------------------------------------------------------------- + +class _Txn: + """Undo log so a mid-apply failure leaves the game directory as it was.""" + + def __init__(self): + self.undo: list[Callable[[], None]] = [] + self.trash: list[Path] = [] + + def rollback(self) -> list[str]: + errs = [] + for fn in reversed(self.undo): + try: + fn() + except Exception as e: # keep unwinding + errs.append(str(e)) + return errs + + +class Deployer: + def __init__(self, game_dir: Path, paths: Paths, link_mode: str = "symlink"): + self.game = Path(game_dir) + self.paths = paths + self.link_mode = link_mode + if not self.game.is_dir(): + raise DeployError(f"game directory not found: {self.game}") + self._real_game = Path(os.path.realpath(self.game)) + + # -- manifest --------------------------------------------------------- + def manifest(self) -> dict: + m = read_json(self.paths.manifest_file, None) + if not isinstance(m, dict) or "entries" not in m: + return {"version": MANIFEST_VERSION, "entries": {}, "dirs": []} + m.setdefault("dirs", []) + return m + + def _save_manifest(self, m: dict) -> None: + atomic_write_json(self.paths.manifest_file, m) + + def _inside(self, path: Path) -> bool: + real = Path(os.path.realpath(path)) + return real == self._real_game or self._real_game in real.parents + + def _p(self, dest: str) -> Path: + return self.game / dest + + def check(self, dest: str, entry: dict) -> str: + """'ok' if the on-disk state still matches what we recorded, 'missing', or 'modified'.""" + p = self._p(dest) + if not os.path.lexists(p): + return "missing" + try: + mode = entry["mode"] + if mode == "symlink": + return "ok" if os.path.islink(p) and os.readlink(p) == entry["src"] else "modified" + if os.path.islink(p) or not os.path.isfile(p): + return "modified" + st = os.stat(p) + if mode == "hardlink": + return "ok" if (st.st_ino, st.st_dev) == (entry.get("ino"), entry.get("dev")) else "modified" + return "ok" if (st.st_size, st.st_mtime_ns) == (entry.get("size"), entry.get("mtime_ns")) else "modified" + except (OSError, KeyError): + return "modified" + + def owned_paks(self) -> set[str]: + m = self.manifest() + return {d for d, e in m["entries"].items() + if e.get("kind") == PAK and self.check(d, e) == "ok"} + + def desired_from_manifest(self, exclude_kind: str | None = None) -> Desired: + """The currently-deployed state as a Desired, optionally minus one kind.""" + des = Desired() + for d, e in self.manifest()["entries"].items(): + if e.get("kind") != exclude_kind: + des.items[d] = Item(d, Path(e["src"]), e.get("mod", ""), e.get("kind", "")) + return des + + # -- planning ----------------------------------------------------------- + def plan(self, desired: Desired) -> Plan: + plan = Plan() + m = self.manifest() + entries: dict[str, dict] = m["entries"] + baseline = paks.load_baseline(self.paths.baseline_file) + vanilla = {n for c in (baseline.chains.values() if baseline else []) for n in c.get("files", {})} + + for dest, entry in sorted(entries.items()): + state = self.check(dest, entry) + want = desired.items.get(dest) + if want is None: + if state == "ok": + plan.actions.append(Action("remove", dest, entry=entry)) + elif state == "missing": + plan.actions.append(Action("forget", dest, entry=entry, note="already gone")) + else: + plan.actions.append(Action("forget", dest, entry=entry, note="changed since deployment; left in place")) + plan.warnings.append(f"{dest} was modified outside the manager; leaving it alone") + elif state == "modified": + plan.actions.append(Action("skip", dest, want, entry, "changed since deployment; not overwritten")) + plan.warnings.append(f"{dest} was modified outside the manager; skipping") + elif str(want.src) != entry["src"] or want.mod != entry.get("mod"): + plan.actions.append(Action("remove", dest, entry=entry, note="replaced")) + plan.actions.append(Action("add", dest, want)) + elif state == "missing": + plan.actions.append(Action("add", dest, want, note="re-link")) + + for dest, item in sorted(desired.items.items()): + if dest in entries: + continue + err = validate_dest(dest, vanilla) + if err: + plan.actions.append(Action("skip", dest, item, note=err)) + plan.warnings.append(err) + continue + p = self._p(dest) + shadow = os.path.lexists(p) + if shadow and (os.path.isdir(p) and not os.path.islink(p)): + plan.actions.append(Action("skip", dest, item, note="a directory is in the way")) + plan.warnings.append(f"{dest}: a directory is in the way") + continue + if shadow and (self.paths.backup_dir / dest).exists(): + plan.actions.append(Action("skip", dest, item, note="a stale backup already exists")) + plan.warnings.append(f"{dest}: stale backup exists at {self.paths.backup_dir / dest}") + continue + plan.actions.append(Action("add", dest, item, shadow=shadow)) + return plan + + # -- apply -------------------------------------------------------------- + def apply(self, plan: Plan) -> ApplyResult: + m = self.manifest() + entries: dict[str, dict] = dict(m["entries"]) + dirs: list[str] = list(m["dirs"]) + res = ApplyResult(warnings=list(plan.warnings)) + txn = _Txn() + try: + # removals first so a replaced path is free before its new link goes in + carry: dict[str, str] = {} + for a in plan.actions: + if a.op == "remove": + replaced = a.note == "replaced" + old = entries.pop(a.dest) + self._remove(a.dest, old, txn, restore=not replaced) + if replaced and old.get("backup"): + carry[a.dest] = old["backup"] # the shadowed original stays backed up + else: + res.removed += 1 + elif a.op == "forget": + e = entries.pop(a.dest, None) + if a.note == "already gone" and e and e.get("backup"): + self._restore_backup(a.dest, txn) + for a in plan.actions: + if a.op == "add": + entries[a.dest] = self._add(a, txn, dirs, carry.get(a.dest, "")) + res.added += 1 + m = {"version": MANIFEST_VERSION, "entries": entries, "dirs": dirs} + self._save_manifest(m) + except BaseException as e: + errs = txn.rollback() + if errs: + res.warnings.extend(f"rollback: {x}" for x in errs) + if isinstance(e, (OSError, DeployError)): + raise DeployError(f"deploy failed and was rolled back: {e}") from e + raise + for t in txn.trash: + shutil.rmtree(t, ignore_errors=True) + self._prune_dirs(m) + return res + + def undeploy_all(self) -> ApplyResult: + return self.apply(self.plan(Desired())) + + # -- primitives ----------------------------------------------------------- + def _remove(self, dest: str, entry: dict, txn: _Txn, restore: bool = True) -> None: + p = self._p(dest) + mode = entry["mode"] + if mode == "symlink": + target = os.readlink(p) + os.unlink(p) + txn.undo.append(lambda: os.symlink(target, p)) + elif mode == "hardlink": + src = entry["src"] + os.unlink(p) + txn.undo.append(lambda: os.link(src, p)) + else: + stash = self.paths.trash_dir / uuid.uuid4().hex + stash.mkdir(parents=True) + txn.trash.append(stash) + shutil.move(str(p), str(stash / "f")) + txn.undo.append(lambda: shutil.move(str(stash / "f"), str(p))) + if restore and entry.get("backup"): + self._restore_backup(dest, txn) + + def _restore_backup(self, dest: str, txn: _Txn) -> None: + bak = self.paths.backup_dir / dest + p = self._p(dest) + if not os.path.lexists(bak): + return + if os.path.lexists(p): + return # something else lives there now; keep the backup + os.makedirs(p.parent, exist_ok=True) + shutil.move(str(bak), str(p)) + txn.undo.append(lambda: (os.makedirs(bak.parent, exist_ok=True), shutil.move(str(p), str(bak)))) + self._prune_backup_dirs(bak.parent) + + def _prune_backup_dirs(self, d: Path) -> None: + root = self.paths.backup_dir + while d != root and root in d.parents: + try: + d.rmdir() + except OSError: + return + d = d.parent + + def _mkdirs(self, rel_parent: str, dirs: list[str], txn: _Txn) -> None: + cur = self.game + rel = "" + for part in PurePosixPath(rel_parent).parts: + cur = cur / part + rel = f"{rel}/{part}" if rel else part + if os.path.lexists(cur): + if not os.path.isdir(cur): + raise DeployError(f"{rel} exists and is not a directory") + continue + if not self._inside(cur.parent): + raise DeployError(f"{rel} would land outside the game directory") + os.mkdir(cur) + dirs.append(rel) + txn.undo.append(lambda c=cur: os.rmdir(c)) + + def _add(self, a: Action, txn: _Txn, dirs: list[str], carry_backup) -> dict: + assert a.item is not None + dest, src = a.dest, a.item.src + err = validate_dest(dest) + if err: + raise DeployError(err) + p = self._p(dest) + if not os.path.isfile(src): + raise DeployError(f"source file missing: {src}") + rel_parent = str(PurePosixPath(dest).parent) + if rel_parent != ".": + self._mkdirs(rel_parent, dirs, txn) + if not self._inside(p.parent): + raise DeployError(f"{dest} would land outside the game directory") + backup = carry_backup or "" + if os.path.lexists(p): + bak = self.paths.backup_dir / dest + if os.path.lexists(bak): + raise DeployError(f"stale backup exists: {bak}") + os.makedirs(bak.parent, exist_ok=True) + shutil.move(str(p), str(bak)) + txn.undo.append(lambda: shutil.move(str(bak), str(p))) + backup = dest + mode = self._link(src, p, txn) + rec = {"mod": a.item.mod, "src": str(src), "mode": mode, "kind": a.item.kind, "backup": backup} + if mode != "symlink": + st = os.stat(p) + rec.update(size=st.st_size, mtime_ns=st.st_mtime_ns, ino=st.st_ino, dev=st.st_dev) + return rec + + def _link(self, src: Path, dest: Path, txn: _Txn) -> str: + mode = self.link_mode + src_abs = os.path.abspath(src) + if mode == "symlink": + os.symlink(src_abs, dest) + elif mode == "hardlink": + try: + os.link(src_abs, dest) + except OSError: + shutil.copy2(src_abs, dest) + mode = "copy" + else: + shutil.copy2(src_abs, dest) + txn.undo.append(lambda: os.unlink(dest)) + return mode + + def _prune_dirs(self, m: dict) -> None: + """Remove directories we created that are now empty (deepest first).""" + keep = [] + for rel in sorted(m["dirs"], key=lambda r: -r.count("/")): + p = self._p(rel) + if not os.path.isdir(p): + continue + try: + p.rmdir() + except OSError: + keep.append(rel) + if len(keep) != len(m["dirs"]): + m["dirs"] = sorted(keep) + self._save_manifest(m) diff --git a/src/mhwmm/core/desktop.py b/src/mhwmm/core/desktop.py new file mode 100644 index 0000000..ed95557 --- /dev/null +++ b/src/mhwmm/core/desktop.py @@ -0,0 +1,44 @@ +"""Desktop entry + x-scheme-handler/nxm registration (user-level only).""" +from __future__ import annotations + +import os +import shlex +import shutil +import subprocess +from pathlib import Path + +from .. import APP_ID +from .util import atomic_write_text + +DESKTOP_NAME = f"{APP_ID}.desktop" + + +def desktop_entry(exec_path: str) -> str: + return ( + "[Desktop Entry]\n" + "Type=Application\n" + "Name=MH Wilds Mod Manager\n" + "Comment=Manage mods for Monster Hunter Wilds\n" + f"Exec={shlex.quote(exec_path)} %u\n" + "Icon=applications-games\n" + "Terminal=false\n" + "Categories=Game;Utility;\n" + "MimeType=x-scheme-handler/nxm;\n" + "StartupNotify=true\n" + ) + + +def applications_dir() -> Path: + base = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local/share") + return base / "applications" + + +def register_nxm_handler(exec_path: str) -> Path: + """Write the .desktop file and make it the default nxm:// handler. Returns the file path.""" + target = applications_dir() / DESKTOP_NAME + atomic_write_text(target, desktop_entry(exec_path), 0o644) + for cmd in (["update-desktop-database", str(target.parent)], + ["xdg-mime", "default", DESKTOP_NAME, "x-scheme-handler/nxm"]): + if shutil.which(cmd[0]): + subprocess.run(cmd, check=False, capture_output=True, timeout=30) + return target diff --git a/src/mhwmm/core/layout.py b/src/mhwmm/core/layout.py new file mode 100644 index 0000000..b3bba2b --- /dev/null +++ b/src/mhwmm/core/layout.py @@ -0,0 +1,110 @@ +"""Classify an extracted mod tree into deployment entries. + +Mods are packaged inconsistently, so the payload root is discovered, not assumed. +""" +from __future__ import annotations + +import os +import re +from dataclasses import dataclass, field +from pathlib import Path + +LOOSE, REFRAMEWORK, PAK, ROOT = "loose", "reframework", "pak", "root" + +_JUNK = {".ini", ".txt", ".md", ".png", ".jpg", ".jpeg", ".webp", ".pdf", ".url", ".rtf"} +_SUB_CHAIN = re.compile(r"\.sub_000\.", re.I) +PAK_DEPTH = 2 + + +@dataclass(frozen=True) +class Entry: + kind: str + src: Path + dest: str # posix path relative to the game dir; "" for PAKs (slot assigned later) + chain: str = "" # PAKs only: "main" or "sub" + + +@dataclass +class Classification: + entries: list[Entry] = field(default_factory=list) + ambiguous: list[Path] = field(default_factory=list) # root-level .dll needing a user decision + notes: list[str] = field(default_factory=list) + + @property + def empty(self) -> bool: + return not self.entries and not self.ambiguous + + +def pak_chain(name: str) -> str: + return "sub" if _SUB_CHAIN.search(name) else "main" + + +def _effective_root(root: Path) -> Path: + """Descend through single wrapper directories (Mod/Mod/natives/...).""" + while True: + kids = [p for p in root.iterdir() if not p.is_symlink()] + dirs = [p for p in kids if p.is_dir() and not p.name.startswith("__MACOSX")] + files = [p for p in kids if p.is_file()] + if len(dirs) == 1 and dirs[0].name.lower() not in ("natives", "reframework", "stm") \ + and all(f.suffix.lower() in _JUNK for f in files): + root = dirs[0] + else: + return root + + +def _tree(base: Path, prefix: str, kind: str, out: Classification, skip_data: bool = False) -> None: + for dirpath, dirnames, filenames in os.walk(base): + rel_dir = Path(dirpath).relative_to(base) + if skip_data and not rel_dir.parts: + if any(d.lower() == "data" for d in dirnames): + out.notes.append("Ignored reframework/data (user settings are never managed)") + dirnames[:] = [d for d in dirnames if d.lower() != "data"] + dirnames[:] = sorted(d for d in dirnames if not os.path.islink(os.path.join(dirpath, d))) + for f in sorted(filenames): + p = Path(dirpath) / f + if p.is_symlink(): + continue + out.entries.append(Entry(kind, p, "/".join([prefix, *rel_dir.parts, f]))) + + +def classify(root: Path, dll_choice: str | None = None) -> Classification: + """dll_choice for root-level .dll files: 'plugin' | 'root' | 'skip' | None (=> ambiguous).""" + root = Path(root) + out = Classification() + top = _effective_root(root) + + def walk(d: Path, depth: int) -> None: + for e in sorted(d.iterdir(), key=lambda p: p.name.lower()): + if e.is_symlink(): + continue + low = e.name.lower() + if e.is_dir(): + if low.startswith("__macosx"): + continue + if low == "natives": + _tree(e, "natives", LOOSE, out) + elif low == "reframework": + _tree(e, "reframework", REFRAMEWORK, out, skip_data=True) + elif low == "stm": + _tree(e, "natives/STM", LOOSE, out) + elif depth < PAK_DEPTH: + walk(e, depth + 1) + elif e.is_file(): + if low.endswith(".pak") and depth <= PAK_DEPTH: + out.entries.append(Entry(PAK, e, "", pak_chain(e.name))) + elif depth == 0 and low.endswith(".lua"): + out.entries.append(Entry(REFRAMEWORK, e, f"reframework/autorun/{e.name}")) + elif depth == 0 and low.endswith(".dll"): + if dll_choice == "plugin": + out.entries.append(Entry(REFRAMEWORK, e, f"reframework/plugins/{e.name}")) + elif dll_choice == "root": + out.entries.append(Entry(ROOT, e, e.name)) + elif dll_choice != "skip": + if "reframework" in low or "d2d" in low: + out.entries.append(Entry(REFRAMEWORK, e, f"reframework/plugins/{e.name}")) + else: + out.ambiguous.append(e) + + walk(top, 0) + # the PAK order inside one mod follows its own patch numbers, then name + return out diff --git a/src/mhwmm/core/library.py b/src/mhwmm/core/library.py new file mode 100644 index 0000000..cc6aa75 --- /dev/null +++ b/src/mhwmm/core/library.py @@ -0,0 +1,163 @@ +"""The mod library: import, scan, rename, delete. Never touches the game directory.""" +from __future__ import annotations + +import os +import re +import shutil +import time +import uuid +from dataclasses import asdict, dataclass, field +from pathlib import Path + +from . import archive, layout, modinfo +from .paths import Paths +from .util import atomic_write_json, is_within, read_json, slugify + + +class LibraryError(Exception): + pass + + +@dataclass +class Mod: + id: str + name: str + version: str = "" + author: str = "" + description: str = "" + category: str = "" + screenshot: str = "" # relative to files/ + imported_at: float = 0.0 + source: str = "" # original archive name + dll_choice: str = "" # "", "plugin", "root", "skip" + nexus_mod_id: str = "" + nexus_file_id: str = "" + nexus_url: str = "" + root: Path = field(default=Path(), compare=False, repr=False) # mods/ + + @property + def files_root(self) -> Path: + return self.root / "files" + + @property + def screenshot_path(self) -> Path | None: + if not self.screenshot: + return None + p = self.files_root / self.screenshot + return p if p.is_file() else None + + def classification(self) -> layout.Classification: + return layout.classify(self.files_root, self.dll_choice or None) + + def to_meta(self) -> dict: + d = asdict(self) + d.pop("root") + return d + + +class Library: + def __init__(self, paths: Paths): + self.paths = paths + self.mods: dict[str, Mod] = {} + + # -- loading ---------------------------------------------------------- + def scan(self) -> None: + self.mods.clear() + base = self.paths.mods_dir + if not base.is_dir(): + return + for d in sorted(base.iterdir()): + meta = read_json(d / "meta.json", None) if d.is_dir() and not d.name.startswith(".") else None + if not isinstance(meta, dict) or not (d / "files").is_dir(): + continue + known = Mod.__dataclass_fields__.keys() - {"root"} + mod = Mod(**{k: v for k, v in meta.items() if k in known}) + mod.id = d.name + mod.root = d + self.mods[mod.id] = mod + + def get(self, mod_id: str) -> Mod | None: + return self.mods.get(mod_id) + + def sorted_mods(self) -> list[Mod]: + return sorted(self.mods.values(), key=lambda m: m.name.lower()) + + # -- import ----------------------------------------------------------- + def _unique_id(self, name: str) -> str: + base = slugify(name) + cand, n = base, 2 + while (self.paths.mods_dir / cand).exists(): + cand = f"{base}-{n}" + n += 1 + return cand + + def import_source(self, source: Path, nexus: dict | None = None) -> Mod: + """Import an archive file or an extracted folder. `nexus` may prefill metadata.""" + source = Path(source) + self.paths.mods_dir.mkdir(parents=True, exist_ok=True) + tmp = self.paths.mods_dir / f".incoming-{uuid.uuid4().hex[:8]}" + try: + tmp.mkdir() + try: + if source.is_dir(): + archive.copy_tree(source, tmp) + elif source.suffix.lower() in (".pak", ".dll", ".lua"): + shutil.copy2(source, tmp / source.name) + else: + archive.extract(source, tmp) + except archive.ArchiveError as e: + raise LibraryError(str(e)) from e + cls = layout.classify(tmp) + if cls.empty: + raise LibraryError("Nothing recognisable to install (no natives/, reframework/, .pak or .lua files).") + info, info_dir = modinfo.read_modinfo(tmp) + nexus = nexus or {} + stem = re.sub(r"[-_ ]?\d+-\d+(-\d+)*(-\d+)?$", "", source.stem) if not source.is_dir() else source.name + name = info.name or nexus.get("name") or stem or "Unnamed mod" + mod_id = self._unique_id(name) + shot = modinfo.find_screenshot(info_dir or tmp, info.screenshot) + shot_rel = os.path.relpath(shot, tmp) if shot else "" + mod = Mod( + id=mod_id, name=name, + version=info.version or str(nexus.get("version") or ""), + author=info.author or str(nexus.get("author") or ""), + description=info.description or str(nexus.get("summary") or ""), + category=info.category, + screenshot=shot_rel, imported_at=time.time(), source=source.name, + nexus_mod_id=info.nexus_mod_id or str(nexus.get("mod_id") or ""), + nexus_file_id=str(nexus.get("file_id") or ""), + nexus_url=str(nexus.get("url") or ""), + ) + dest = self.paths.mods_dir / mod_id + dest.mkdir() + os.rename(tmp, dest / "files") + mod.root = dest + self.save(mod) + self.mods[mod_id] = mod + return mod + finally: + shutil.rmtree(tmp, ignore_errors=True) + + def save(self, mod: Mod) -> None: + atomic_write_json(mod.root / "meta.json", mod.to_meta()) + + # -- edits ------------------------------------------------------------ + def update(self, mod_id: str, **fields) -> Mod: + mod = self.mods[mod_id] + for k, v in fields.items(): + if k in ("name", "category", "version", "author", "description", "dll_choice", "screenshot"): + setattr(mod, k, v) + self.save(mod) + return mod + + def delete(self, mod_id: str) -> None: + mod = self.mods.get(mod_id) + if mod is None: + return + if not is_within(mod.root, self.paths.mods_dir) or mod.root.resolve() == self.paths.mods_dir.resolve(): + raise LibraryError("refusing to delete outside the library") + shutil.rmtree(mod.root) + del self.mods[mod_id] + + def categories(self) -> list[str]: + return sorted({m.category for m in self.mods.values() if m.category}, key=str.lower) diff --git a/src/mhwmm/core/loader.py b/src/mhwmm/core/loader.py new file mode 100644 index 0000000..cebb88e --- /dev/null +++ b/src/mhwmm/core/loader.py @@ -0,0 +1,136 @@ +"""REFramework setup: dinput8.dll, re2_fw_config.txt and the Proton launch option.""" +from __future__ import annotations + +import io +import json +import re +import urllib.request +import zipfile +from dataclasses import dataclass, field +from pathlib import Path +from typing import Callable + +from . import steam +from .util import atomic_write_bytes, atomic_write_text + +# The filename keeps its RE2 origin across all RE Engine games. +CONFIG_NAME = "re2_fw_config.txt" +LOOSE_KEY = "LooseFileLoader_Enabled" +RELEASES_API = "https://api.github.com/repos/praydog/REFramework-nightly/releases/latest" +ASSET_NAME = "REFramework.zip" +MAX_DOWNLOAD = 200 * 1024 * 1024 + + +@dataclass +class Check: + label: str + ok: bool + detail: str = "" + + +@dataclass +class LoaderStatus: + checks: list[Check] = field(default_factory=list) + launch_options: str | None = None + suggested_launch_options: str = steam.REQUIRED_LAUNCH_OPTIONS + + @property + def all_ok(self) -> bool: + return all(c.ok for c in self.checks) + + +# -- config file ------------------------------------------------------------------ + +def read_config_value(config: Path, key: str) -> str | None: + try: + text = config.read_text(encoding="utf-8", errors="replace") + except OSError: + return None + for line in text.splitlines(): + k, sep, v = line.partition("=") + if sep and k.strip().lower() == key.lower(): + return v.strip() + return None + + +def set_config_value(config: Path, key: str, value: str) -> None: + """Key-wise patch: keep every unrelated line, create the file if absent.""" + try: + text = config.read_text(encoding="utf-8") + except FileNotFoundError: + text = "" + nl = "\r\n" if "\r\n" in text else "\n" + lines = text.splitlines() + for i, line in enumerate(lines): + k, sep, _ = line.partition("=") + if sep and k.strip().lower() == key.lower(): + lines[i] = f"{k.strip()}={value}" + break + else: + lines.append(f"{key}={value}") + atomic_write_text(config, nl.join(lines) + nl) + + +def loose_loader_enabled(game_dir: Path) -> bool: + return (read_config_value(game_dir / CONFIG_NAME, LOOSE_KEY) or "").lower() == "true" + + +def enable_loose_loader(game_dir: Path) -> None: + set_config_value(game_dir / CONFIG_NAME, LOOSE_KEY, "true") + + +# -- status ------------------------------------------------------------------------- + +def status(game_dir: Path, launch_options: str | None) -> LoaderStatus: + st = LoaderStatus(launch_options=launch_options, + suggested_launch_options=steam.suggest_launch_options(launch_options)) + has_dll = (game_dir / "dinput8.dll").is_file() + st.checks.append(Check("REFramework (dinput8.dll) installed", has_dll, + "" if has_dll else "dinput8.dll not found in the game folder")) + loose = loose_loader_enabled(game_dir) + st.checks.append(Check(f"{LOOSE_KEY}=true in {CONFIG_NAME}", loose, + "" if loose else "Must be set before launching the game")) + lo = steam.launch_options_ok(launch_options) + st.checks.append(Check("Steam launch option overrides dinput8", lo, + "" if lo else "Proton needs WINEDLLOVERRIDES to load dinput8.dll")) + return st + + +# -- install ------------------------------------------------------------------------ + +Fetch = Callable[[str], bytes] + + +def _default_fetch(url: str) -> bytes: + req = urllib.request.Request(url, headers={"User-Agent": "mhwilds-mod-manager", + "Accept": "application/vnd.github+json"}) + with urllib.request.urlopen(req, timeout=60) as r: + data = r.read(MAX_DOWNLOAD + 1) + if len(data) > MAX_DOWNLOAD: + raise RuntimeError("download is unexpectedly large") + return data + + +def install_reframework(game_dir: Path, fetch: Fetch = _default_fetch, backup_dir: Path | None = None) -> str: + """Download the latest nightly MHWILDS.zip and extract dinput8.dll. Returns the release tag.""" + release = json.loads(fetch(RELEASES_API)) + asset = next((a for a in release.get("assets", []) if a.get("name") == ASSET_NAME), None) + if not asset: + raise RuntimeError(f"latest REFramework release has no {ASSET_NAME}") + blob = fetch(asset["browser_download_url"]) + with zipfile.ZipFile(io.BytesIO(blob)) as zf: + member = next((n for n in zf.namelist() if n.lower() == "dinput8.dll"), None) + if member is None: + raise RuntimeError("dinput8.dll not found in the release archive") + dll = zf.read(member) + target = game_dir / "dinput8.dll" + if target.exists() and backup_dir is not None: + backup_dir.mkdir(parents=True, exist_ok=True) + (backup_dir / "dinput8.dll.prev").write_bytes(target.read_bytes()) + atomic_write_bytes(target, dll, 0o755) + return str(release.get("tag_name", "")) + + +def setup_all(game_dir: Path) -> None: + """Everything except the Steam launch option (that is verify-only by default).""" + enable_loose_loader(game_dir) diff --git a/src/mhwmm/core/manager.py b/src/mhwmm/core/manager.py new file mode 100644 index 0000000..73beb83 --- /dev/null +++ b/src/mhwmm/core/manager.py @@ -0,0 +1,269 @@ +"""Facade tying library, profiles, deploy engine and PAK slotting together. + +The UI talks only to this class; it has no GTK imports so it can be driven headlessly. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path + +from . import loader, paks, steam +from .config import Settings +from .deploy import Conflict, DeployError, DeployMod, Deployer, Desired, Plan, ApplyResult, build_desired +from .layout import PAK, Classification +from .library import Library, Mod +from .paths import Paths +from .profiles import Profiles + + +@dataclass +class StartupReport: + game_found: bool = False + notes: list[str] = field(default_factory=list) + + +class Manager: + def __init__(self, paths: Paths | None = None, settings: Settings | None = None): + self.paths = paths or Paths.default() + self.paths.ensure() + self.settings = settings or Settings.load(self.paths.settings_file) + self.library = Library(self.paths) + self.profiles = Profiles(self.paths.profiles_file) + self.game_dir: Path | None = None + self.install: steam.GameInstall | None = None + self.deployer: Deployer | None = None + self.baseline: paks.Baseline | None = None + self._cls_cache: dict[str, Classification] = {} + self.last_desired: Desired = Desired() + self.reload_library() + + # -- discovery ------------------------------------------------------------ + def discover_game(self) -> Path | None: + if self.settings.game_dir: + p = Path(self.settings.game_dir) + self.install = None + return p if p.is_dir() else None + self.install = steam.find_app() + return self.install.game_dir if self.install else None + + def bind_game(self, game_dir: Path | None) -> bool: + self.game_dir = game_dir + self.deployer = Deployer(game_dir, self.paths, self.settings.link_mode) if game_dir else None + return self.deployer is not None + + def set_game_dir(self, path: str) -> None: + self.settings.game_dir = path + self.settings.save(self.paths.settings_file) + + def apply_settings(self) -> None: + self.settings.save(self.paths.settings_file) + if self.deployer: + self.deployer.link_mode = self.settings.link_mode + + def steam_root(self) -> Path | None: + if self.install: + return self.install.steam_root + roots = steam.steam_roots() + return roots[0] if roots else None + + # -- startup ---------------------------------------------------------------- + def startup(self) -> StartupReport: + rep = StartupReport() + self.reload_library() + if not self.bind_game(self.discover_game()): + return rep + rep.game_found = True + assert self.deployer and self.game_dir + self.baseline = paks.load_baseline(self.paths.baseline_file) + if self.baseline is None: + self.baseline = paks.capture_baseline(self.game_dir, self.deployer.owned_paks()) + paks.save_baseline(self.paths.baseline_file, self.baseline) + note = self._handle_game_update() + try: + self.deploy() + except DeployError as e: + rep.notes.append(str(e)) + if note: + rep.notes.insert(0, self._update_message(note)) + return rep + + def _handle_game_update(self) -> bool: + """If Steam patched the game: drop our PAKs, re-derive the ceiling. Returns True if it happened.""" + assert self.deployer and self.game_dir and self.baseline + report = paks.detect_update(self.game_dir, self.baseline, self.deployer.owned_paks()) + if not report.updated: + return False + keep = self.deployer.desired_from_manifest(exclude_kind=PAK) + self.deployer.apply(self.deployer.plan(keep)) + self.baseline = paks.capture_baseline(self.game_dir, self.deployer.owned_paks()) + paks.save_baseline(self.paths.baseline_file, self.baseline) + return True + + def _update_message(self, _: bool) -> str: + assert self.baseline + first = self.baseline.ceiling("main") + 1 + if any(e.kind == PAK for m in self._enabled_mods() for e in m.entries): + return f"Game updated — mod PAKs re-slotted to {first:03d}+" + return f"Game updated — vanilla PAK ceiling is now {first - 1:03d}" + + # -- library ----------------------------------------------------------------- + def reload_library(self) -> None: + self.library.scan() + self._cls_cache.clear() + self.profiles.reconcile([m.id for m in self.library.sorted_mods()]) + self.profiles.save() + + def import_source(self, source: Path, nexus: dict | None = None) -> Mod: + mod = self.library.import_source(source, nexus) + self.profiles.reconcile(list(self.library.mods)) # new mod is appended to every profile's order + self.profiles.save() + return mod + + def classification(self, mod: Mod) -> Classification: + c = self._cls_cache.get(mod.id) + if c is None: + c = self._cls_cache[mod.id] = mod.classification() + return c + + def update_mod(self, mod_id: str, **fields) -> Mod: + mod = self.library.update(mod_id, **fields) + self._cls_cache.pop(mod_id, None) + return mod + + def delete_mod(self, mod_id: str) -> None: + if self.profiles.is_enabled(mod_id) or any(mod_id in p["enabled"] for p in self.profiles.data.values()): + self.profiles.set_enabled(mod_id, False) + if self.deployer: + self.deploy() + self.library.delete(mod_id) + self.profiles.remove_mod(mod_id) + self.profiles.save() + self._cls_cache.pop(mod_id, None) + + # -- state ------------------------------------------------------------------- + def _enabled_mods(self) -> list[DeployMod]: + out = [] + for mid in self.profiles.enabled_in_order(): + mod = self.library.get(mid) + if mod: + out.append(DeployMod(mid, tuple(self.classification(mod).entries))) + return out + + def desired(self) -> Desired: + if self.baseline is None: + raise DeployError("Game not set up yet") + self.last_desired = build_desired(self._enabled_mods(), self.baseline) + return self.last_desired + + def preview(self) -> Plan: + if not self.deployer: + raise DeployError("Game not found") + return self.deployer.plan(self.desired()) + + def deploy(self) -> ApplyResult: + if not self.deployer: + raise DeployError("Game not found") + return self.deployer.apply(self.deployer.plan(self.desired())) + + def undeploy_all(self) -> ApplyResult: + if not self.deployer: + raise DeployError("Game not found") + return self.deployer.undeploy_all() + + def set_enabled(self, mod_id: str, on: bool) -> ApplyResult | None: + self.profiles.set_enabled(mod_id, on) + self.profiles.save() + return self.deploy() if self.settings.auto_deploy and self.deployer else None + + def move(self, mod_id: str, new_index: int) -> ApplyResult | None: + self.profiles.move(mod_id, new_index) + self.profiles.save() + return self.deploy() if self.settings.auto_deploy and self.deployer else None + + def switch_profile(self, name: str) -> ApplyResult | None: + self.profiles.switch(name) + self.profiles.save() + return self.deploy() if self.deployer else None + + def conflicts(self) -> list[Conflict]: + return self.last_desired.conflicts if self.baseline else [] + + def conflicts_for(self, mod_id: str) -> list[Conflict]: + return [c for c in self.conflicts() if mod_id in c.mods] + + def pending(self) -> bool: + """True if what is on disk differs from what the profile asks for.""" + return bool(self.deployer and self.baseline and self.preview().changes) + + # -- PAK info for the UI --------------------------------------------------------- + def pak_summary(self) -> dict[str, dict]: + """chain -> {ceiling, count, mod_slots}""" + if not self.game_dir: + return {} + scan = paks.scan_game_paks(self.game_dir) + owned = self.deployer.owned_paks() if self.deployer else set() + out = {} + for chain in paks.CHAINS: + vanilla = [n for n, i in scan[chain].items() if i["name"] not in owned] + out[chain] = {"ceiling": max(vanilla, default=0), + "baseline_ceiling": self.baseline.ceiling(chain) if self.baseline else 0, + "vanilla": len(vanilla), + "mod_slots": sorted(n for n, i in scan[chain].items() if i["name"] in owned)} + return out + + # -- loader ---------------------------------------------------------------------- + def launch_options(self) -> str | None: + root = self.steam_root() + lc = steam.localconfig_path(root, self.settings.steam_user_id) if root else None + return steam.read_launch_options(lc) if lc else None + + def loader_status(self) -> loader.LoaderStatus: + assert self.game_dir + return loader.status(self.game_dir, self.launch_options()) + + def install_reframework(self) -> str: + import time + from .library import Mod + assert self.game_dir + + mod_id = "reframework" + mod_dir = self.paths.mods_dir / mod_id + files_dir = mod_dir / "files" + files_dir.mkdir(parents=True, exist_ok=True) + + tag = loader.install_reframework(files_dir, backup_dir=self.paths.backup_dir) + + if mod_id not in self.library.mods: + mod = Mod( + id=mod_id, + name="REFramework", + version=tag, + author="praydog", + description="RE Engine Modding Framework", + category="Core", + imported_at=time.time(), + source="REFramework.zip", + dll_choice="root", + ) + mod.root = mod_dir + self.library.save(mod) + self.library.mods[mod_id] = mod + self.profiles.reconcile(list(self.library.mods.keys())) + self.profiles.set_enabled(mod_id, True) + self.profiles.save() + else: + self.library.update(mod_id, version=tag, dll_choice="root") + + self.deploy() + + self.settings.reframework_version = tag + self.apply_settings() + loader.setup_all(self.game_dir) + return tag + + def write_launch_options(self, value: str) -> Path: + root = self.steam_root() + lc = steam.localconfig_path(root, self.settings.steam_user_id) if root else None + if lc is None: + raise steam.VDFError("Steam user data not found") + return steam.write_launch_options(lc, value) diff --git a/src/mhwmm/core/modinfo.py b/src/mhwmm/core/modinfo.py new file mode 100644 index 0000000..4b49852 --- /dev/null +++ b/src/mhwmm/core/modinfo.py @@ -0,0 +1,83 @@ +"""Fluffy-compatible modinfo.ini and screenshot discovery.""" +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".webp") + + +@dataclass +class ModInfo: + name: str = "" + version: str = "" + author: str = "" + description: str = "" + category: str = "" + screenshot: str = "" # as written in the ini (relative) + nexus_mod_id: str = "" + + +def parse_modinfo(text: str) -> ModInfo: + """Tolerant key=value parser; Fluffy files have no section header, but accept one.""" + vals: dict[str, str] = {} + for line in text.splitlines(): + line = line.strip() + if not line or line[0] in ";#[" or "=" not in line: + continue + k, _, v = line.partition("=") + vals[k.strip().lower()] = v.strip() + return ModInfo( + name=vals.get("name", ""), + version=vals.get("version", ""), + author=vals.get("author", ""), + description=vals.get("description", "").replace("\\n", "\n"), + category=vals.get("category", ""), + screenshot=vals.get("screenshot", ""), + nexus_mod_id=vals.get("nexusid", vals.get("nexus_mod_id", "")), + ) + + +def _read_text(path: Path) -> str: + raw = path.read_bytes() + for enc in ("utf-8-sig", "utf-16", "latin-1"): + try: + return raw.decode(enc) + except UnicodeError: + continue + return raw.decode("utf-8", "replace") + + +def find_modinfo(root: Path) -> Path | None: + """modinfo.ini at the root or one directory down.""" + for base in [root, *sorted(p for p in root.iterdir() if p.is_dir())]: + for p in base.iterdir(): + if p.is_file() and p.name.lower() == "modinfo.ini": + return p + return None + + +def read_modinfo(root: Path) -> tuple[ModInfo, Path | None]: + p = find_modinfo(root) + if p is None: + return ModInfo(), None + try: + return parse_modinfo(_read_text(p)), p.parent + except OSError: + return ModInfo(), None + + +def find_screenshot(base: Path, hint: str = "") -> Path | None: + """Explicit `screenshot=` wins, else screenshot.png|jpg in the mod root (Fluffy convention).""" + if not base.is_dir(): + return None + files = {p.name.lower(): p for p in base.iterdir() if p.is_file()} + if hint: + p = files.get(Path(hint).name.lower()) + if p: + return p + for ext in IMAGE_EXTS: + p = files.get("screenshot" + ext) + if p: + return p + return None diff --git a/src/mhwmm/core/nexus.py b/src/mhwmm/core/nexus.py new file mode 100644 index 0000000..13b497b --- /dev/null +++ b/src/mhwmm/core/nexus.py @@ -0,0 +1,213 @@ +"""nxm:// handling and the Nexus Mods API client.""" +from __future__ import annotations + +import json +import os +import re +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass, field +from pathlib import Path +from typing import Callable + +from .. import __version__ +from .util import atomic_write_json, read_json + +GAME_DOMAIN = "monsterhunterwilds" +API = "https://api.nexusmods.com" +_SECRET_ATTRS = {"application": "mhwilds-mod-manager", "kind": "nexus-api-key"} + + +class NexusError(Exception): + pass + + +@dataclass +class NxmLink: + game: str + mod_id: int + file_id: int + key: str = "" + expires: str = "" + + @property + def is_premium_flow(self) -> bool: + return not self.key + + +def parse_nxm(url: str) -> NxmLink: + """nxm://monsterhunterwilds/mods//files/?key=…&expires=… (key/expires pass through).""" + u = urllib.parse.urlparse(url.strip()) + if u.scheme != "nxm": + raise NexusError(f"not an nxm:// link: {url}") + m = re.fullmatch(r"/mods/(\d+)/files/(\d+)", u.path) + if not u.netloc or not m: + raise NexusError(f"malformed nxm link: {url}") + q = urllib.parse.parse_qs(u.query) + return NxmLink(u.netloc.lower(), int(m.group(1)), int(m.group(2)), + q.get("key", [""])[0], q.get("expires", [""])[0]) + + +# -- API key storage ----------------------------------------------------------------- + +def _secret_mod(): + try: + import gi + gi.require_version("Secret", "1") + from gi.repository import Secret + return Secret + except (ImportError, ValueError): + return None + + +class KeyStore: + """libsecret when importable, else a 0600 JSON file.""" + + def __init__(self, fallback_file: Path): + self.file = fallback_file + + def _schema(self, Secret): + return Secret.Schema.new("ca.schantli.MHWildsModManager", Secret.SchemaFlags.NONE, + {"application": Secret.SchemaAttributeType.STRING, + "kind": Secret.SchemaAttributeType.STRING}) + + def get(self) -> str: + Secret = _secret_mod() + if Secret: + try: + v = Secret.password_lookup_sync(self._schema(Secret), _SECRET_ATTRS, None) + if v: + return v + except Exception: + pass + return str(read_json(self.file, {}).get("nexus_api_key", "")) + + def set(self, key: str) -> None: + Secret = _secret_mod() + if Secret: + try: + if Secret.password_store_sync(self._schema(Secret), _SECRET_ATTRS, Secret.COLLECTION_DEFAULT, + "Nexus Mods API key", key, None): + if self.file.exists(): + self.file.unlink() + return + except Exception: + pass + atomic_write_json(self.file, {"nexus_api_key": key}, mode=0o600) + + +# -- client ---------------------------------------------------------------------------- + +@dataclass +class RateLimits: + daily_remaining: str = "" + daily_limit: str = "" + hourly_remaining: str = "" + hourly_limit: str = "" + + def summary(self) -> str: + if not (self.daily_remaining or self.hourly_remaining): + return "" + return f"API quota: {self.hourly_remaining}/{self.hourly_limit} hourly, {self.daily_remaining}/{self.daily_limit} daily" + + +Opener = Callable[[urllib.request.Request], "object"] + + +class NexusClient: + def __init__(self, api_key: str, opener: Opener | None = None): + self.api_key = api_key + self._open = opener or (lambda req: urllib.request.urlopen(req, timeout=60)) + self.limits = RateLimits() + + def _headers(self) -> dict[str, str]: + return {"apikey": self.api_key, "Application-Name": "MHWildsModManager", + "Application-Version": __version__, "User-Agent": f"mhwilds-mod-manager/{__version__}", + "Accept": "application/json"} + + def _get_json(self, path: str, params: dict[str, str] | None = None): + if not self.api_key: + raise NexusError("No Nexus API key set (Preferences → Nexus Mods).") + url = f"{API}{path}" + (f"?{urllib.parse.urlencode(params)}" if params else "") + try: + resp = self._open(urllib.request.Request(url, headers=self._headers())) + self._note_limits(resp.headers) + return json.loads(resp.read()) + except urllib.error.HTTPError as e: + self._note_limits(e.headers) + msg = {401: "Invalid Nexus API key.", 403: "Nexus refused the download (premium required or link expired).", + 404: "Not found on Nexus.", 429: "Nexus rate limit reached; try again later."}.get(e.code, f"Nexus HTTP {e.code}") + raise NexusError(msg) from e + except (urllib.error.URLError, OSError) as e: + raise NexusError(f"Network error: {e}") from e + + def _note_limits(self, headers) -> None: + if headers is None: + return + g = headers.get + self.limits = RateLimits(g("x-rl-daily-remaining", ""), g("x-rl-daily-limit", ""), + g("x-rl-hourly-remaining", ""), g("x-rl-hourly-limit", "")) + + def download_links(self, link: NxmLink) -> list[dict]: + params = {} + if link.key: # what lets non-premium accounts download + params = {"key": link.key, "expires": link.expires} + return self._get_json(f"/v1/games/{link.game}/mods/{link.mod_id}/files/{link.file_id}/download_link.json", + params) + + def mod_info(self, link: NxmLink) -> dict: + return self._get_json(f"/v1/games/{link.game}/mods/{link.mod_id}.json") + + +def prefill(link: NxmLink, info: dict) -> dict: + return {"name": info.get("name", ""), "version": info.get("version", ""), "author": info.get("author", ""), + "summary": info.get("summary", ""), "picture_url": info.get("picture_url", ""), + "mod_id": link.mod_id, "file_id": link.file_id, + "url": f"https://www.nexusmods.com/{link.game}/mods/{link.mod_id}"} + + +def download(url: str, dest_dir: Path, progress: Callable[[int, int], None] | None = None, + opener: Opener | None = None) -> Path: + """Stream a CDN URL to dest_dir; the file name comes from the URL path.""" + url = urllib.parse.quote(url, safe=":/&?=%") + u = urllib.parse.urlparse(url) + if u.scheme != "https": + raise NexusError("refusing non-HTTPS download") + name = os.path.basename(urllib.parse.unquote(u.path)) or "download.bin" + name = re.sub(r"[^\w.\- ]", "_", name).lstrip(".") or "download.bin" + dest_dir.mkdir(parents=True, exist_ok=True) + dest = dest_dir / name + part = dest.with_name(dest.name + ".part") + req = urllib.request.Request(url, headers={"User-Agent": f"mhwilds-mod-manager/{__version__}"}) + try: + resp = (opener or (lambda r: urllib.request.urlopen(r, timeout=60)))(req) + total = int(resp.headers.get("Content-Length") or 0) + done = 0 + with open(part, "wb") as fh: + while chunk := resp.read(1 << 20): + fh.write(chunk) + done += len(chunk) + if progress: + progress(done, total) + os.replace(part, dest) + except (urllib.error.URLError, OSError) as e: + part.unlink(missing_ok=True) + raise NexusError(f"Download failed: {e}") from e + return dest + + +def fetch_thumbnail(url: str, dest: Path, opener: Opener | None = None, limit: int = 8 * 1024 * 1024) -> bool: + """Best-effort mod picture download; never raises.""" + if not url.startswith("https://"): + return False + try: + req = urllib.request.Request(url, headers={"User-Agent": f"mhwilds-mod-manager/{__version__}"}) + resp = (opener or (lambda r: urllib.request.urlopen(r, timeout=30)))(req) + data = resp.read(limit + 1) + if len(data) > limit or not data: + return False + dest.write_bytes(data) + return True + except (urllib.error.URLError, OSError): + return False diff --git a/src/mhwmm/core/paks.py b/src/mhwmm/core/paks.py new file mode 100644 index 0000000..cb0f1aa --- /dev/null +++ b/src/mhwmm/core/paks.py @@ -0,0 +1,217 @@ +"""Vanilla PAK baseline, mod slot allocation and title-update detection. + +Wilds has two parallel patch chains. Mods must sit *above* the vanilla ceiling of +their chain, and the ceiling rises with every title update, so slots are derived +rather than fixed. +""" +from __future__ import annotations + +import os +import re +import struct +import time +from dataclasses import dataclass, field +from pathlib import Path + +from .layout import PAK, Entry +from .util import atomic_write_json, read_json + +PAK_RE = re.compile(r"^re_chunk_000\.pak(?P\.sub_000\.pak)?\.patch_(?P\d{3})\.pak$") +CHAINS = ("main", "sub") +_BASES = {"main": "re_chunk_000.pak", "sub": "re_chunk_000.pak.sub_000.pak"} + + +def pak_name(chain: str, n: int) -> str: + return f"{_BASES[chain]}.patch_{n:03d}.pak" + + +def parse_pak_name(name: str) -> tuple[str, int] | None: + m = PAK_RE.match(name) + if not m: + return None + return ("sub" if m.group("sub") else "main"), int(m.group("n")) + + +def scan_game_paks(game_dir: Path) -> dict[str, dict[int, dict]]: + """chain -> patch number -> {name,size,mtime_ns}. Only real patch files are considered.""" + out: dict[str, dict[int, dict]] = {c: {} for c in CHAINS} + try: + it = list(os.scandir(game_dir)) + except OSError: + return out + for e in it: + parsed = parse_pak_name(e.name) + if parsed is None: + continue + try: + st = e.stat() + except OSError: + continue + out[parsed[0]][parsed[1]] = {"name": e.name, "size": st.st_size, "mtime_ns": st.st_mtime_ns} + return out + + +# -- baseline --------------------------------------------------------------- + +@dataclass +class Baseline: + chains: dict[str, dict] = field(default_factory=dict) # chain -> {"ceiling": int, "files": {name: {...}}} + captured: float = 0.0 + + def ceiling(self, chain: str) -> int: + return int(self.chains.get(chain, {}).get("ceiling", 0)) + + def to_json(self) -> dict: + return {"version": 1, "captured": self.captured, "chains": self.chains} + + @classmethod + def from_json(cls, raw) -> "Baseline | None": + if not isinstance(raw, dict) or not isinstance(raw.get("chains"), dict): + return None + return cls(raw["chains"], float(raw.get("captured", 0))) + + +def capture_baseline(game_dir: Path, owned: set[str] = frozenset()) -> Baseline: + """Snapshot vanilla PAKs. `owned` = file names we created; they are not vanilla.""" + scan = scan_game_paks(game_dir) + chains = {} + for chain in CHAINS: + files = {i["name"]: {"size": i["size"], "mtime_ns": i["mtime_ns"]} + for n, i in scan[chain].items() if i["name"] not in owned} + nums = [n for n, i in scan[chain].items() if i["name"] not in owned] + chains[chain] = {"ceiling": max(nums, default=0), "files": files} + return Baseline(chains, time.time()) + + +def load_baseline(path: Path) -> Baseline | None: + return Baseline.from_json(read_json(path, None)) + + +def save_baseline(path: Path, baseline: Baseline) -> None: + atomic_write_json(path, baseline.to_json()) + + +# -- slot allocation -------------------------------------------------------- + +@dataclass(frozen=True) +class Slot: + mod_id: str + src: Path + chain: str + number: int + + @property + def dest(self) -> str: + # Deploy to pak_mods/ so REFramework's IntegrityCheckBypass can load them + # natively (fixing Linux/Proton issues and silent patch failures). + return f"pak_mods/{self.chain}_{self.number:03d}_{self.mod_id}.pak" + + +def _pak_sort_key(p: Path): + parsed = parse_pak_name(p.name) + return (parsed[1] if parsed else 10 ** 6, p.name.lower()) + + +def allocate(mods: list[tuple[str, list[Entry]]], baseline: Baseline) -> list[Slot]: + """Assign contiguous slots above each chain's ceiling, following load order.""" + nxt = {c: baseline.ceiling(c) + 1 for c in CHAINS} + slots: list[Slot] = [] + for mod_id, entries in mods: + for chain in CHAINS: + paks = sorted((e.src for e in entries if e.kind == PAK and e.chain == chain), key=_pak_sort_key) + for src in paks: + slots.append(Slot(mod_id, src, chain, nxt[chain])) + nxt[chain] += 1 + return slots + + +# -- title update detection ------------------------------------------------- + +@dataclass +class UpdateReport: + updated: bool = False + reasons: list[str] = field(default_factory=list) + + +def detect_update(game_dir: Path, baseline: Baseline, owned: set[str]) -> UpdateReport: + """A PAK we do not own above the ceiling, or a changed/missing vanilla PAK, means Steam patched.""" + rep = UpdateReport() + scan = scan_game_paks(game_dir) + for chain in CHAINS: + ceiling = baseline.ceiling(chain) + for n, info in sorted(scan[chain].items()): + if n > ceiling and info["name"] not in owned: + rep.reasons.append(f"new PAK {info['name']}") + present = {i["name"]: i for i in scan[chain].values()} + for name, rec in baseline.chains.get(chain, {}).get("files", {}).items(): + cur = present.get(name) + if cur is None: + rep.reasons.append(f"vanilla PAK {name} disappeared") + elif cur["size"] != rec["size"]: + rep.reasons.append(f"vanilla PAK {name} changed size") + rep.updated = bool(rep.reasons) + return rep + + +# -- PAK content hashes (best-effort conflict detection) --------------------- + +def murmur3_32(data: bytes, seed: int = 0) -> int: + c1, c2 = 0xCC9E2D51, 0x1B873593 + h = seed & 0xFFFFFFFF + n = len(data) // 4 + for (k,) in struct.iter_unpack("> 17)) & 0xFFFFFFFF + k = (k * c2) & 0xFFFFFFFF + h ^= k + h = ((h << 13) | (h >> 19)) & 0xFFFFFFFF + h = (h * 5 + 0xE6546B64) & 0xFFFFFFFF + tail = data[n * 4:] + k = 0 + for i, b in enumerate(tail): + k |= b << (8 * i) + if tail: + k = (k * c1) & 0xFFFFFFFF + k = ((k << 15) | (k >> 17)) & 0xFFFFFFFF + k = (k * c2) & 0xFFFFFFFF + h ^= k + h ^= len(data) + h ^= h >> 16 + h = (h * 0x85EBCA6B) & 0xFFFFFFFF + h ^= h >> 13 + h = (h * 0xC2B2AE35) & 0xFFFFFFFF + h ^= h >> 16 + return h + + +def path_hash(game_path: str) -> tuple[int, int]: + """RE Engine KPKA entry key: murmur3 of the lower/upper-cased UTF-16LE path, seed 0xFFFFFFFF.""" + p = game_path.replace("\\", "/") + return (murmur3_32(p.lower().encode("utf-16-le"), 0xFFFFFFFF), + murmur3_32(p.upper().encode("utf-16-le"), 0xFFFFFFFF)) + + +_ENTRY = struct.Struct(" frozenset[tuple[int, int]]: + """Set of (lower, upper) path hashes inside a KPKA archive; empty if unreadable.""" + try: + st = os.stat(path) + key = (str(path), st.st_size, st.st_mtime_ns) + if key in _pak_cache: + return _pak_cache[key] + with open(path, "rb") as fh: + head = fh.read(16) + if len(head) < 16 or head[:4] != b"KPKA": + return frozenset() + total = struct.unpack_from(" 5_000_000 or 16 + total * _ENTRY.size > st.st_size: + return frozenset() + table = fh.read(total * _ENTRY.size) + result = frozenset((lo, up) for lo, up, *_ in _ENTRY.iter_unpack(table)) + _pak_cache[key] = result + return result + except (OSError, struct.error): + return frozenset() diff --git a/src/mhwmm/core/paths.py b/src/mhwmm/core/paths.py new file mode 100644 index 0000000..4cf2b98 --- /dev/null +++ b/src/mhwmm/core/paths.py @@ -0,0 +1,65 @@ +"""XDG directories and library layout.""" +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + +APP_DIRNAME = "mhwilds-mod-manager" + + +@dataclass(frozen=True) +class Paths: + data_dir: Path + config_dir: Path + + @classmethod + def default(cls) -> "Paths": + home = Path.home() + data = Path(os.environ.get("XDG_DATA_HOME") or home / ".local/share") + conf = Path(os.environ.get("XDG_CONFIG_HOME") or home / ".config") + return cls(data / APP_DIRNAME, conf / APP_DIRNAME) + + @property + def mods_dir(self) -> Path: + return self.data_dir / "mods" + + @property + def downloads_dir(self) -> Path: + return self.data_dir / "downloads" + + @property + def state_dir(self) -> Path: + return self.data_dir / "state" + + @property + def backup_dir(self) -> Path: + return self.state_dir / "backup" + + @property + def trash_dir(self) -> Path: + return self.state_dir / "trash" + + @property + def manifest_file(self) -> Path: + return self.state_dir / "deployed.json" + + @property + def baseline_file(self) -> Path: + return self.state_dir / "pak-baseline.json" + + @property + def settings_file(self) -> Path: + return self.config_dir / "settings.json" + + @property + def profiles_file(self) -> Path: + return self.config_dir / "profiles.json" + + @property + def secrets_file(self) -> Path: + return self.config_dir / "secrets.json" + + def ensure(self) -> None: + for d in (self.mods_dir, self.downloads_dir, self.state_dir, self.backup_dir, self.config_dir): + d.mkdir(parents=True, exist_ok=True) diff --git a/src/mhwmm/core/profiles.py b/src/mhwmm/core/profiles.py new file mode 100644 index 0000000..c6d68c5 --- /dev/null +++ b/src/mhwmm/core/profiles.py @@ -0,0 +1,103 @@ +"""Named profiles: an enabled-set plus a load order (later = higher priority).""" +from __future__ import annotations + +from pathlib import Path + +from .util import atomic_write_json, read_json + +DEFAULT = "Default" + + +class ProfileError(Exception): + pass + + +class Profiles: + def __init__(self, path: Path): + self.path = path + self.active = DEFAULT + self.data: dict[str, dict] = {DEFAULT: {"enabled": [], "order": []}} + raw = read_json(path, None) + if isinstance(raw, dict) and isinstance(raw.get("profiles"), dict) and raw["profiles"]: + self.data = {n: {"enabled": list(p.get("enabled", [])), "order": list(p.get("order", []))} + for n, p in raw["profiles"].items() if isinstance(p, dict)} or self.data + self.active = raw.get("active") if raw.get("active") in self.data else next(iter(self.data)) + + def save(self) -> None: + atomic_write_json(self.path, {"active": self.active, "profiles": self.data}) + + @property + def names(self) -> list[str]: + return list(self.data) + + @property + def current(self) -> dict: + return self.data[self.active] + + # -- reconciliation with the library ------------------------------------ + def reconcile(self, known_ids: list[str]) -> None: + known = set(known_ids) + for p in self.data.values(): + p["order"] = [i for i in dict.fromkeys(p["order"]) if i in known] + p["order"] += [i for i in known_ids if i not in p["order"]] + p["enabled"] = [i for i in dict.fromkeys(p["enabled"]) if i in known] + + # -- queries ------------------------------------------------------------ + def is_enabled(self, mod_id: str) -> bool: + return mod_id in self.current["enabled"] + + def order(self) -> list[str]: + return list(self.current["order"]) + + def enabled_in_order(self) -> list[str]: + en = set(self.current["enabled"]) + return [i for i in self.current["order"] if i in en] + + # -- edits -------------------------------------------------------------- + def set_enabled(self, mod_id: str, on: bool) -> None: + en = self.current["enabled"] + if on and mod_id not in en: + en.append(mod_id) + if mod_id not in self.current["order"]: + self.current["order"].append(mod_id) + elif not on and mod_id in en: + en.remove(mod_id) + + def move(self, mod_id: str, new_index: int) -> None: + order = self.current["order"] + if mod_id not in order: + return + order.remove(mod_id) + order.insert(max(0, min(new_index, len(order))), mod_id) + + def remove_mod(self, mod_id: str) -> None: + for p in self.data.values(): + p["order"] = [i for i in p["order"] if i != mod_id] + p["enabled"] = [i for i in p["enabled"] if i != mod_id] + + def create(self, name: str, copy_from: str | None = None) -> None: + name = name.strip() + if not name or name in self.data: + raise ProfileError("A profile with that name already exists" if name else "Name required") + src = self.data.get(copy_from or "", {"enabled": [], "order": list(self.current["order"])}) + self.data[name] = {"enabled": list(src["enabled"]), "order": list(src["order"])} + + def rename(self, old: str, new: str) -> None: + new = new.strip() + if old not in self.data or not new or new in self.data: + raise ProfileError("Invalid profile name") + self.data = {(new if k == old else k): v for k, v in self.data.items()} + if self.active == old: + self.active = new + + def delete(self, name: str) -> None: + if len(self.data) <= 1: + raise ProfileError("Cannot delete the last profile") + self.data.pop(name, None) + if self.active == name: + self.active = next(iter(self.data)) + + def switch(self, name: str) -> None: + if name not in self.data: + raise ProfileError(f"No such profile: {name}") + self.active = name diff --git a/src/mhwmm/core/steam.py b/src/mhwmm/core/steam.py new file mode 100644 index 0000000..35d883c --- /dev/null +++ b/src/mhwmm/core/steam.py @@ -0,0 +1,234 @@ +"""Steam discovery: VDF parsing, app lookup and launch-option handling.""" +from __future__ import annotations + +import os +import re +import shutil +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from .util import atomic_write_text + +APP_ID = "2246340" # Monster Hunter Wilds +REQUIRED_LAUNCH_OPTIONS = 'WINEDLLOVERRIDES="dinput8.dll=n,b" %command%' +_OVERRIDE = 'dinput8.dll=n,b' + +_TOKEN = re.compile(r'"((?:[^"\\]|\\.)*)"|([{}])|//[^\n]*|\s+') +_ESC = {"n": "\n", "t": "\t", "\\": "\\", '"': '"'} + + +class VDFError(ValueError): + pass + + +def _unescape(s: str) -> str: + return re.sub(r"\\(.)", lambda m: _ESC.get(m.group(1), m.group(0)), s) + + +def _escape(s: str) -> str: + return s.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n").replace("\t", "\\t") + + +def parse_vdf(text: str) -> dict[str, Any]: + """Parse Valve text KeyValues into nested dicts (duplicate keys: last wins).""" + root: dict[str, Any] = {} + stack = [root] + key: str | None = None + pos = 0 + while pos < len(text): + m = _TOKEN.match(text, pos) + if not m: + raise VDFError(f"unexpected character at offset {pos}") + pos = m.end() + if m.group(1) is not None: + s = _unescape(m.group(1)) + if key is None: + key = s + else: + stack[-1][key] = s + key = None + elif m.group(2) == "{": + if key is None: + raise VDFError("'{' without a key") + child: dict[str, Any] = {} + stack[-1][key] = child + stack.append(child) + key = None + elif m.group(2) == "}": + if len(stack) == 1: + raise VDFError("unbalanced '}'") + stack.pop() + if len(stack) != 1: + raise VDFError("unterminated block") + return root + + +def dump_vdf(data: dict[str, Any], depth: int = 0) -> str: + out = [] + ind = "\t" * depth + for k, v in data.items(): + if isinstance(v, dict): + out.append(f'{ind}"{_escape(k)}"\n{ind}{{\n{dump_vdf(v, depth + 1)}{ind}}}\n') + else: + out.append(f'{ind}"{_escape(k)}"\t\t"{_escape(str(v))}"\n') + return "".join(out) + + +def vdf_get(data: dict, *path: str, default: Any = None) -> Any: + """Case-insensitive nested lookup (Steam is inconsistent about key case).""" + cur: Any = data + for part in path: + if not isinstance(cur, dict): + return default + hit = next((k for k in cur if k.lower() == part.lower()), None) + if hit is None: + return default + cur = cur[hit] + return cur + + +def read_vdf(path: Path) -> dict[str, Any]: + return parse_vdf(Path(path).read_text(encoding="utf-8", errors="replace")) + + +@dataclass +class GameInstall: + app_id: str + name: str + steam_root: Path + library: Path + game_dir: Path + build_id: str = "" + + +def steam_roots() -> list[Path]: + home = Path.home() + cands = [home / ".steam/steam", home / ".local/share/Steam", + home / ".var/app/com.valvesoftware.Steam/.local/share/Steam"] + seen, out = set(), [] + for c in cands: + if (c / "steamapps").is_dir(): + real = c.resolve() + if real not in seen: + seen.add(real) + out.append(real) + return out + + +def library_folders(steam_root: Path) -> list[Path]: + libs = [Path(steam_root)] + vdf = Path(steam_root) / "steamapps/libraryfolders.vdf" + if vdf.is_file(): + try: + data = vdf_get(read_vdf(vdf), "libraryfolders", default={}) + except VDFError: + data = {} + for entry in data.values() if isinstance(data, dict) else []: + p = entry.get("path") if isinstance(entry, dict) else None + if p and Path(p) not in libs: + libs.append(Path(p)) + return libs + + +def find_app(app_id: str = APP_ID, roots: list[Path] | None = None) -> GameInstall | None: + for root in roots if roots is not None else steam_roots(): + for lib in library_folders(root): + acf = lib / "steamapps" / f"appmanifest_{app_id}.acf" + if not acf.is_file(): + continue + try: + state = vdf_get(read_vdf(acf), "AppState", default={}) + except VDFError: + continue + installdir = state.get("installdir") if isinstance(state, dict) else None + if not installdir: + continue + game_dir = lib / "steamapps/common" / installdir + if game_dir.is_dir(): + return GameInstall(app_id, state.get("name", installdir), root, lib, game_dir, + state.get("buildid", "")) + return None + + +def userdata_ids(steam_root: Path) -> list[str]: + ud = Path(steam_root) / "userdata" + if not ud.is_dir(): + return [] + return sorted(p.name for p in ud.iterdir() if p.name.isdigit() and (p / "config").is_dir()) + + +def localconfig_path(steam_root: Path, user_id: str = "") -> Path | None: + ids = userdata_ids(steam_root) + uid = user_id if user_id in ids else (ids[0] if ids else None) + return Path(steam_root) / "userdata" / uid / "config/localconfig.vdf" if uid else None + + +_APP_PATH = ("UserLocalConfigStore", "Software", "Valve", "Steam", "apps") + + +def read_launch_options(localconfig: Path, app_id: str = APP_ID) -> str | None: + try: + data = read_vdf(localconfig) + except (OSError, VDFError): + return None + return vdf_get(data, *_APP_PATH, app_id, "LaunchOptions", default="") + + +def steam_running() -> bool: + try: + for p in Path("/proc").iterdir(): + if p.name.isdigit(): + try: + if (p / "comm").read_text().strip() == "steam": + return True + except OSError: + continue + except OSError: + pass + return False + + +def launch_options_ok(opts: str | None) -> bool: + return bool(opts) and re.search(r"WINEDLLOVERRIDES=[\"']?[^\s\"']*dinput8(?:\.dll)?=n", opts) is not None + + +def suggest_launch_options(existing: str | None) -> str: + """Return a launch-option string that includes the dinput8 override, keeping user args.""" + existing = (existing or "").strip() + if not existing: + return REQUIRED_LAUNCH_OPTIONS + if launch_options_ok(existing): + return existing + m = re.search(r"WINEDLLOVERRIDES=([\"']?)([^\"'\s]*)\1", existing) + if m: + q = m.group(1) + merged = f"WINEDLLOVERRIDES={q}{m.group(2)};{_OVERRIDE}{q}" + return existing[:m.start()] + merged + existing[m.end():] + if "%command%" in existing: + return f'WINEDLLOVERRIDES="{_OVERRIDE}" ' + existing + return f'WINEDLLOVERRIDES="{_OVERRIDE}" %command% {existing}' + + +class SteamRunningError(RuntimeError): + pass + + +def write_launch_options(localconfig: Path, value: str, app_id: str = APP_ID) -> Path: + """Opt-in write. Refuses while Steam runs (it would overwrite the file on exit). Returns backup path.""" + if steam_running(): + raise SteamRunningError("Steam is running; it would overwrite localconfig.vdf on exit. Close Steam first.") + data = read_vdf(localconfig) + node = data + for part in (*_APP_PATH, app_id): + hit = next((k for k in node if k.lower() == part.lower()), part) + nxt = node.setdefault(hit, {}) + if not isinstance(nxt, dict): + raise VDFError(f"unexpected value at {part}") + node = nxt + node["LaunchOptions"] = value + backup = localconfig.with_name(f"{localconfig.name}.mhwmm-bak-{int(time.time())}") + shutil.copy2(localconfig, backup) + atomic_write_text(localconfig, dump_vdf(data)) + return backup diff --git a/src/mhwmm/core/util.py b/src/mhwmm/core/util.py new file mode 100644 index 0000000..06df146 --- /dev/null +++ b/src/mhwmm/core/util.py @@ -0,0 +1,69 @@ +"""Small filesystem helpers shared by core modules.""" +from __future__ import annotations + +import json +import os +import re +import tempfile +from pathlib import Path, PurePosixPath +from typing import Any + + +def atomic_write_bytes(path: Path, data: bytes, mode: int | None = None) -> None: + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp") + try: + with os.fdopen(fd, "wb") as fh: + fh.write(data) + fh.flush() + os.fsync(fh.fileno()) + if mode is not None: + os.chmod(tmp, mode) + os.replace(tmp, path) + except BaseException: + try: + os.unlink(tmp) + except OSError: + pass + raise + + +def atomic_write_text(path: Path, text: str, mode: int | None = None) -> None: + atomic_write_bytes(path, text.encode("utf-8"), mode) + + +def atomic_write_json(path: Path, obj: Any, mode: int | None = None) -> None: + atomic_write_text(path, json.dumps(obj, indent=2, sort_keys=True) + "\n", mode) + + +def read_json(path: Path, default: Any) -> Any: + try: + with open(path, "r", encoding="utf-8") as fh: + return json.load(fh) + except (FileNotFoundError, json.JSONDecodeError): + return default + + +def slugify(text: str, fallback: str = "mod") -> str: + slug = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-") + return slug[:48] or fallback + + +def safe_relpath(rel: str) -> str | None: + """Normalise an archive/dest path. Returns None if it is absolute or escapes.""" + rel = rel.replace("\\", "/") + if not rel or "\0" in rel or rel.startswith("/") or re.match(r"^[A-Za-z]:", rel): + return None + parts = [p for p in PurePosixPath(rel).parts if p not in ("", ".")] + if not parts or any(p == ".." for p in parts): + return None + return "/".join(parts) + + +def is_within(child: Path, parent: Path) -> bool: + try: + Path(os.path.realpath(child)).relative_to(os.path.realpath(parent)) + return True + except ValueError: + return False diff --git a/src/mhwmm/ui/__init__.py b/src/mhwmm/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/mhwmm/ui/app.py b/src/mhwmm/ui/app.py new file mode 100644 index 0000000..5ec42c1 --- /dev/null +++ b/src/mhwmm/ui/app.py @@ -0,0 +1,47 @@ +"""Adw.Application with single-instance nxm:// forwarding.""" +from __future__ import annotations + +from pathlib import Path + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") +from gi.repository import Adw, Gio, GLib + +from .. import APP_ID +from ..core.manager import Manager +from .window import MainWindow + + +class App(Adw.Application): + def __init__(self): + # HANDLES_COMMAND_LINE makes a second launch (e.g. the browser opening nxm://) forward its + # arguments to this already-running instance over D-Bus instead of opening a second window. + super().__init__(application_id=APP_ID, flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE) + self.manager: Manager | None = None + self.win: MainWindow | None = None + quit_action = Gio.SimpleAction.new("quit", None) + quit_action.connect("activate", lambda *_: self.quit()) + self.add_action(quit_action) + self.set_accels_for_action("app.quit", ["q"]) + + def _window(self) -> MainWindow: + if self.win is None: + self.manager = Manager() + self.win = MainWindow(self, self.manager) + return self.win + + def do_activate(self): + self._window().present() + + def do_command_line(self, cmdline: Gio.ApplicationCommandLine) -> int: + args = cmdline.get_arguments()[1:] + win = self._window() + win.present() + for a in args: + if a.startswith("nxm://"): + GLib.idle_add(lambda url=a: (win.handle_nxm(url), False)[1]) + elif Path(a).exists(): + win.import_paths([Path(a)]) + return 0 diff --git a/src/mhwmm/ui/details.py b/src/mhwmm/ui/details.py new file mode 100644 index 0000000..586f359 --- /dev/null +++ b/src/mhwmm/ui/details.py @@ -0,0 +1,207 @@ +"""Content pane: screenshot, metadata, and Files / Conflicts tabs for the selected mod.""" +from __future__ import annotations + +from typing import Callable + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") +from gi.repository import Adw, Gtk + +from ..core.library import Mod +from ..core.manager import Manager + +MAX_FILE_LINES = 2000 + + +class DetailsPane(Gtk.Stack): + def __init__(self, manager: Manager, on_edit: Callable[[str], None], on_delete: Callable[[str], None], + on_choose_dll: Callable[[str], None], on_import: Callable[[], None]): + super().__init__() + self.m = manager + self.on_edit, self.on_delete, self.on_choose_dll = on_edit, on_delete, on_choose_dll + self.mod_id: str | None = None + self._loading = False + + self.empty = Adw.StatusPage(icon_name="package-x-generic-symbolic", title="Select a mod", + description="Pick a mod from the list to see its details.") + self.first_run = Adw.StatusPage(icon_name="folder-download-symbolic", title="No mods yet", + description="Import a mod archive (.zip, .7z, .rar) or drop one onto this window.") + btn = Gtk.Button(label="Import Mod…", halign=Gtk.Align.CENTER) + btn.add_css_class("suggested-action") + btn.add_css_class("pill") + btn.connect("clicked", lambda *_: on_import()) + self.first_run.set_child(btn) + self.add_named(self.empty, "empty") + self.add_named(self.first_run, "first-run") + self.add_named(self._build_detail(), "detail") + + def _build_detail(self) -> Gtk.Widget: + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12, margin_top=12, margin_bottom=24, + margin_start=12, margin_end=12) + self.picture = Gtk.Picture(can_shrink=True, content_fit=Gtk.ContentFit.COVER, height_request=260) + self.picture.add_css_class("card") + box.append(self.picture) + + self.title = Gtk.Label(xalign=0, wrap=True, selectable=True) + self.title.add_css_class("title-1") + box.append(self.title) + self.meta = Gtk.Label(xalign=0, wrap=True) + self.meta.add_css_class("dim-label") + box.append(self.meta) + + self.banner = Adw.Banner.new("") + self.banner.set_button_label("Choose…") + self.banner.connect("button-clicked", lambda *_: self.mod_id and self.on_choose_dll(self.mod_id)) + box.append(self.banner) + + self.desc = Gtk.Label(xalign=0, wrap=True, selectable=True) + box.append(self.desc) + + edit = Adw.PreferencesGroup(title="Details") + self.name_row = Adw.EntryRow(title="Name", show_apply_button=True) + self.cat_row = Adw.EntryRow(title="Category", show_apply_button=True) + self.ver_row = Adw.EntryRow(title="Version", show_apply_button=True) + for key, row in (("name", self.name_row), ("category", self.cat_row), ("version", self.ver_row)): + row.connect("apply", self._apply, key) + edit.add(row) + self.path_row = Adw.EntryRow(title="Installed Path", editable=False) + edit.add(self.path_row) + self.game_path_row = Adw.EntryRow(title="Game Directory", editable=False) + edit.add(self.game_path_row) + + self.nexus_row = Adw.ActionRow(title="Nexus Mods", subtitle="Not linked") + self.nexus_link_btn = Gtk.LinkButton(label="Open Page") + self.nexus_row.add_suffix(self.nexus_link_btn) + self.nexus_update_btn = Gtk.Button(label="Check Update", valign=Gtk.Align.CENTER) + self.nexus_update_btn.connect("clicked", self._check_update_clicked) + self.nexus_row.add_suffix(self.nexus_update_btn) + edit.add(self.nexus_row) + + box.append(edit) + + + stack = Adw.ViewStack() + self.files_view = Gtk.TextView(editable=False, cursor_visible=False, monospace=True, top_margin=8, + bottom_margin=8, left_margin=8, right_margin=8) + files_scroll = Gtk.ScrolledWindow(min_content_height=220, child=self.files_view) + self.conflicts_view = Gtk.TextView(editable=False, cursor_visible=False, wrap_mode=Gtk.WrapMode.WORD_CHAR, + top_margin=8, bottom_margin=8, left_margin=8, right_margin=8) + conf_scroll = Gtk.ScrolledWindow(min_content_height=220, child=self.conflicts_view) + self.files_page = stack.add_titled(files_scroll, "files", "Files") + self.conf_page = stack.add_titled(conf_scroll, "conflicts", "Conflicts") + switcher = Adw.ViewSwitcher(stack=stack, policy=Adw.ViewSwitcherPolicy.WIDE, halign=Gtk.Align.START) + box.append(switcher) + box.append(stack) + + delete = Gtk.Button(label="Delete Mod…", halign=Gtk.Align.START) + delete.add_css_class("destructive-action") + delete.connect("clicked", lambda *_: self.mod_id and self.on_delete(self.mod_id)) + box.append(delete) + + clamp = Adw.Clamp(maximum_size=760, child=box) + return Gtk.ScrolledWindow(child=clamp, hscrollbar_policy=Gtk.PolicyType.NEVER) + + def _check_update_clicked(self, btn) -> None: + if not self.mod_id: return + mod = self.m.library.get(self.mod_id) + if not mod or not mod.nexus_mod_id: return + + def work(): + from ..core.nexus import KeyStore, NexusClient, NxmLink + client = NexusClient(KeyStore(self.m.paths.config_dir / "nexus.json").get()) + return client.mod_info(NxmLink(game="monsterhunterwilds", mod_id=int(mod.nexus_mod_id), file_id=0)) + + def done(info, err): + if err: + self.nexus_row.set_subtitle(f"Check failed: {err}") + elif info: + latest = info.get("version", "Unknown") + if latest != mod.version: + self.nexus_row.set_subtitle(f"Update available! Latest: v{latest}") + self.nexus_update_btn.add_css_class("suggested-action") + else: + self.nexus_row.set_subtitle("Up to date") + self.nexus_update_btn.remove_css_class("suggested-action") + + self.nexus_row.set_subtitle("Checking...") + from .tasks import run_async + run_async(work, done) + + def _apply(self, row: Adw.EntryRow, key: str) -> None: + + if self._loading or not self.mod_id: + return + text = row.get_text().strip() + if key == "name" and not text: + return + self.m.update_mod(self.mod_id, **{key: text}) + self.on_edit(self.mod_id) + + def clear(self) -> None: + self.mod_id = None + self.set_visible_child_name("empty" if self.m.library.mods else "first-run") + + def show_mod(self, mod: Mod) -> None: + self._loading = True + self.mod_id = mod.id + shot = mod.screenshot_path + self.picture.set_filename(str(shot) if shot else None) + self.picture.set_visible(bool(shot)) + self.title.set_label(mod.name) + bits = [b for b in (f"v{mod.version}" if mod.version else "", f"by {mod.author}" if mod.author else "", + mod.category) if b] + self.meta.set_label(" · ".join(bits) or "No metadata") + self.desc.set_label(mod.description) + self.desc.set_visible(bool(mod.description)) + for row, val in ((self.name_row, mod.name), (self.cat_row, mod.category), (self.ver_row, mod.version)): + row.set_text(val) + self.path_row.set_text(str(mod.files_root)) + + if mod.nexus_url: + self.nexus_row.set_visible(True) + self.nexus_link_btn.set_uri(mod.nexus_url) + self.nexus_row.set_subtitle(f"Linked (ID: {mod.nexus_mod_id})") + self.nexus_update_btn.remove_css_class("suggested-action") + else: + self.nexus_row.set_visible(False) + + cls = self.m.classification(mod) + + if self.m.game_dir and cls.entries: + import os + paths = [os.path.dirname(e.dest) for e in cls.entries if e.dest] + if not paths: + self.game_path_row.set_text(str(self.m.game_dir)) + else: + common = os.path.commonpath(paths) if len(paths) > 1 else paths[0] + self.game_path_row.set_text(str(self.m.game_dir / common)) + elif self.m.game_dir: + self.game_path_row.set_text(str(self.m.game_dir)) + else: + self.game_path_row.set_text("Not deployed or no files") + self.banner.set_title(f"{len(cls.ambiguous)} .dll file(s) at the top level: plugin or root-level DLL?") + self.banner.set_revealed(bool(cls.ambiguous)) + + lines = [f"[{e.kind}] {e.dest or e.src.name}" + (f" ({e.chain} chain)" if e.kind == "pak" else "") + for e in cls.entries] + lines += [f"[?] {p.name} (undecided .dll)" for p in cls.ambiguous] + lines += [f"note: {n}" for n in cls.notes] + if len(lines) > MAX_FILE_LINES: + lines = lines[:MAX_FILE_LINES] + [f"… {len(lines) - MAX_FILE_LINES} more"] + self.files_view.get_buffer().set_text("\n".join(lines) or "No deployable files") + + confs = self.m.conflicts_for(mod.id) + names = lambda ids: ", ".join(self.m.library.get(i).name if self.m.library.get(i) else i for i in ids) + if not confs: + text = "No conflicts with other enabled mods." if self.m.profiles.is_enabled(mod.id) \ + else "Enable this mod to check for conflicts." + else: + text = "\n\n".join( + f"{c.key}\n {'PAK / loose' if c.kind == 'pak-loose' else 'PAK' if c.kind == 'pak-pak' else 'Loose file'}" + f" · winner: {names([c.winner])}\n overridden: {names(c.losers)}" for c in confs) + self.conflicts_view.get_buffer().set_text(text) + self.conf_page.set_badge_number(len(confs)) if hasattr(self.conf_page, "set_badge_number") else None + self.set_visible_child_name("detail") + self._loading = False diff --git a/src/mhwmm/ui/dialogs.py b/src/mhwmm/ui/dialogs.py new file mode 100644 index 0000000..f1a4e31 --- /dev/null +++ b/src/mhwmm/ui/dialogs.py @@ -0,0 +1,294 @@ +"""Dialogs: alerts, text prompts, preview, conflicts, game setup and preferences.""" +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Callable + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") +from gi.repository import Adw, Gdk, Gio, GLib, Gtk + +from ..core import desktop, loader, nexus, steam +from ..core.config import LINK_MODES +from ..core.deploy import Plan +from ..core.manager import Manager +from .tasks import run_async + +DESTRUCTIVE = Adw.ResponseAppearance.DESTRUCTIVE +SUGGESTED = Adw.ResponseAppearance.SUGGESTED + + +def alert(parent: Gtk.Window, heading: str, body: str, + responses: list[tuple[str, str, Adw.ResponseAppearance | None]], + callback: Callable[[str], None], extra_child: Gtk.Widget | None = None) -> None: + """Modal alert. `responses` are (id, label, appearance); the id 'cancel' is the close response.""" + if hasattr(Adw, "AlertDialog"): + dlg = Adw.AlertDialog.new(heading, body) + else: # libadwaita < 1.5 + dlg = Adw.MessageDialog.new(parent, heading, body) + for rid, label, appearance in responses: + dlg.add_response(rid, label) + if appearance is not None: + dlg.set_response_appearance(rid, appearance) + dlg.set_close_response("cancel") + if extra_child is not None: + dlg.set_extra_child(extra_child) + dlg.connect("response", lambda _d, rid: callback(rid)) + if hasattr(Adw, "AlertDialog"): + dlg.present(parent) + else: + dlg.present() + + +def confirm(parent, heading: str, body: str, label: str, on_yes: Callable[[], None], destructive=False) -> None: + alert(parent, heading, body, + [("cancel", "Cancel", None), ("yes", label, DESTRUCTIVE if destructive else SUGGESTED)], + lambda rid: on_yes() if rid == "yes" else None) + + +def prompt_text(parent, heading: str, initial: str, label: str, on_text: Callable[[str], None]) -> None: + entry = Gtk.Entry(text=initial, activates_default=True) + alert(parent, heading, "", [("cancel", "Cancel", None), ("ok", label, SUGGESTED)], + lambda rid: on_text(entry.get_text().strip()) if rid == "ok" and entry.get_text().strip() else None, + extra_child=entry) + + +def copy_text(widget: Gtk.Widget, text: str) -> None: + widget.get_clipboard().set_content(Gdk.ContentProvider.new_for_value(text)) + + +class TextDialog(Adw.Window): + """Read-only monospace text with optional confirm action (used for Preview changes / conflicts).""" + + def __init__(self, parent: Gtk.Window, title: str, text: str, action_label: str | None = None, + on_action: Callable[[], None] | None = None): + super().__init__(transient_for=parent, modal=True, title=title, default_width=640, default_height=480) + view = Gtk.TextView(editable=False, cursor_visible=False, monospace=True, wrap_mode=Gtk.WrapMode.WORD_CHAR, + left_margin=12, right_margin=12, top_margin=12, bottom_margin=12) + view.get_buffer().set_text(text) + scroller = Gtk.ScrolledWindow(vexpand=True, child=view) + header = Adw.HeaderBar() + cancel = Gtk.Button(label="Close" if not on_action else "Cancel") + cancel.connect("clicked", lambda *_: self.close()) + header.pack_start(cancel) + if on_action: + go = Gtk.Button(label=action_label or "Apply") + go.add_css_class("suggested-action") + go.connect("clicked", lambda *_: (self.close(), on_action())) + header.pack_end(go) + tv = Adw.ToolbarView(content=scroller) + tv.add_top_bar(header) + self.set_content(tv) + + +def preview_dialog(parent, plan: Plan, on_apply: Callable[[], None]) -> None: + lines = plan.describe() + text = "\n".join(lines) if lines else "Nothing to do — the game folder already matches your profile." + changes = len(plan.changes) + TextDialog(parent, f"Preview changes ({changes})", text, + "Apply" if changes else None, on_apply if changes else None).present() + + +class GameSetupWindow(Adw.Window): + """Game folder, PAK chains and REFramework status with one-click fixes.""" + + def __init__(self, parent: Gtk.Window, manager: Manager, on_change: Callable[[], None]): + super().__init__(transient_for=parent, modal=True, title="Game setup", default_width=620, default_height=700) + self.m, self.parent_win, self.on_change = manager, parent, on_change + self.build() + + # -- helpers -------------------------------------------------------------- + def toast(self, msg: str) -> None: + self.overlay.add_toast(Adw.Toast.new(msg)) + + def build(self) -> None: + page = Adw.PreferencesPage() + + g = Adw.PreferencesGroup(title="Game") + row = Adw.ActionRow(title=self.m.install.name if self.m.install else "Monster Hunter Wilds", + subtitle=str(self.m.game_dir), subtitle_selectable=True) + g.add(row) + page.add(g) + + pg = Adw.PreferencesGroup(title="PAK chains", + description="Mod PAKs are slotted above the vanilla ceiling and re-slotted " + "automatically after a game update.") + for chain, info in self.m.pak_summary().items(): + slots = info["mod_slots"] + r = Adw.ActionRow(title=f"{chain} chain", subtitle=( + f"Vanilla ceiling {info['ceiling']:03d} · {info['vanilla']} PAKs · " + + (f"mod slots {', '.join(f'{n:03d}' for n in slots)}" if slots else "no mod PAKs deployed"))) + pg.add(r) + page.add(pg) + + lg = Adw.PreferencesGroup(title="Mod loader (REFramework)") + st = self.m.loader_status() + for chk in st.checks: + r = Adw.ActionRow(title=chk.label, subtitle=chk.detail) + r.add_prefix(Gtk.Image.new_from_icon_name("emblem-ok-symbolic" if chk.ok else "dialog-warning-symbolic")) + lg.add(r) + page.add(lg) + + ag = Adw.PreferencesGroup() + install = Adw.ActionRow(title="Install / update REFramework", + subtitle="Downloads the latest nightly MHWILDS build and installs dinput8.dll") + b = Gtk.Button(label="Install", valign=Gtk.Align.CENTER) + b.connect("clicked", self._install) + install.add_suffix(b) + ag.add(install) + loose = Adw.ActionRow(title="Enable loose file loader", + subtitle=f"Sets LooseFileLoader_Enabled=true in {loader.CONFIG_NAME} (before launch)") + b2 = Gtk.Button(label="Enable", valign=Gtk.Align.CENTER) + b2.connect("clicked", self._enable_loose) + loose.add_suffix(b2) + ag.add(loose) + page.add(ag) + + og = Adw.PreferencesGroup( + title="Steam launch options", + description="Proton must be told to load dinput8.dll. Set this in Steam → Properties → Launch Options, " + "or let the manager write it while Steam is closed.") + cur = Adw.ActionRow(title="Current", subtitle=st.launch_options or "(empty)", subtitle_selectable=True) + og.add(cur) + need = Adw.ActionRow(title="Required", subtitle=st.suggested_launch_options, subtitle_selectable=True) + cp = Gtk.Button(icon_name="edit-copy-symbolic", valign=Gtk.Align.CENTER, tooltip_text="Copy") + cp.connect("clicked", lambda *_: (copy_text(cp, st.suggested_launch_options), self.toast("Copied"))) + need.add_suffix(cp) + og.add(need) + write = Adw.ActionRow(title="Write to Steam config", + subtitle="Backs up localconfig.vdf first. Only works while Steam is not running.") + wb = Gtk.Button(label="Write…", valign=Gtk.Align.CENTER) + wb.set_sensitive(not st.all_ok or not steam.launch_options_ok(st.launch_options)) + wb.connect("clicked", lambda *_: self._write_opts(st.suggested_launch_options)) + write.add_suffix(wb) + og.add(write) + page.add(og) + + self.overlay = Adw.ToastOverlay(child=page) + tv = Adw.ToolbarView(content=self.overlay) + tv.add_top_bar(Adw.HeaderBar()) + self.set_content(tv) + + # -- actions -------------------------------------------------------------- + def _refresh(self) -> None: + self.build() + self.on_change() + + def _install(self, btn) -> None: + btn.set_sensitive(False) + self.toast("Downloading REFramework…") + + def done(tag, err): + if err: + btn.set_sensitive(True) + self.toast(f"Install failed: {err}") + else: + self._refresh() + self.toast(f"Installed REFramework {tag}") + run_async(self.m.install_reframework, done) + + def _enable_loose(self, *_): + try: + loader.setup_all(self.m.game_dir) + except OSError as e: + self.toast(f"Could not write config: {e}") + return + self._refresh() + self.toast("Loose file loader enabled") + + def _write_opts(self, value: str) -> None: + def go(): + try: + bak = self.m.write_launch_options(value) + except steam.SteamRunningError as e: + self.toast(str(e)) + except (OSError, steam.VDFError) as e: + self.toast(f"Could not write: {e}") + else: + self._refresh() + self.toast(f"Written. Backup: {bak.name}") + confirm(self, "Write launch options?", f"This edits Steam's localconfig.vdf:\n\n{value}", "Write", go) + + +class PreferencesWindow(Adw.PreferencesWindow): + def __init__(self, parent: Gtk.Window, manager: Manager, keystore: nexus.KeyStore, on_change: Callable[[], None]): + super().__init__(transient_for=parent, modal=True, title="Preferences") + self.m, self.keystore, self.on_change = manager, keystore, on_change + page = Adw.PreferencesPage(title="General") + + g = Adw.PreferencesGroup(title="Deployment") + combo = Adw.ComboRow(title="Link mode", subtitle="Symlink is instant and uses no disk space", + model=Gtk.StringList.new(list(LINK_MODES))) + combo.set_selected(LINK_MODES.index(manager.settings.link_mode)) + combo.connect("notify::selected", self._link_mode) + g.add(combo) + auto = Adw.SwitchRow(title="Deploy on toggle", subtitle="Off: review and apply changes with the Deploy button") + auto.set_active(manager.settings.auto_deploy) + auto.connect("notify::active", self._auto) + g.add(auto) + page.add(g) + + gg = Adw.PreferencesGroup(title="Game folder") + self.folder = Adw.ActionRow(title="Override", subtitle=manager.settings.game_dir or "Auto-detect from Steam") + pick = Gtk.Button(label="Choose…", valign=Gtk.Align.CENTER) + pick.connect("clicked", self._pick_folder) + reset = Gtk.Button(label="Auto", valign=Gtk.Align.CENTER, tooltip_text="Use Steam auto-detection") + reset.connect("clicked", lambda *_: self._set_folder("")) + self.folder.add_suffix(pick) + self.folder.add_suffix(reset) + gg.add(self.folder) + page.add(gg) + + n = Adw.PreferencesGroup(title="Nexus Mods", + description="An API key is needed for nxm:// downloads. Stored in the system keyring " + "when available.") + key = Adw.PasswordEntryRow(title="API key") + key.set_show_apply_button(True) + key.connect("apply", lambda r: (keystore.set(r.get_text().strip()), self.add_toast(Adw.Toast.new("API key saved")))) + n.add(key) + reg = Adw.ActionRow(title="Handle nxm:// links", subtitle="Register this app for the “Mod Manager Download” button") + rb = Gtk.Button(label="Register", valign=Gtk.Align.CENTER) + rb.connect("clicked", self._register) + reg.add_suffix(rb) + n.add(reg) + page.add(n) + self.add(page) + + def _link_mode(self, row, _p): + self.m.settings.link_mode = LINK_MODES[row.get_selected()] + self.m.apply_settings() + + def _auto(self, row, _p): + self.m.settings.auto_deploy = row.get_active() + self.m.apply_settings() + self.on_change() + + def _pick_folder(self, *_): + d = Gtk.FileDialog(title="Select the MonsterHunterWilds folder") + + def done(dlg, res): + try: + f = dlg.select_folder_finish(res) + except GLib.Error: + return + self._set_folder(f.get_path()) + d.select_folder(self, None, done) + + def _set_folder(self, path: str): + self.m.set_game_dir(path) + self.folder.set_subtitle(path or "Auto-detect from Steam") + self.on_change() + + def _register(self, *_): + argv0 = Path(sys.argv[0]).resolve() + checkout = Path(__file__).resolve().parents[3] / "bin" / "mhwmm" + launcher = argv0 if argv0.name != "__main__.py" else (checkout if checkout.exists() else argv0) + try: + f = desktop.register_nxm_handler(str(launcher)) + except OSError as e: + self.add_toast(Adw.Toast.new(f"Could not register: {e}")) + return + self.add_toast(Adw.Toast.new(f"Registered {f.name}")) diff --git a/src/mhwmm/ui/downloads.py b/src/mhwmm/ui/downloads.py new file mode 100644 index 0000000..d79a477 --- /dev/null +++ b/src/mhwmm/ui/downloads.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import gi +from gi.repository import GLib, Gtk, Pango, Adw + +class DownloadRow(Gtk.ListBoxRow): + def __init__(self, name: str): + super().__init__() + self.set_selectable(False) + + vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) + vbox.set_margin_start(12) + vbox.set_margin_end(12) + vbox.set_margin_top(8) + vbox.set_margin_bottom(8) + + self.label = Gtk.Label(label=name, xalign=0.0) + self.label.set_ellipsize(Pango.EllipsizeMode.END) + self.label.add_css_class("heading") + vbox.append(self.label) + + self.progress_bar = Gtk.ProgressBar() + self.progress_bar.set_show_text(True) + vbox.append(self.progress_bar) + + self.set_child(vbox) + + def update_progress(self, downloaded: int, total: int) -> None: + if total > 0: + fraction = downloaded / total + self.progress_bar.set_fraction(fraction) + self.progress_bar.set_text(f"{int(fraction * 100)}%") + else: + self.progress_bar.pulse() + self.progress_bar.set_text("Downloading...") + + def set_status(self, status: str) -> None: + self.progress_bar.set_text(status) + +class DownloadManager: + def __init__(self): + self.popover = Gtk.Popover() + + self.scrolled = Gtk.ScrolledWindow() + self.scrolled.set_min_content_width(450) + self.scrolled.set_min_content_height(250) + self.scrolled.set_max_content_height(600) + + self.listbox = Gtk.ListBox() + self.listbox.set_selection_mode(Gtk.SelectionMode.NONE) + self.listbox.add_css_class("boxed-list") + self.listbox.set_margin_start(12) + self.listbox.set_margin_end(12) + self.listbox.set_margin_top(12) + self.listbox.set_margin_bottom(12) + + self.scrolled.set_child(self.listbox) + self.popover.set_child(self.scrolled) + + self.active_downloads: dict[str, DownloadRow] = {} + + def add_download(self, download_id: str, name: str) -> DownloadRow: + row = DownloadRow(name) + self.active_downloads[download_id] = row + self.listbox.append(row) + return row + + def remove_download(self, download_id: str) -> None: + if download_id in self.active_downloads: + row = self.active_downloads.pop(download_id) + self.listbox.remove(row) diff --git a/src/mhwmm/ui/tasks.py b/src/mhwmm/ui/tasks.py new file mode 100644 index 0000000..c74b450 --- /dev/null +++ b/src/mhwmm/ui/tasks.py @@ -0,0 +1,19 @@ +"""Run blocking work off the GTK main loop.""" +from __future__ import annotations + +import threading +from typing import Callable + +from gi.repository import GLib + + +def run_async(work: Callable[[], object], done: Callable[[object, Exception | None], None]) -> None: + """Execute `work` in a thread; call `done(result, error)` on the main loop.""" + def target(): + try: + result, error = work(), None + except Exception as e: # surfaced to the UI as a toast + result, error = None, e + GLib.idle_add(lambda: (done(result, error), False)[1]) + + threading.Thread(target=target, daemon=True).start() diff --git a/src/mhwmm/ui/window.py b/src/mhwmm/ui/window.py new file mode 100644 index 0000000..de4b39a --- /dev/null +++ b/src/mhwmm/ui/window.py @@ -0,0 +1,673 @@ +"""Main window: Adw.NavigationSplitView with the mod list on the left and details on the right.""" +from __future__ import annotations + +from pathlib import Path + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") +from gi.repository import Adw, Gdk, Gio, GLib, GObject, Gtk, Pango + +from ..core import nexus +from ..core.deploy import ApplyResult, DeployError +from ..core.library import LibraryError +from ..core.manager import Manager +from .details import DetailsPane +from .downloads import DownloadManager +from .dialogs import (DESTRUCTIVE, GameSetupWindow, PreferencesWindow, TextDialog, alert, confirm, preview_dialog, + prompt_text) +from .tasks import run_async + +ARCHIVE_PATTERNS = ("*.zip", "*.7z", "*.rar", "*.ZIP", "*.7Z", "*.RAR", "*.pak", "*.PAK", "*.dll", "*.DLL", "*.lua", "*.LUA") + + +class MainWindow(Adw.ApplicationWindow): + def __init__(self, app: Adw.Application, manager: Manager): + super().__init__(application=app, title="MH Wilds Mod Manager", default_width=1120, default_height=740) + self.m = manager + self.keystore = nexus.KeyStore(manager.paths.secrets_file) + self.rows: dict[str, Gtk.ListBoxRow] = {} + self._search = "" + self._category = "" + self._busy = 0 + self._syncing_profiles = False + self._syncing_categories = False + + self.toasts = Adw.ToastOverlay() + self.set_content(self.toasts) + self.root_stack = Gtk.Stack() + self.toasts.set_child(self.root_stack) + self.root_stack.add_named(self._build_no_game(), "no-game") + self.root_stack.add_named(self._build_main(), "main") + self._install_actions() + self._install_file_drop() + GLib.idle_add(self._startup) + + # -- construction ------------------------------------------------------------- + def _build_no_game(self) -> Gtk.Widget: + page = Adw.StatusPage(icon_name="dialog-question-symbolic", title="Monster Hunter Wilds not found", + description="Steam does not list the game (app 2246340). Install it, or point the " + "manager at the game folder.") + box = Gtk.Box(spacing=12, halign=Gtk.Align.CENTER) + pick = Gtk.Button(label="Choose Game Folder…") + pick.add_css_class("suggested-action") + pick.add_css_class("pill") + pick.connect("clicked", lambda *_: self._choose_game_folder()) + retry = Gtk.Button(label="Retry") + retry.add_css_class("pill") + retry.connect("clicked", lambda *_: self._startup()) + box.append(pick) + box.append(retry) + page.set_child(box) + tv = Adw.ToolbarView(content=page) + tv.add_top_bar(Adw.HeaderBar()) + return tv + + def _build_main(self) -> Gtk.Widget: + self.split = Adw.NavigationSplitView(min_sidebar_width=300, max_sidebar_width=420, + sidebar_width_fraction=0.32) + + # sidebar + header = Adw.HeaderBar() + self.dl_manager = DownloadManager() + dl_btn = Gtk.MenuButton(icon_name="folder-download-symbolic", tooltip_text="Downloads", popover=self.dl_manager.popover) + header.pack_start(dl_btn) + imp = Gtk.Button(icon_name="list-add-symbolic", tooltip_text="Import Mod…", action_name="win.import") + header.pack_start(imp) + menu = Gio.Menu() + section = Gio.Menu() + for label, action in (("Game Setup…", "win.setup"), ("Conflicts…", "win.conflicts"), + ("Undeploy All Mods", "win.undeploy-all")): + section.append(label, action) + menu.append_section(None, section) + prof = Gio.Menu() + for label, action in (("New Profile…", "win.new-profile"), ("Rename Profile…", "win.rename-profile"), + ("Delete Profile", "win.delete-profile")): + prof.append(label, action) + menu.append_section("Profiles", prof) + tail = Gio.Menu() + tail.append("Preferences", "win.prefs") + tail.append("Quit", "app.quit") + menu.append_section(None, tail) + mb = Gtk.MenuButton(icon_name="open-menu-symbolic", menu_model=menu, tooltip_text="Main menu") + header.pack_end(mb) + self.update_all_btn = Gtk.Button(label="Check Updates", tooltip_text="Check Nexus Mods for updates", action_name="win.update_all") + header.pack_end(self.update_all_btn) + + self.profile_dd = Gtk.DropDown(model=Gtk.StringList.new([]), hexpand=True) + self.profile_dd.connect("notify::selected", self._on_profile_selected) + self.cat_dd = Gtk.DropDown(model=Gtk.StringList.new(["All categories"])) + self.cat_dd.connect("notify::selected", self._on_category_selected) + self.search = Gtk.SearchEntry(placeholder_text="Search mods", hexpand=True) + self.search.connect("search-changed", self._on_search) + controls = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6, margin_start=8, margin_end=8, + margin_top=6, margin_bottom=6) + controls.append(self.profile_dd) + controls.append(self.search) + controls.append(self.cat_dd) + + self.listbox = Gtk.ListBox(selection_mode=Gtk.SelectionMode.SINGLE) + self.listbox.add_css_class("navigation-sidebar") + self.listbox.set_filter_func(self._filter) + self.listbox.connect("row-selected", self._on_row_selected) + self.hint = Gtk.Label(label="Load order: later mods win conflicts. Drag ⠿ or Alt+↑/↓ to reorder.", + wrap=True, xalign=0, margin_start=12, margin_end=12, margin_bottom=8) + self.hint.add_css_class("caption") + self.hint.add_css_class("dim-label") + side_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + side_box.append(controls) + side_box.append(Gtk.ScrolledWindow(child=self.listbox, vexpand=True, hscrollbar_policy=Gtk.PolicyType.NEVER)) + side_box.append(self.hint) + stv = Adw.ToolbarView(content=side_box) + stv.add_top_bar(header) + self.split.set_sidebar(Adw.NavigationPage.new(stv, "Mods")) + + # content + self.details = DetailsPane(self.m, self._on_mod_edited, self._delete_mod, self._choose_dll, + lambda: self.activate_action("win.import", None)) + chead = Adw.HeaderBar() + up = Gtk.Button(icon_name="go-up-symbolic", tooltip_text="Move up (lower priority) Alt+↑", action_name="win.move-up") + down = Gtk.Button(icon_name="go-down-symbolic", tooltip_text="Move down (higher priority) Alt+↓", + action_name="win.move-down") + chead.pack_end(down) + chead.pack_end(up) + ctv = Adw.ToolbarView(content=self.details) + ctv.add_top_bar(chead) + self.split.set_content(Adw.NavigationPage.new(ctv, "Details")) + return self.split + + def _install_actions(self) -> None: + def add(name, cb): + a = Gio.SimpleAction.new(name, None) + a.connect("activate", lambda *_: cb()) + self.add_action(a) + add("import", self._import_dialog) + add("update_all", self._check_updates_all) + add("undeploy-all", self._undeploy_all) + add("setup", self._open_setup) + add("conflicts", self._show_conflicts) + add("prefs", self._open_prefs) + add("new-profile", self._new_profile) + add("rename-profile", self._rename_profile) + add("delete-profile", self._delete_profile) + add("move-up", lambda: self._move_selected(-1)) + add("move-down", lambda: self._move_selected(+1)) + app = self.get_application() + app.set_accels_for_action("win.move-up", ["Up"]) + app.set_accels_for_action("win.move-down", ["Down"]) + app.set_accels_for_action("win.import", ["o"]) + + def _install_file_drop(self) -> None: + target = Gtk.DropTarget.new(Gdk.FileList, Gdk.DragAction.COPY) + target.connect("drop", self._on_files_dropped) + self.add_controller(target) + + # -- startup / refresh ----------------------------------------------------------------- + def toast(self, msg: str, timeout: int = 4) -> None: + t = Adw.Toast.new(msg) + t.set_timeout(timeout) + self.toasts.add_toast(t) + + def _startup(self) -> bool: + try: + rep = self.m.startup() + except Exception as e: + self.toast(f"Startup problem: {e}", 10) + rep = None + if rep is None or not rep.game_found: + self.root_stack.set_visible_child_name("no-game") + return False + self.root_stack.set_visible_child_name("main") + for n in rep.notes: + self.toast(n, 12) + self.refresh_all() + return False + + def refresh_all(self) -> None: + self._recompute() + self._fill_profiles() + self._fill_categories() + self._fill_list() + + def _recompute(self) -> None: + try: + self.m.desired() + except DeployError: + pass + + def _fill_profiles(self) -> None: + self._syncing_profiles = True + names = self.m.profiles.names + self.profile_dd.set_model(Gtk.StringList.new(names)) + self.profile_dd.set_selected(names.index(self.m.profiles.active)) + self._syncing_profiles = False + + def _fill_categories(self) -> None: + self._syncing_categories = True + cats = ["All categories", *self.m.library.categories()] + self.cat_dd.set_model(Gtk.StringList.new(cats)) + self.cat_dd.set_selected(cats.index(self._category) if self._category in cats else 0) + if self._category not in cats: + self._category = "" + self._syncing_categories = False + + def _fill_list(self) -> None: + keep = self.details.mod_id + while (child := self.listbox.get_first_child()) is not None: + self.listbox.remove(child) + self.rows.clear() + for mid in self.m.profiles.order(): + mod = self.m.library.get(mid) + if mod: + row = self._make_row(mod) + self.rows[mid] = row + self.listbox.append(row) + if keep and keep in self.rows: + self.listbox.select_row(self.rows[keep]) + else: + self.details.clear() + + def _make_row(self, mod) -> Gtk.ListBoxRow: + row = Gtk.ListBoxRow() + row.mod_id = mod.id + box = Gtk.Box(spacing=8, margin_start=4, margin_end=4, margin_top=6, margin_bottom=6) + + handle = Gtk.Image.new_from_icon_name("list-drag-handle-symbolic") + handle.set_tooltip_text("Drag to change load order") + src = Gtk.DragSource(actions=Gdk.DragAction.MOVE) + src.connect("prepare", lambda _s, _x, _y, mid=mod.id: Gdk.ContentProvider.new_for_value(mid)) + handle.add_controller(src) + box.append(handle) + + check = Gtk.CheckButton(active=self.m.profiles.is_enabled(mod.id), valign=Gtk.Align.CENTER) + check.connect("toggled", self._on_toggled, mod.id) + box.append(check) + row.check = check + + text = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, hexpand=True) + name = Gtk.Label(label=mod.name, xalign=0, ellipsize=Pango.EllipsizeMode.END) + sub = Gtk.Label(label=f"v{mod.version}" if mod.version else " ", xalign=0) + sub.add_css_class("caption") + sub.add_css_class("dim-label") + text.append(name) + text.append(sub) + box.append(text) + + if mod.category: + badge = Gtk.Label(label=mod.category) + badge.add_css_class("caption") + badge.add_css_class("accent") + box.append(badge) + + warn = Gtk.Image.new_from_icon_name("dialog-warning-symbolic") + warn.add_css_class("warning") + box.append(warn) + row.warn = warn + self._update_warn(row) + + drop = Gtk.DropTarget.new(GObject.TYPE_STRING, Gdk.DragAction.MOVE) + drop.connect("drop", lambda _t, value, _x, _y, mid=mod.id: self._on_row_drop(value, mid)) + row.add_controller(drop) + row.set_child(box) + return row + + def _update_warn(self, row) -> None: + confs = self.m.conflicts_for(row.mod_id) if self.m.profiles.is_enabled(row.mod_id) else [] + row.warn.set_visible(bool(confs)) + if confs: + others = sorted({self.m.library.get(i).name for c in confs for i in c.mods + if i != row.mod_id and self.m.library.get(i)}) + row.warn.set_tooltip_text(f"Conflicts with: {', '.join(others)}") + + def _update_all_warns(self) -> None: + for row in self.rows.values(): + self._update_warn(row) + + # -- filtering ----------------------------------------------------------------------------- + def _filter(self, row) -> bool: + mod = self.m.library.get(row.mod_id) + if mod is None: + return False + if self._category and mod.category != self._category: + return False + q = self._search + return not q or q in mod.name.lower() or q in mod.author.lower() or q in mod.category.lower() + + def _on_search(self, entry) -> None: + self._search = entry.get_text().strip().lower() + self.listbox.invalidate_filter() + + def _on_category_selected(self, dd, _p) -> None: + if self._syncing_categories: + return + item = dd.get_selected_item() + cat = item.get_string() if item else "" + self._category = "" if cat == "All categories" else cat + self.listbox.invalidate_filter() + + # -- selection / edits ------------------------------------------------------------------------ + def _on_row_selected(self, _lb, row) -> None: + if row is None: + return + mod = self.m.library.get(row.mod_id) + if mod: + self.details.show_mod(mod) + self.split.set_show_content(True) + + def _on_mod_edited(self, mod_id: str) -> None: + self._fill_categories() + row = self.rows.get(mod_id) + mod = self.m.library.get(mod_id) + if row and mod: + self._fill_list() + self.listbox.invalidate_filter() + + def _selected_id(self) -> str | None: + row = self.listbox.get_selected_row() + return row.mod_id if row else None + + # -- enabling / ordering ------------------------------------------------------------------------- + def _after_change(self, res: ApplyResult | None) -> None: + self._recompute() + self._update_all_warns() + mid = self.details.mod_id + if mid and self.m.library.get(mid): + self.details.show_mod(self.m.library.get(mid)) + if res is not None: + for w in res.warnings[:3]: + self.toast(w, 8) + + def _on_toggled(self, check, mod_id: str) -> None: + on = check.get_active() + try: + res = self.m.set_enabled(mod_id, on) + except (DeployError, OSError) as e: + self.m.profiles.set_enabled(mod_id, not on) + self.m.profiles.save() + check.handler_block_by_func(self._on_toggled) + check.set_active(not on) + check.handler_unblock_by_func(self._on_toggled) + self.toast(f"Deploy failed: {e}", 10) + return + name = self.m.library.get(mod_id).name + if res is not None and (res.added or res.removed): + self.toast(f"{'Enabled' if on else 'Disabled'} {name}") + self._after_change(res) + + def _move_selected(self, delta: int) -> None: + mid = self._selected_id() + if not mid: + return + order = self.m.profiles.order() + idx = order.index(mid) + new = idx + delta + if 0 <= new < len(order): + self._reorder(mid, new) + + def _reorder(self, mod_id: str, new_index: int) -> None: + try: + res = self.m.move(mod_id, new_index) + except (DeployError, OSError) as e: + self.toast(f"Reorder failed: {e}", 10) + return + self._fill_list() + self._after_change(res) + + def _on_row_drop(self, value, target_id: str) -> bool: + order = self.m.profiles.order() + if not isinstance(value, str) or value == target_id or value not in order or target_id not in order: + return False + self._reorder(value, order.index(target_id)) + return True + + # -- profiles ------------------------------------------------------------------------------------------ + def _on_profile_selected(self, dd, _p) -> None: + if self._syncing_profiles: + return + item = dd.get_selected_item() + if not item or item.get_string() == self.m.profiles.active: + return + try: + self.m.switch_profile(item.get_string()) + except (DeployError, OSError) as e: + self.toast(f"Could not switch profile: {e}", 10) + self.refresh_all() + self.toast(f"Profile: {self.m.profiles.active}") + + def _new_profile(self) -> None: + def make(name: str): + try: + self.m.profiles.create(name, copy_from=self.m.profiles.active) + self.m.profiles.save() + self.m.switch_profile(name) + except Exception as e: + self.toast(str(e)) + self.refresh_all() + prompt_text(self, "New profile", "", "Create", make) + + def _rename_profile(self) -> None: + def rename(name: str): + try: + self.m.profiles.rename(self.m.profiles.active, name) + self.m.profiles.save() + except Exception as e: + self.toast(str(e)) + self._fill_profiles() + prompt_text(self, "Rename profile", self.m.profiles.active, "Rename", rename) + + def _delete_profile(self) -> None: + name = self.m.profiles.active + + def go(): + try: + self.m.profiles.delete(name) + self.m.profiles.save() + self.m.switch_profile(self.m.profiles.active) + except Exception as e: + self.toast(str(e)) + self.refresh_all() + if len(self.m.profiles.names) <= 1: + self.toast("Cannot delete the last profile") + return + confirm(self, f"Delete profile “{name}”?", "Mods themselves are kept.", "Delete", go, destructive=True) + + # -- deploy --------------------------------------------------------------------------------------------- + + + + + def _check_updates_all(self) -> None: + def work(): + from ..core.nexus import KeyStore, NexusClient, NxmLink + client = NexusClient(KeyStore(self.m.paths.config_dir / "nexus.json").get()) + updates = [] + for mod in self.m.library.mods.values(): + if mod.nexus_mod_id: + try: + info = client.mod_info(NxmLink(game="monsterhunterwilds", mod_id=int(mod.nexus_mod_id), file_id=0)) + if info.get("version") and info["version"] != mod.version: + updates.append(mod.name) + except Exception: + pass + return updates + + def done(updates, err): + if err: + self.toast(f"Update check failed: {err}") + elif updates: + self.toast("Updates available for: " + ", ".join(updates), 10) + else: + self.toast("All Nexus mods are up to date.") + + self.toast("Checking for updates...") + from .tasks import run_async + run_async(work, done) + + def _undeploy_all(self) -> None: + def go(): + try: + res = self.m.undeploy_all() + except (DeployError, OSError) as e: + self.toast(f"Undeploy failed: {e}", 10) + return + self.toast(f"Removed {res.removed} managed file(s); profile selection is unchanged") + self._after_change(res) + confirm(self, "Undeploy all mods?", + "Every file the manager placed in the game folder is removed and any files it backed up are restored. " + "Your library and profiles are kept.", "Undeploy", go, destructive=True) + + def _open_setup(self) -> None: + GameSetupWindow(self, self.m, self.refresh_all).present() + + def _open_prefs(self) -> None: + PreferencesWindow(self, self.m, self.keystore, self._prefs_changed).present() + + def _prefs_changed(self) -> None: + if self.m.settings.game_dir and (not self.m.game_dir or str(self.m.game_dir) != self.m.settings.game_dir): + self._startup() + + def _choose_game_folder(self) -> None: + d = Gtk.FileDialog(title="Select the MonsterHunterWilds folder") + + def done(dlg, res): + try: + f = dlg.select_folder_finish(res) + except GLib.Error: + return + path = Path(f.get_path()) + if not (path / "MonsterHunterWilds.exe").exists(): + self.toast("That folder does not contain MonsterHunterWilds.exe") + return + self.m.set_game_dir(str(path)) + self._startup() + d.select_folder(self, None, done) + + def _show_conflicts(self) -> None: + confs = self.m.conflicts() + name = lambda i: self.m.library.get(i).name if self.m.library.get(i) else i + kinds = {"loose": "Loose file", "pak-pak": "PAK vs PAK", "pak-loose": "PAK vs loose file"} + text = "\n\n".join(f"{kinds[c.kind]}: {c.key}\n winner: {name(c.winner)}\n overridden: " + f"{', '.join(name(i) for i in c.losers)}" for c in confs) \ + or "No conflicts between enabled mods." + TextDialog(self, f"Conflicts ({len(confs)})", text).present() + + # -- import --------------------------------------------------------------------------------------------------- + def _import_dialog(self) -> None: + d = Gtk.FileDialog(title="Import mod archives") + flt = Gtk.FileFilter() + flt.set_name("Mod archives") + for p in ARCHIVE_PATTERNS: + flt.add_pattern(p) + allf = Gtk.FileFilter() + allf.set_name("All files") + allf.add_pattern("*") + filters = Gio.ListStore.new(Gtk.FileFilter) + filters.append(flt) + filters.append(allf) + d.set_filters(filters) + d.set_default_filter(flt) + + def done(dlg, res): + try: + files = dlg.open_multiple_finish(res) + except GLib.Error: + return + self.import_paths([Path(files.get_item(i).get_path()) for i in range(files.get_n_items())]) + d.open_multiple(self, None, done) + + def _on_files_dropped(self, _t, value, _x, _y) -> bool: + paths = [Path(f.get_path()) for f in value.get_files() if f.get_path()] + if not paths: + return False + self.import_paths(paths) + return True + + def import_paths(self, paths: list[Path]) -> None: + if not self.m.deployer: + self.toast("Set up the game folder first") + return + self._busy += 1 + self.toast(f"Importing {len(paths)} item(s)…", 2) + + def work(): + mods, errors = [], [] + for p in paths: + try: + mods.append(self.m.import_source(p)) + except (LibraryError, OSError) as e: + errors.append(f"{p.name}: {e}") + return mods, errors + + def done(result, err): + self._busy -= 1 + if err: + self.toast(f"Import failed: {err}", 10) + return + mods, errors = result + self.refresh_all() + if mods: + self.toast(f"Imported {', '.join(m.name for m in mods[:3])}" + (" …" if len(mods) > 3 else "")) + self.listbox.select_row(self.rows[mods[-1].id]) + for e in errors[:3]: + self.toast(e, 10) + run_async(work, done) + + # -- dll decision / delete ----------------------------------------------------------------------------------------- + def _choose_dll(self, mod_id: str) -> None: + def chosen(rid: str): + if rid == "cancel": + return + self.m.update_mod(mod_id, dll_choice=rid) + try: + res = self.m.deploy() if self.m.profiles.is_enabled(mod_id) and self.m.settings.auto_deploy else None + except (DeployError, OSError) as e: + self.toast(f"Deploy failed: {e}", 10) + res = None + self._after_change(res) + alert(self, "What is this .dll?", + "A .dll at the top level of a mod could be a REFramework plugin (goes in reframework/plugins) " + "or a game-root DLL such as a proxy loader. The manager will not guess.", + [("cancel", "Cancel", None), ("skip", "Ignore it", None), ("root", "Game root", None), + ("plugin", "REFramework plugin", None)], chosen) + + def _delete_mod(self, mod_id: str) -> None: + mod = self.m.library.get(mod_id) + if not mod: + return + + def go(): + try: + self.m.delete_mod(mod_id) + except (DeployError, OSError, LibraryError) as e: + self.toast(f"Could not delete: {e}", 10) + return + self.refresh_all() + self.toast(f"Deleted {mod.name}") + confirm(self, f"Delete “{mod.name}”?", "It is undeployed and removed from the library, including its files.", + "Delete", go, destructive=True) + + # -- nxm:// -------------------------------------------------------------------------------------------------------------- + def handle_nxm(self, url: str) -> None: + try: + link = nexus.parse_nxm(url) + except nexus.NexusError as e: + self.toast(str(e), 8) + return + key = self.keystore.get() + if not key: + self.toast("Set your Nexus API key in Preferences first", 8) + self._open_prefs() + return + if not self.m.deployer: + self.toast("Set up the game folder first") + return + client = nexus.NexusClient(key) + self.toast(f"Downloading mod {link.mod_id} from Nexus…", 3) + self._busy += 1 + + dl_id = f"{link.mod_id}-{link.file_id}" + dl_row = self.dl_manager.add_download(dl_id, f"Mod {link.mod_id}") + + def work(): + info = {} + try: + info = nexus.prefill(link, client.mod_info(link)) + except nexus.NexusError: + info = nexus.prefill(link, {}) # metadata is a nicety; the download is what matters + + if info.get("name"): + GLib.idle_add(dl_row.label.set_text, info["name"]) + + links = client.download_links(link) + if not links: + raise nexus.NexusError("Nexus returned no download servers") + + def progress_cb(downloaded, total): + GLib.idle_add(dl_row.update_progress, downloaded, total) + + archive = nexus.download(links[0]["URI"], self.m.paths.downloads_dir, progress=progress_cb) + + GLib.idle_add(dl_row.set_status, "Importing...") + mod = self.m.import_source(archive, info) + pic = info.get("picture_url") + if pic and not mod.screenshot: + dest = mod.files_root / "nexus-thumbnail.jpg" + if nexus.fetch_thumbnail(pic, dest): + self.m.update_mod(mod.id, screenshot="nexus-thumbnail.jpg") + return mod, client.limits.summary() + + def done(result, err): + self._busy -= 1 + if err: + dl_row.set_status("Failed") + err_str = GLib.markup_escape_text(str(err)) + self.toast(f"Nexus download failed: {err_str}", 10) + return + dl_row.set_status("Complete") + mod, quota = result + self.refresh_all() + self.toast(f"Imported {mod.name}" + (f" · {quota}" if quota else ""), 8) + if mod.id in self.rows: + self.listbox.select_row(self.rows[mod.id]) + run_async(work, done) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..518f8b2 --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,70 @@ +import hashlib +import os +import struct +import tempfile +import unittest +import zipfile +from pathlib import Path + +from mhwmm.core.paks import path_hash, pak_name +from mhwmm.core.paths import Paths + + +def snapshot(root: Path) -> dict: + """Byte-level picture of a tree: files by hash, symlinks by target, and directories.""" + snap = {} + for dirpath, dirnames, filenames in os.walk(root): + for n in dirnames + filenames: + p = Path(dirpath) / n + rel = str(p.relative_to(root)) + if p.is_symlink(): + snap[rel] = ("l", os.readlink(p)) + elif p.is_dir(): + snap[rel] = ("d",) + else: + snap[rel] = ("f", hashlib.sha256(p.read_bytes()).hexdigest()) + return snap + + +def make_game(root: Path, ceiling: int = 15) -> Path: + game = root / "game" + game.mkdir() + for chain in ("main", "sub"): + for n in range(1, ceiling + 1): + (game / pak_name(chain, n)).write_bytes(f"vanilla-{chain}-{n}".encode()) + (game / "re_chunk_000.pak").write_bytes(b"base") + (game / "MonsterHunterWilds.exe").write_bytes(b"exe") + return game + + +def write_tree(root: Path, files: dict[str, bytes | str]) -> Path: + for rel, data in files.items(): + p = root / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_bytes(data if isinstance(data, bytes) else data.encode()) + return root + + +def make_kpka(paths: list[str]) -> bytes: + """Minimal KPKA with entries keyed by the path hashes of `paths`.""" + out = b"KPKA" + struct.pack(" Path: + with zipfile.ZipFile(path, "w") as zf: + for name, data in files.items(): + zf.writestr(name, data) + return path + + +class TmpCase(unittest.TestCase): + def setUp(self): + self._td = tempfile.TemporaryDirectory() + self.addCleanup(self._td.cleanup) + self.tmp = Path(self._td.name) + self.paths = Paths(self.tmp / "data", self.tmp / "config") + self.paths.ensure() diff --git a/tests/test_deploy.py b/tests/test_deploy.py new file mode 100644 index 0000000..6403ecb --- /dev/null +++ b/tests/test_deploy.py @@ -0,0 +1,211 @@ +import os +import unittest +from pathlib import Path + +from mhwmm.core import paks +from mhwmm.core.deploy import DeployError, DeployMod, Deployer, build_desired, validate_dest +from mhwmm.core.layout import classify +from tests.helpers import TmpCase, make_game, make_kpka, snapshot, write_tree + + +class DeployBase(TmpCase): + def setUp(self): + super().setUp() + self.game = make_game(self.tmp) + self.baseline = paks.capture_baseline(self.game) + paks.save_baseline(self.paths.baseline_file, self.baseline) + + def mod(self, mid: str, files: dict, **kw) -> DeployMod: + root = write_tree(self.paths.mods_dir / mid / "files", files) + return DeployMod(mid, tuple(classify(root, **kw).entries)) + + def deployer(self, mode="symlink") -> Deployer: + return Deployer(self.game, self.paths, mode) + + def deploy(self, d: Deployer, mods): + return d.apply(d.plan(build_desired(mods, self.baseline))) + + +class RoundTrip(DeployBase): + def _roundtrip(self, mode): + # a pre-existing unmanaged file that a mod will shadow + write_tree(self.game, {"natives/STM/shadowed.txt": "original"}) + before = snapshot(self.game) + mods = [ + self.mod("a", {"natives/STM/a.bin": "A", "natives/STM/shadowed.txt": "mod", + "reframework/autorun/a.lua": "print(1)", "a.pak": "P1"}), + self.mod("b", {"natives/STM/deep/dir/b.bin": "B", "b.pak": "P2", + "sub.sub_000.pak.patch_099.pak": "S"}), + ] + d = self.deployer(mode) + res = self.deploy(d, mods) + self.assertGreater(res.added, 0) + self.assertTrue((self.game / "natives/STM/deep/dir/b.bin").exists()) + self.assertEqual((self.game / "natives/STM/shadowed.txt").read_text(), "mod") + self.assertTrue((self.game / "re_chunk_000.pak.patch_016.pak").exists()) + self.assertTrue((self.game / "re_chunk_000.pak.patch_017.pak").exists()) + self.assertTrue((self.game / "re_chunk_000.pak.sub_000.pak.patch_016.pak").exists()) + self.assertNotEqual(before, snapshot(self.game)) + d.undeploy_all() + self.assertEqual(before, snapshot(self.game)) + self.assertEqual(d.manifest()["entries"], {}) + + def test_roundtrip_symlink(self): + self._roundtrip("symlink") + + def test_roundtrip_hardlink(self): + self._roundtrip("hardlink") + + def test_roundtrip_copy(self): + self._roundtrip("copy") + + def test_incremental_disable_prunes_only_our_dirs(self): + (self.game / "natives").mkdir() # pre-existing, not ours + before = snapshot(self.game) + a = self.mod("a", {"natives/STM/x/a.bin": "A"}) + d = self.deployer() + self.deploy(d, [a]) + self.deploy(d, []) + self.assertEqual(before, snapshot(self.game)) + self.assertTrue((self.game / "natives").is_dir()) + + +class Conflicts(DeployBase): + def test_later_mod_wins_and_reorder_flips(self): + a = self.mod("a", {"natives/STM/shared.bin": "A"}) + b = self.mod("b", {"natives/STM/Shared.bin": "B"}) # case differs + des = build_desired([a, b], self.baseline) + self.assertEqual(len(des.conflicts), 1) + self.assertEqual(des.conflicts[0].winner, "b") + self.assertEqual(des.conflicts[0].losers, ["a"]) + des = build_desired([b, a], self.baseline) + self.assertEqual(des.conflicts[0].winner, "a") + d = self.deployer() + self.deploy(d, [a, b]) + self.assertEqual((self.game / "natives/STM/Shared.bin").read_text(), "B") + self.deploy(d, [b, a]) + self.assertEqual((self.game / "natives/STM/shared.bin").read_text(), "A") + self.assertFalse((self.game / "natives/STM/Shared.bin").exists()) + + def test_conflicts_span_pak_and_loose(self): + loose = "natives/STM/player/mod.bin" + pak_a = make_kpka([loose, "natives/stm/other.bin"]) + pak_b = make_kpka(["natives/stm/other.bin"]) + a = self.mod("a", {"a.pak": pak_a}) + b = self.mod("b", {"b.pak": pak_b}) + c = self.mod("c", {loose: "x"}) + des = build_desired([a, b, c], self.baseline) + kinds = {(x.kind, tuple(x.mods)): x for x in des.conflicts} + self.assertIn(("pak-pak", ("a", "b")), kinds) + self.assertEqual(kinds[("pak-pak", ("a", "b"))].winner, "b") + pl = kinds[("pak-loose", ("a", "c"))] + self.assertEqual(pl.winner, "c") # loose loader beats PAK contents + + +class Safety(DeployBase): + def test_modified_file_is_never_deleted(self): + a = self.mod("a", {"natives/STM/a.bin": "A"}) + d = self.deployer("copy") + self.deploy(d, [a]) + target = self.game / "natives/STM/a.bin" + target.write_text("user edited") + plan = d.plan(build_desired([], self.baseline)) + self.assertTrue(plan.warnings) + d.apply(plan) + self.assertEqual(target.read_text(), "user edited") + self.assertEqual(d.manifest()["entries"], {}) + + def test_foreign_pak_is_never_deleted(self): + d = self.deployer() + stranger = self.game / "re_chunk_000.pak.patch_016.pak" + stranger.write_bytes(b"not ours") + self.deploy(d, []) + d.undeploy_all() + self.assertEqual(stranger.read_bytes(), b"not ours") + + def test_reframework_data_never_managed(self): + m = self.mod("a", {"reframework/data/settings.json": "x", "reframework/autorun/a.lua": "y"}) + self.assertTrue(all("data" not in e.dest for e in m.entries)) + self.assertIsNotNone(validate_dest("reframework/data/x.json")) + self.assertIsNotNone(validate_dest("../evil")) + self.assertIsNotNone(validate_dest("/etc/passwd")) + self.assertIsNotNone(validate_dest("natives/../../x")) + self.assertIsNotNone(validate_dest("MonsterHunterWilds.exe")) + self.assertIsNone(validate_dest("natives/STM/a.bin")) + + def test_rollback_on_midway_failure(self): + good = self.mod("a", {"natives/STM/a.bin": "A", "natives/STM/z.bin": "Z"}) + d = self.deployer() + plan = d.plan(build_desired([good], self.baseline)) + os.unlink(good.entries[-1].src) # last add will fail: source vanished + before = snapshot(self.game) + with self.assertRaises(DeployError): + d.apply(plan) + self.assertEqual(before, snapshot(self.game)) + self.assertEqual(d.manifest()["entries"], {}) + + def test_replacing_keeps_original_backup(self): + write_tree(self.game, {"natives/STM/s.txt": "orig"}) + before = snapshot(self.game) + d = self.deployer() + self.deploy(d, [self.mod("a", {"natives/STM/s.txt": "A"})]) + self.deploy(d, [self.mod("a", {"natives/STM/s.txt": "A"}), self.mod("b", {"natives/STM/s.txt": "B"})]) + self.assertEqual((self.game / "natives/STM/s.txt").read_text(), "B") + d.undeploy_all() + self.assertEqual(before, snapshot(self.game)) + + def test_dll_choice(self): + root = write_tree(self.paths.mods_dir / "d" / "files", {"thing.dll": "x"}) + self.assertEqual(len(classify(root).ambiguous), 1) + self.assertEqual(classify(root, "plugin").entries[0].dest, "reframework/plugins/thing.dll") + self.assertEqual(classify(root, "root").entries[0].dest, "thing.dll") + self.assertTrue(classify(root, "skip").empty) + + +class PakUpdate(DeployBase): + def test_slots_start_above_ceiling_in_load_order(self): + a = self.mod("a", {"a1.pak": "1", "a2.pak": "2"}) + b = self.mod("b", {"b.pak": "3"}) + slots = paks.allocate([(m.id, list(m.entries)) for m in (a, b)], self.baseline) + self.assertEqual([(s.mod_id, s.number) for s in slots], [("a", 16), ("a", 17), ("b", 18)]) + + def test_title_update_reslots(self): + d = self.deployer() + a = self.mod("a", {"a.pak": "AAA", "natives/STM/a.bin": "A"}) + self.deploy(d, [a]) + self.assertEqual(d.owned_paks(), {"re_chunk_000.pak.patch_016.pak"}) + self.assertFalse(paks.detect_update(self.game, self.baseline, d.owned_paks()).updated) + + # Steam ships patch_016 over our symlink and patch_017 on the sub chain + (self.game / "re_chunk_000.pak.patch_016.pak").unlink() + (self.game / "re_chunk_000.pak.patch_016.pak").write_bytes(b"vanilla-16") + (self.game / "re_chunk_000.pak.sub_000.pak.patch_016.pak").write_bytes(b"vanilla-sub-16") + rep = paks.detect_update(self.game, self.baseline, d.owned_paks()) + self.assertTrue(rep.updated) + + d.apply(d.plan(d.desired_from_manifest(exclude_kind="pak"))) + self.assertEqual((self.game / "re_chunk_000.pak.patch_016.pak").read_bytes(), b"vanilla-16") + self.assertTrue((self.game / "natives/STM/a.bin").is_symlink()) # loose files untouched + + self.baseline = paks.capture_baseline(self.game, d.owned_paks()) + paks.save_baseline(self.paths.baseline_file, self.baseline) + self.assertEqual(self.baseline.ceiling("main"), 16) + self.assertEqual(self.baseline.ceiling("sub"), 16) + self.deploy(d, [a]) + self.assertTrue((self.game / "re_chunk_000.pak.patch_017.pak").is_symlink()) + self.assertEqual(d.owned_paks(), {"re_chunk_000.pak.patch_017.pak"}) + d.undeploy_all() + self.assertEqual((self.game / "re_chunk_000.pak.patch_016.pak").read_bytes(), b"vanilla-16") + + def test_synthetic_patch_016_from_outside_is_detected(self): + d = self.deployer() + (self.game / "re_chunk_000.pak.patch_016.pak").write_bytes(b"steam") + self.assertTrue(paks.detect_update(self.game, self.baseline, d.owned_paks()).updated) + + def test_baseline_ceiling(self): + self.assertEqual(self.baseline.ceiling("main"), 15) + self.assertEqual(self.baseline.ceiling("sub"), 15) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_integrations.py b/tests/test_integrations.py new file mode 100644 index 0000000..5a9a993 --- /dev/null +++ b/tests/test_integrations.py @@ -0,0 +1,165 @@ +import io +import json +import unittest +import zipfile +from pathlib import Path + +from mhwmm.core import loader, nexus, steam +from tests.helpers import TmpCase, make_zip + +VDF = '''"libraryfolders" +{ +\t"0" +\t{ +\t\t"path"\t\t"/tmp/a b" +\t\t"apps" { "2246340" "1" } +\t} +\t"1" { "path" "/mnt/games" } +} +''' + + +class VDFTests(unittest.TestCase): + def test_parse_and_case_insensitive_get(self): + d = steam.parse_vdf(VDF) + self.assertEqual(steam.vdf_get(d, "LibraryFolders", "1", "PATH"), "/mnt/games") + + def test_roundtrip_dump(self): + d = steam.parse_vdf(VDF) + self.assertEqual(steam.parse_vdf(steam.dump_vdf(d)), d) + + def test_escapes_and_comments(self): + d = steam.parse_vdf('// hi\n"a" "x\\"y\\\\z"\n') + self.assertEqual(d["a"], 'x"y\\z') + + def test_errors(self): + for bad in ('"a" {', '}', '{ "a" "b" }'): + with self.assertRaises(steam.VDFError): + steam.parse_vdf(bad) + + def test_real_files_on_this_machine(self): + root = Path.home() / ".steam/steam/steamapps" + if not (root / "appmanifest_2246340.acf").is_file(): + self.skipTest("Monster Hunter Wilds not installed here") + acf = steam.vdf_get(steam.read_vdf(root / "appmanifest_2246340.acf"), "AppState") + self.assertEqual(acf["appid"], "2246340") + self.assertTrue(acf["installdir"]) + self.assertTrue(steam.library_folders(root.parent)) + found = steam.find_app() + self.assertIsNotNone(found) + self.assertTrue((found.game_dir / "MonsterHunterWilds.exe").exists()) + + def test_launch_options(self): + req = steam.REQUIRED_LAUNCH_OPTIONS + self.assertTrue(steam.launch_options_ok(req)) + self.assertFalse(steam.launch_options_ok("")) + self.assertFalse(steam.launch_options_ok("mangohud %command%")) + self.assertEqual(steam.suggest_launch_options(""), req) + self.assertEqual(steam.suggest_launch_options("mangohud %command%"), + 'WINEDLLOVERRIDES="dinput8.dll=n,b" mangohud %command%') + self.assertEqual(steam.suggest_launch_options('WINEDLLOVERRIDES="winmm=n" %command%'), + 'WINEDLLOVERRIDES="winmm=n;dinput8.dll=n,b" %command%') + self.assertEqual(steam.suggest_launch_options(req), req) + + +class LocalConfigTests(TmpCase): + def test_read_and_write_launch_options(self): + lc = self.tmp / "localconfig.vdf" + lc.write_text('"UserLocalConfigStore"\n{\n\t"Software"\n\t{\n\t\t"Valve"\n\t\t{\n\t\t\t"Steam"\n\t\t\t{\n' + '\t\t\t\t"apps"\n\t\t\t\t{\n\t\t\t\t\t"2246340"\n\t\t\t\t\t{\n\t\t\t\t\t\t"LaunchOptions"\t\t"-x"\n' + '\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n') + self.assertEqual(steam.read_launch_options(lc), "-x") + orig = steam.steam_running + try: + steam.steam_running = lambda: True + with self.assertRaises(steam.SteamRunningError): + steam.write_launch_options(lc, "y") + self.assertEqual(steam.read_launch_options(lc), "-x") + steam.steam_running = lambda: False + bak = steam.write_launch_options(lc, steam.REQUIRED_LAUNCH_OPTIONS) + finally: + steam.steam_running = orig + self.assertTrue(bak.exists()) + self.assertEqual(steam.read_launch_options(lc), steam.REQUIRED_LAUNCH_OPTIONS) + + +class LoaderTests(TmpCase): + def test_config_patch_preserves_other_keys(self): + cfg = self.tmp / loader.CONFIG_NAME + cfg.write_text("Foo=1\nlooseFileLoader_enabled=false\nBar=2\n") + loader.enable_loose_loader(self.tmp) + self.assertEqual(cfg.read_text(), "Foo=1\nlooseFileLoader_enabled=true\nBar=2\n") + + def test_config_created_when_absent(self): + loader.enable_loose_loader(self.tmp) + self.assertTrue(loader.loose_loader_enabled(self.tmp)) + + def test_status_checks(self): + st = loader.status(self.tmp, None) + self.assertEqual([c.ok for c in st.checks], [False, False, False]) + (self.tmp / "dinput8.dll").write_bytes(b"x") + loader.enable_loose_loader(self.tmp) + st = loader.status(self.tmp, steam.REQUIRED_LAUNCH_OPTIONS) + self.assertTrue(st.all_ok) + + def test_install_from_release(self): + blob = io.BytesIO() + with zipfile.ZipFile(blob, "w") as zf: + zf.writestr("dinput8.dll", b"MZfake") + zf.writestr("reframework/x", b"ignored") + rel = json.dumps({"tag_name": "nightly-1", "assets": [ + {"name": "MHWILDS.zip", "browser_download_url": "https://example/MHWILDS.zip"}]}).encode() + fetch = lambda url: rel if url == loader.RELEASES_API else blob.getvalue() + self.assertEqual(loader.install_reframework(self.tmp, fetch), "nightly-1") + self.assertEqual((self.tmp / "dinput8.dll").read_bytes(), b"MZfake") + self.assertFalse((self.tmp / "reframework").exists()) + + +class NexusTests(TmpCase): + def test_parse_nxm_with_key_and_expires(self): + l = nexus.parse_nxm("nxm://monsterhunterwilds/mods/123/files/456?key=AbC_-9&expires=1700000000&user_id=1") + self.assertEqual((l.game, l.mod_id, l.file_id, l.key, l.expires), + ("monsterhunterwilds", 123, 456, "AbC_-9", "1700000000")) + + def test_parse_rejects_garbage(self): + for bad in ("https://x/mods/1/files/2", "nxm://g/mods/x/files/2", "nxm:///mods/1/files/2", "nxm://g/mods/1"): + with self.assertRaises(nexus.NexusError, msg=bad): + nexus.parse_nxm(bad) + + def test_client_passes_key_and_records_limits(self): + seen = {} + + class Resp: + headers = {"x-rl-daily-remaining": "99", "x-rl-daily-limit": "100", + "x-rl-hourly-remaining": "9", "x-rl-hourly-limit": "10"} + def read(self): return b'[{"URI": "https://cdn/x.zip"}]' + + def opener(req): + seen["url"], seen["headers"] = req.full_url, dict(req.header_items()) + return Resp() + + c = nexus.NexusClient("SECRET", opener) + out = c.download_links(nexus.parse_nxm("nxm://monsterhunterwilds/mods/1/files/2?key=K&expires=5")) + self.assertEqual(out[0]["URI"], "https://cdn/x.zip") + self.assertIn("/v1/games/monsterhunterwilds/mods/1/files/2/download_link.json?key=K&expires=5", seen["url"]) + self.assertEqual(seen["headers"]["Apikey"], "SECRET") + self.assertIn("9/10 hourly", c.limits.summary()) + + def test_no_key_is_a_clear_error(self): + with self.assertRaises(nexus.NexusError): + nexus.NexusClient("").mod_info(nexus.parse_nxm("nxm://monsterhunterwilds/mods/1/files/2")) + + def test_keystore_file_fallback_is_0600(self): + ks = nexus.KeyStore(self.tmp / "secrets.json") + nexus._secret_mod = lambda: None + ks.set("abc") + self.assertEqual(ks.get(), "abc") + self.assertEqual((self.tmp / "secrets.json").stat().st_mode & 0o777, 0o600) + + def test_download_refuses_http(self): + with self.assertRaises(nexus.NexusError): + nexus.download("http://x/y.zip", self.tmp) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_library.py b/tests/test_library.py new file mode 100644 index 0000000..58a846c --- /dev/null +++ b/tests/test_library.py @@ -0,0 +1,167 @@ +import unittest + +from mhwmm.core import archive, layout +from mhwmm.core.library import Library, LibraryError +from mhwmm.core.manager import Manager +from mhwmm.core.config import Settings +from mhwmm.core.modinfo import parse_modinfo +from mhwmm.core.profiles import Profiles +from tests.helpers import TmpCase, make_game, make_zip, write_tree +import zipfile + + +class ArchiveTests(TmpCase): + def test_zip_slip_rejected(self): + z = make_zip(self.tmp / "evil.zip", {"../../evil.txt": "x"}) + with self.assertRaises(archive.ArchiveError): + archive.extract(z, self.tmp / "out") + self.assertFalse((self.tmp / "evil.txt").exists()) + + def test_absolute_and_drive_paths_rejected(self): + for name in ("/etc/evil", "C:/evil", "a/../../evil", "..\\evil"): + z = make_zip(self.tmp / "e.zip", {name: "x"}) + with self.assertRaises(archive.ArchiveError, msg=name): + archive.extract(z, self.tmp / "out2") + + def test_symlink_entry_rejected(self): + p = self.tmp / "s.zip" + with zipfile.ZipFile(p, "w") as zf: + info = zipfile.ZipInfo("link") + info.external_attr = 0o120777 << 16 + zf.writestr(info, "/etc/passwd") + with self.assertRaises(archive.ArchiveError): + archive.extract(p, self.tmp / "o") + + def test_sniff_ignores_extension(self): + z = make_zip(self.tmp / "actually.rar", {"a": "b"}) + self.assertEqual(archive.sniff(z), "zip") + (self.tmp / "junk.zip").write_bytes(b"nope") + with self.assertRaises(archive.ArchiveError): + archive.sniff(self.tmp / "junk.zip") + + def test_good_zip_extracts(self): + z = make_zip(self.tmp / "ok.zip", {"natives/STM/a.bin": "A"}) + archive.extract(z, self.tmp / "ok") + self.assertEqual((self.tmp / "ok/natives/STM/a.bin").read_text(), "A") + + +class LayoutTests(TmpCase): + def classify(self, files): + return layout.classify(write_tree(self.tmp / f"t{len(list(self.tmp.iterdir()))}", files)) + + def dests(self, c): + return sorted(e.dest for e in c.entries) + + def test_natives_any_depth_case_insensitive(self): + c = self.classify({"Wrapper/Inner/Natives/STM/x.bin": "1", "Wrapper/Inner/readme.txt": "r"}) + self.assertEqual(self.dests(c), ["natives/STM/x.bin"]) + + def test_bare_stm(self): + self.assertEqual(self.dests(self.classify({"STM/a/b.bin": "1"})), ["natives/STM/a/b.bin"]) + + def test_reframework_preserved(self): + c = self.classify({"reframework/autorun/a.lua": "1", "reframework/plugins/p.dll": "2", + "reframework/data/keep.json": "3"}) + self.assertEqual(self.dests(c), ["reframework/autorun/a.lua", "reframework/plugins/p.dll"]) + self.assertTrue(c.notes) + + def test_pak_near_root(self): + c = self.classify({"paks/re_chunk_000.pak.sub_000.pak.patch_020.pak": "1", "x.pak": "2"}) + self.assertEqual(sorted((e.kind, e.chain) for e in c.entries), [("pak", "main"), ("pak", "sub")]) + + def test_loose_lua_and_ambiguous_dll(self): + c = self.classify({"script.lua": "1", "mystery.dll": "2"}) + self.assertEqual(self.dests(c), ["reframework/autorun/script.lua"]) + self.assertEqual(len(c.ambiguous), 1) + + def test_mixed_kinds_in_one_mod(self): + c = self.classify({"natives/STM/a.bin": "1", "reframework/autorun/a.lua": "2", "m.pak": "3"}) + self.assertEqual({e.kind for e in c.entries}, {"loose", "reframework", "pak"}) + + def test_junk_only_is_empty(self): + self.assertTrue(self.classify({"readme.txt": "hi", "modinfo.ini": "name=x"}).empty) + + +class LibraryTests(TmpCase): + def test_import_reads_modinfo_and_screenshot(self): + z = make_zip(self.tmp / "Cool Mod-123-1-0-99.zip", { + "modinfo.ini": "name=Cool Mod\nversion=1.2\nauthor=me\ncategory=Armor\ndescription=hello", + "screenshot.png": b"\x89PNG", + "natives/STM/a.bin": "A"}) + lib = Library(self.paths) + mod = lib.import_source(z) + self.assertEqual((mod.name, mod.version, mod.category), ("Cool Mod", "1.2", "Armor")) + self.assertTrue(mod.screenshot_path and mod.screenshot_path.name == "screenshot.png") + lib2 = Library(self.paths) + lib2.scan() + self.assertIn(mod.id, lib2.mods) + self.assertEqual(lib2.categories(), ["Armor"]) + + def test_import_rejects_empty_and_leaves_no_debris(self): + z = make_zip(self.tmp / "junk.zip", {"readme.txt": "x"}) + lib = Library(self.paths) + with self.assertRaises(LibraryError): + lib.import_source(z) + self.assertEqual([p for p in self.paths.mods_dir.iterdir()], []) + + def test_duplicate_names_get_unique_ids(self): + lib = Library(self.paths) + ids = {lib.import_source(make_zip(self.tmp / f"m{i}.zip", {"modinfo.ini": "name=Same", "STM/a": "x"})).id + for i in range(3)} + self.assertEqual(len(ids), 3) + + def test_modinfo_tolerant(self): + i = parse_modinfo("\ufeff[~ modinfo ~]\nName = X \n; c\nVersion=2\n") + self.assertEqual((i.name, i.version), ("X", "2")) + + +class ProfileTests(TmpCase): + def test_profiles_roundtrip(self): + p = Profiles(self.paths.profiles_file) + p.reconcile(["a", "b", "c"]) + p.set_enabled("a", True) + p.set_enabled("c", True) + p.move("c", 0) + p.create("Alt", copy_from="Default") + p.switch("Alt") + p.set_enabled("b", True) + p.save() + q = Profiles(self.paths.profiles_file) + self.assertEqual(q.active, "Alt") + self.assertEqual(q.data["Default"]["enabled"], ["a", "c"]) + q.switch("Default") + self.assertEqual(q.enabled_in_order(), ["c", "a"]) + q.reconcile(["a", "b"]) # c vanished from the library + self.assertEqual(q.enabled_in_order(), ["a"]) + + +class ManagerTests(TmpCase): + def test_end_to_end_and_startup_reslot(self): + game = make_game(self.tmp) + s = Settings(game_dir=str(game)) + m = Manager(self.paths, s) + self.assertTrue(m.startup().game_found) + self.assertEqual(m.pak_summary()["main"]["ceiling"], 15) + mod = m.import_source(make_zip(self.tmp / "a.zip", {"modinfo.ini": "name=A", "natives/STM/a.bin": "A", "a.pak": "P"})) + m.set_enabled(mod.id, True) + self.assertTrue((game / "natives/STM/a.bin").is_symlink()) + self.assertEqual(m.pak_summary()["main"]["mod_slots"], [16]) + self.assertFalse(m.pending()) + + # Steam patches while the manager is closed + (game / "re_chunk_000.pak.patch_016.pak").unlink() + (game / "re_chunk_000.pak.patch_016.pak").write_bytes(b"steam") + m2 = Manager(self.paths, Settings(game_dir=str(game))) + rep = m2.startup() + self.assertTrue(any("re-slotted to 017+" in n for n in rep.notes), rep.notes) + self.assertEqual((game / "re_chunk_000.pak.patch_016.pak").read_bytes(), b"steam") + self.assertEqual(m2.pak_summary()["main"]["mod_slots"], [17]) + + m2.set_enabled(mod.id, False) + self.assertFalse((game / "natives").exists()) + m2.delete_mod(mod.id) + self.assertEqual(m2.library.mods, {}) + + +if __name__ == "__main__": + unittest.main()