Initial commit with nexus updates and streamlined UI
This commit is contained in:
commit
f780daecad
41 files changed
+4668
No files matched your search
@@ -0,0 +1,4 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
*.egg-info/
|
||||||
Generated
+10
@@ -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
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<profile version="1.0">
|
||||||
|
<option name="myName" value="Project Default" />
|
||||||
|
<inspection_tool class="PyPep8NamingInspection" enabled="true" level="WEAK WARNING" enabled_by_default="true">
|
||||||
|
<option name="ignoredErrors">
|
||||||
|
<list>
|
||||||
|
<option value="N806" />
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</inspection_tool>
|
||||||
|
</profile>
|
||||||
|
</component>
|
||||||
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectRootManager" version="2">
|
||||||
|
<output url="file://$PROJECT_DIR$/out" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+8
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/MHW-Mod-Manager.iml" filepath="$PROJECT_DIR$/MHW-Mod-Manager.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="GENERAL_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||||
|
<exclude-output />
|
||||||
|
<content url="file://$MODULE_DIR$" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
@@ -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/<id>/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.
|
||||||
@@ -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())
|
||||||
@@ -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
|
||||||
@@ -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/<mod-id>/ 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/<uid>/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/<mod_id>/files/<file_id>?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.
|
||||||
@@ -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"]
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
__version__ = "0.1.0"
|
||||||
|
APP_ID = "ca.schantli.MHWildsModManager"
|
||||||
@@ -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())
|
||||||
Whitespace-only changes.
@@ -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)
|
||||||
@@ -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))
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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/<id>
|
||||||
|
|
||||||
|
@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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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/<mod>/files/<file>?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
|
||||||
@@ -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>\.sub_000\.pak)?\.patch_(?P<n>\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("<I", data[: n * 4]):
|
||||||
|
k = (k * c1) & 0xFFFFFFFF
|
||||||
|
k = ((k << 15) | (k >> 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("<IIQQQQQ") # hash_lower, hash_upper, offset, csize, usize, attrs, checksum (48 B)
|
||||||
|
_pak_cache: dict[tuple[str, int, int], frozenset] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def pak_hashes(path: Path) -> 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("<I", head, 8)[0]
|
||||||
|
if total > 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()
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Whitespace-only changes.
@@ -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", ["<Primary>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
|
||||||
@@ -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
|
||||||
@@ -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}"))
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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", ["<Alt>Up"])
|
||||||
|
app.set_accels_for_action("win.move-down", ["<Alt>Down"])
|
||||||
|
app.set_accels_for_action("win.import", ["<Primary>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)
|
||||||
Whitespace-only changes.
@@ -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("<BBHII", 4, 0, 0, len(paths), 0)
|
||||||
|
for p in paths:
|
||||||
|
lo, up = path_hash(p)
|
||||||
|
out += struct.pack("<IIQQQQQ", lo, up, 0, 0, 0, 0, 0)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def make_zip(path: Path, files: dict[str, bytes | str]) -> 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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
Reference in new issue
Block a user