From 02096887dd1a739431f8efe804ea82db641c0340 Mon Sep 17 00:00:00 2001 From: Zorro909 Date: Wed, 4 Nov 2026 19:13:26 +0000 Subject: [PATCH 001/156] Propose a mod manager: per-version mod sets and a signed index The proposal, the HTML mockup, and the index format the launcher will read from Zorro909/weft-mods-index. --- docs/mod-manager/INDEX_FORMAT.md | 95 ++++++++ docs/mod-manager/PROPOSAL.md | 208 ++++++++++++++++ docs/mod-manager/mockup.html | 403 +++++++++++++++++++++++++++++++ 3 files changed, 706 insertions(+) create mode 100644 docs/mod-manager/INDEX_FORMAT.md create mode 100644 docs/mod-manager/PROPOSAL.md create mode 100644 docs/mod-manager/mockup.html diff --git a/docs/mod-manager/INDEX_FORMAT.md b/docs/mod-manager/INDEX_FORMAT.md new file mode 100644 index 0000000..6016786 --- /dev/null +++ b/docs/mod-manager/INDEX_FORMAT.md @@ -0,0 +1,95 @@ +# Mod index format, v1 + +This file is the contract between Weft Launcher and [`Zorro909/weft-mods-index`](https://github.com/Zorro909/weft-mods-index). The launcher accepts exactly this format. A change that breaks it needs a new `schema` number and a new URL path (`/v2/`). + +## Where it lives + +| File | URL | +|---|---| +| Index | `https://zorro909.github.io/weft-mods-index/v1/index.json` | +| Signature | `https://zorro909.github.io/weft-mods-index/v1/index.json.minisig` | + +- The signature is a minisign signature of the exact bytes of `index.json`. +- The launcher embeds the public key (`keys/index.pub` in the index repo). +- An index whose signature doesn't verify is rejected. The launcher then keeps using its last verified copy. + +## `index.json` + +```jsonc +{ + "schema": 1, + "generated": "2026-11-04T18:00:00Z", // RFC 3339, UTC + + // Optional. ABI hash per Weft release, so the launcher doesn't have to compute it. + "weft": [ + { "tag": "v26.11.1", "commit": "<40 hex>", "abi": "f470b1cbb1f9ad91" } + ], + + "mods": [ + { + "id": "cogwork", // [a-z0-9_]+, equal to the crate name and the zip stem + "name": "Cogwork", + "summary": "Gears, item tubes and engines…", // one line, <= 140 chars + "authors": ["Zorro909"], + "license": "MPL-2.0", // SPDX id, or null + "homepage": "https://github.com/Zorro909/weft-mods/tree/main/cogwork", + "icon": "https://raw.githubusercontent.com/…/icon.png", // PNG, or null + "versions": [ // newest first + { + "version": "0.2.0", + "channel": "release", // "release" | "prerelease" | "experimental" + "tag": "cogwork-v0.2.0", + "published": "2026-11-04T12:00:00Z", + "source": { "repo": "Zorro909/weft-mods", "commit": "<40 hex>" }, + "weft": { "tag": "v26.11.1", "commit": "<40 hex>", "abi": "f470b1cbb1f9ad91" }, + "deps": [{ "id": "base" }], + "platforms": ["windows-x64", "linux-x64", "macos-arm64"], + "asset": { + "name": "cogwork.zip", // always ".zip" + "url": "https://github.com/Zorro909/weft-mods/releases/download/cogwork-v0.2.0/cogwork.zip", + "size": 2642469, + "sha256": "<64 hex, lowercase>" + }, + "provenance": { // or null when no attestation exists + "kind": "github-attestation", + "repo": "Zorro909/weft-mods", + "workflow": ".github/workflows/release.yml", + "ref": "refs/heads/main", + "verified_at": "2026-11-04T18:00:00Z" // when index CI ran `gh attestation verify` + }, + "reviewed": { "by": "Zorro909", "at": "2026-11-04" } + } + ] + } + ], + + "revoked": [ + { "sha256": "<64 hex>", "reason": "Shipped a debug build that deletes saves", "at": "2026-11-05" } + ] +} +``` + +### Rules the launcher enforces + +- `weft[].tag` matches `v[0-9A-Za-z.\-]+`; `id` matches `^[a-z0-9_]+$`. +- `asset.name == id + ".zip"`. +- `asset.url` must start with `https://github.com//releases/download/`. +- `abi` is 16 lowercase hex characters: the FNV-1a-64 hash of `crates/abi/src/lib.rs` at `weft.commit`. +- Unknown fields are ignored. Missing optional fields mean `null`. +- A mod whose `sha256` appears in `revoked` is never installed. If it is already installed, it is turned off. + +## `.mod.json` (one per mod release, for "CI-built" sources) + +A GitHub repo the user adds as a source publishes `.mod.json` next to `.zip` in each release. + +- It holds the mod-level fields (`id`, `name`, `summary`, `authors`, `license`, `homepage`, `icon`) plus a single version's fields at the top level, with `"schema": 1`. +- Its `asset.sha256` must equal both the zip's GitHub `digest` and the sha256 of the downloaded bytes. +- `reviewed` is absent. + +## Platforms + +| Platform | OS and architecture | +|---|---| +| `windows-x64` | Windows x86_64 | +| `linux-x64` | Linux x86_64, glibc | +| `macos-arm64` | macOS on Apple Silicon | diff --git a/docs/mod-manager/PROPOSAL.md b/docs/mod-manager/PROPOSAL.md new file mode 100644 index 0000000..5573987 --- /dev/null +++ b/docs/mod-manager/PROPOSAL.md @@ -0,0 +1,208 @@ +# Mod manager for Weft Launcher: proposal + +Status: draft, 2026-11-04. Nothing is implemented yet. Mockup: [`mockup.html`](mockup.html). Open it in a browser; it's drawn at 1:1 in the launcher's own pixel style. + +Sources: +- Read-only research of the engine (`Weft` @ `7c69c9c`, `master`). +- Read-only research of `Zorro909/weft-mods` (engine pinned at `ff9c712` = `v26.11.1`). +- This repo. + +Engine file references below are relative to the Weft repo. + +--- + +## 1. What we're working with (facts, not proposals) + +### A mod is native code +- A mod is a Rust `cdylib` that exports `weft_mod() -> *const ModDecl` (`crates/sdk/src/lib.rs:103-125`). +- It is distributed as one fat `.zip` holding `.dll`, `lib.so`, `lib.dylib` and `assets/`. The engine picks the library for its own OS (`crates/engine/src/modloader.rs:71-75`). +- It is loaded with `libloading`, so it runs with the player's full privileges (`modloader.rs:309`). There is no sandbox and no permission system. A WASM tier doesn't exist yet. +- **Turned-off mods still run code.** The engine `dlopen`s every library and calls `weft_mod()` before it applies `disabled_mods.txt` (`modloader.rs:305-332` vs `:346`). +- There is **no manifest file**: + - `id`, `version` and `deps` are compiled into the library. + - The display name, description and authors come from lang keys. + - The icon is `assets/textures/gui/icon.png`. + - The file name must equal the crate name (`modloader.rs:328-331`). +- Data-only mods are impossible today: a folder without a library is an error. Resource packs and shader packs are separate and data-only. + +### Compatibility is exact, fragile, and failure is fatal +- `ABI_VERSION` is the FNV-1a-64 hash of the raw text of `crates/abi/src/lib.rs`. A mod loads only if its hash is equal (`modloader.rs:321`). +- Across v0.1.0 → v26.11.1 the hash changed in almost every release. Only 26.10.1 → 26.10.2 kept it. +- A mismatch, a missing symbol, a missing dependency or a panicking `init` **all abort startup**. The result is `exit(1)` with no dialog, and the text only appears in `latest.log` (`app.rs:319`, `main.rs:144-147`). +- One mismatched mod blocks every version it wasn't built for. That includes a turned-off mod (the ABI check runs before the off filter). + +### Where the engine looks +The engine checks these folders in order; for each id, the first copy found wins (`modloader.rs:52-59`, `main.rs:61,118-135`): +1. Every `--mods ` (repeatable). +2. `/mods`, the player's folder. It is **shared by every installed version**. +3. `/run/mods`, the shipped mods such as `base`. + +`--run-dir

` moves data, config and cache under `

`. There is no flag to skip the player's folder. Unknown flags are passed through to mods and **start the game**, so `weft --version` can't be used as a probe (`main.rs:81-87`). + +### How weft-mods releases today +- Tags have the form `-v`. Each GitHub release has exactly one asset, `.zip`. +- RCs and experimental builds are marked `--prerelease`. Experimental tags look like `-v-experimental..`, and their binary reports the same version as the full release. +- The only integrity data is GitHub's own `digest: sha256:…` per asset. There is no checksums file, signature or attestation. +- Compatibility is recorded only as prose in the release notes: `Built against Weft , commit `. +- There is no machine-readable index, and the repo has no LICENSE. + +### What the launcher already does that we can reuse +- It reads the GitHub releases API with ETag caching (`releases.rs`). +- It requires a SHA-256 and verifies it (`install.rs:181-186, 323-341`). +- It restricts download URLs to one repo (`install.rs:187-190`). +- It extracts archives safely and installs atomically. +- It enforces one running game at a time and captures logs per version. +- **`minisign-verify` 0.3 is already in `Cargo.lock`**, pulled in by the updater, so signature checking costs no new dependency. + +--- + +## 2. Design principles + +1. **Mod sets belong to a game version.** The ABI changes almost every release, so a shared mod folder is a trap. Each installed version gets its own set, passed with `--mods`. +2. **The launcher never writes into the player's Weft folder.** This keeps the README promise. Mods live under the launcher's data folder. +3. **"Off" means not passed to the game.** The engine runs code even from turned-off mods, so the launcher turns a mod off by leaving it out of the folder it hands over. It doesn't use `disabled_mods.txt`. +4. **Don't start a launch that is known to fail.** Check the ABI and dependencies before Play. If the game still dies during mod loading, name the mod. +5. **Be honest about trust.** Show what was actually verified. Never show a fake permission list. Always say that native mods can do anything. + +--- + +## 3. Architecture (launcher side) + +### On-disk layout (`%APPDATA%\dev.weft.launcher\…`) + +``` +mods/ + store/.zip content-addressed, each one verified once + sources.json index URL(s) + GitHub repos the user added +profiles// + mods/.zip only the turned-on mods (hardlink, or a copy if that fails) + mods.lock.json every installed mod: id, version, sha256, source, trust, enabled +cache/ + mod-index.json (+ .minisig) + abi/.txt ABI hash per Weft tag, kept forever since tags don't change +``` + +Launch becomes `weft --mods /mods>`. Turning a mod on or off just adds or removes the link. When a version is deleted, its profile goes with it, and unused store entries are garbage-collected. + +### Backend modules (`src-tauri/src/mods/`) + +| Module | Job | +|---|---| +| `abi.rs` | ABI hash of a Weft tag. **Now:** fetch `raw.githubusercontent.com/Weft-Rust/Weft//crates/abi/src/lib.rs` and compute FNV-1a-64 (needs no engine change; verified against v26.10.2 and v26.10.4). **Later:** read a `weft-abi.txt` release asset. | +| `catalog.rs` | Read the signed index, plus `*.mod.json` assets from the GitHub repos the user added, using the same ETag cache as `releases.rs`. Filter by platform, ABI and channel. | +| `verify.rs` | Index signature (minisign), SHA-256, and provenance (§4). | +| `store.rs` | Stream the download (reusing `install.rs`'s download and progress code), verify, check the zip structure, then move it into `store/`. | +| `profile.rs` | Lock file, turning mods on and off, resolving dependencies, the pre-Play check. | + +Tauri commands: `list_mod_catalog`, `get_profile_mods`, `install_mod`, `install_mod_from_file`, `set_mod_enabled`, `remove_mod`, `check_profile`, `copy_profile`. + +### Checking the zip structure (before it is stored) +- Only `.dll`, `lib.so`, `lib.dylib` and `assets/**` are allowed. +- No `..`, absolute paths or symlinks. +- Caps on entry count, uncompressed size and compression ratio (zip bombs). +- The file name is exactly `.zip`, and `` passes `validate_name`. + +The launcher never unpacks the zip to run anything, and **never `dlopen`s a mod to read its metadata**: that would run its code. + +### The pre-Play check +- Every turned-on mod's ABI must equal the version's ABI. +- Every dependency must be turned on, or be `base`. +- If either fails, Play is blocked with the reason and a fix (turn off / find update). +- Also list what is in the player's own `/mods`. The path comes from the `game folder …` log line the launcher already parses. Warn: "these load in every version and can't be checked". This is the one place the launcher *reads* the game folder, and it's a listing only. + +### Diagnosing a failure after Play +On `exit(1)`, scan the version's log for the engine's fixed messages: +- `mod X was built against another mod ABI` +- `loading mod X from` +- `is not a Weft mod` +- `missing dependency` + +Show "X stopped Weft from starting" with Turn off / Find update / Open log (mockup 4). This builds on the existing `lastCrash` UI in `Home.tsx`. + +### Moving to a new Weft version +When a new version is installed, offer "Copy mod set from v26.10.4". Each mod is resolved to the build for the new ABI, and the launcher lists the ones that have no build yet. Because the ABI breaks with nearly every release, this is the feature players will use most. + +--- + +## 4. Trust model: better than "trust me bro" + +What we can and can't promise: +- We **can** prove *where a file came from* and *that it hasn't changed*. +- We **cannot** prove *that it's harmless*. Every tier below still runs native code with the player's privileges, and the UI says so on every install. + +### The trust tiers the UI shows + +| Badge | Meaning | Checks that must pass | +|---|---|---| +| **Reviewed** (green) | In the curated Weft mod index | Index signature ✔, SHA-256 equals the index pin ✔, provenance ✔ (Phase 2) | +| **CI-built** (grey) | From a GitHub repo the user added as a source; built in public CI, not reviewed | SHA-256 equals GitHub `digest` ✔. Phase 2 adds a provenance attestation: built by `release.yml` of repo R at commit C ✔ | +| **Unverified** (red) | Local file, or a source without provenance | Zip structure only. Typed confirmation needed (mockup 3) | +| **Revoked** | The index lists the sha256 as revoked | The launcher turns the mod off on the next refresh and says why | + +### The checks, from cheapest to strongest + +1. **SHA-256 against GitHub `digest`.** Exists today. Proves only that the file wasn't corrupted in transit, because it comes over the same channel as the file. +2. **Checking the zip structure.** Launcher-only, described above. +3. **A curated, signed index** (e.g. a `Weft-Rust/weft-mod-index` repo). + - Each mod version is added through a reviewed PR and pinned by sha256, source repo + commit, Weft tag and ABI. + - CI signs `index.json` with minisign. The public key is embedded in the launcher; that's `minisign-verify`, already in the dependency tree. + - The index also carries a `revoked` list. + - This is the core of the "Reviewed" tier. A compromised mod repo or GitHub account can't change pinned bytes without the index key. +4. **Build provenance** via `actions/attest-build-provenance` in weft-mods CI (Sigstore, keyless). Proves "this zip was built by workflow W of repo R at commit C", which makes "the source is public" checkable. + - Launcher verification options: the GitHub attestations API, or a `.zip.sigstore.json` bundle asset checked with the `sigstore` crate. Bundle support in that crate still needs a maturity check. + - Until then, the index reviewer runs `gh attestation verify` when reviewing, and the index pins the result. +5. **The engine re-checking the hash** (engine change). `--mod-hashes ` makes the engine hash *the copy it loads* (`modloader.rs:307`). That closes the gap between the launcher's check and the actual load, including tampering with the unpack cache. +6. **Later: real untrusted tiers.** Data-only mods (assets plus declarative content, no library), then possibly a WASM "script" tier with a smaller set of capabilities. These are the only options that actually *limit* what a mod can do. + +The UI deliberately has **no permission list**. Native code has no permissions to list, and showing one would be the "trust me bro" we're trying to avoid. + +--- + +## 5. Changes needed outside this repo + +### weft-mods (small; you own it) +1. Publish `.mod.json` next to `.zip`, generated in `tools/ci/publish.sh`. It replaces the prose compatibility record: + ```json + { + "schema": 1, "id": "cogwork", "name": "Cogwork", "summary": "…", "authors": ["Zorro909"], + "license": "MPL-2.0", "version": "0.2.0", "channel": "release", "tag": "cogwork-v0.2.0", + "source": { "repo": "Zorro909/weft-mods", "commit": "cceea78…" }, + "weft": { "tag": "v26.11.1", "commit": "ff9c712…", "abi": "f470b1cbb1f9ad91" }, + "deps": [{ "id": "base" }], + "platforms": ["windows-x64", "linux-x64", "macos-arm64"], + "asset": { "name": "cogwork.zip", "size": 2642469, "sha256": "989b2191…" }, + "icon": "assets/textures/gui/icon.png" + } + ``` +2. Add `actions/attest-build-provenance` to the `publish` jobs. This needs `id-token: write` and `attestations: write`, packing into a known directory instead of `mktemp -d`, and optionally uploading `.zip.sigstore.json`. +3. Add a LICENSE and `license` fields, and an icon for showcase. + +### Weft engine (ranked by value to the launcher) +1. **Don't load turned-off mods, and make mod failures non-fatal.** Check the off list before `dlopen`, then skip and report instead of `Err`. This is a precondition for any trust model. +2. **`--no-user-mods`** (only the `--mods` folders count) and **`--disabled-mods `**. With these, the launcher fully owns the mod set for each version. +3. **Publish `weft-abi.txt`** (or `release.json`) as a release asset, and/or add **`weft --info --json`** that exits before any window or mod loading. +4. **`weft-mod.toml` inside the zip, written by `--pack-mod`**: id, version, ABI, deps, SHA-256 of each library, so tools can read metadata without loading code. +5. **A machine-readable load report** (`logs/mods.json`) and a separate exit code for a mod load failure. +6. **`--mod-hashes `**, the engine-side re-check (§4.5). +7. A less fragile ABI hash (declarations only, or a hand-bumped epoch), and an unpack cache keyed on content hash. + +--- + +## 6. Phases + +| Phase | Scope | Needs | +|---|---|---| +| **1. Mods tab** | Per-version profiles via `--mods`; GitHub repo sources (weft-mods by default); ABI computed from source; pre-Play check; log diagnosis; local-file install with the Unverified flow; warning about the player's own folder | weft-mods change 1 (`mod.json`) | +| **2. Trust** | Signed curated index, Reviewed tier, revocation, provenance verification, "copy mod set to new version" | Index repo + minisign key; weft-mods change 2 | +| **3. Engine integration** | `--no-user-mods`, `--disabled-mods`, `weft-abi.txt`, `mods.json` report, `--mod-hashes` | Engine changes 1–6 | +| **Later** | Data-only mods, WASM tier, server mod-set sync | Engine design work | + +--- + +## 7. Open questions + +1. **Isolation scope.** Per-version mod sets with **shared** saves (proposed), or full "instances" with `--run-dir` (separate saves and config per profile, like MultiMC)? Shared saves are friendlier. The engine already asks when a world's mods are missing. +2. **Index ownership.** Who hosts the curated index (the `Weft-Rust` org?), who reviews, and where does the signing key live: offline, or a CI secret with required reviews? +3. **Reading the player's mods folder.** Is a read-only listing for the warning acceptable, given the README says the launcher never reads game data? +4. **Third-party sources.** Should users be able to add any GitHub repo (CI-built tier), or only use the index plus local files? +5. **Upstream engine changes.** The launcher targets `Weft-Rust/Weft`. Engine changes 1–3 would make Phase 1 much more robust. Are they something you can push upstream? diff --git a/docs/mod-manager/mockup.html b/docs/mod-manager/mockup.html new file mode 100644 index 0000000..20835a5 --- /dev/null +++ b/docs/mod-manager/mockup.html @@ -0,0 +1,403 @@ + + + + +Weft Launcher – Mod manager mockup + + + + + +

+

1 · Mods tab, Installed. Each installed game version has its own mod set, because a mod only loads into the engine it was built for. A mod that's turned off isn't handed to the game at all. The launcher stops a launch that it knows would fail.

+
+
+
+ + +
+ +
+ + +
+ +
+
+
+
+
Cogwork0.2.0Reviewed
+
Zorro909 · built for v26.11.1 · needs base
+
+
+
+
+
+
+
Structures0.1.2CI-built
+
Zorro909/weft-mods · Actions build of cceea78 · not reviewed
+
+
+
+
+
+
+
Showcase0.1.1-rc.2CI-builtPrerelease
+
Off. The game doesn't see it.
+
+
+
+
+
+
+
mytweakUnverified
+
Built for v26.10.4. Weft won't start with it. Turn it off or find a newer build.
+
+
+
+
+
+
+
+
+ + +
+

2 · Browse, then install. The catalogue comes from a signed mod index, plus any GitHub repos you add as sources. The dialog lists only what the launcher actually checked. It never shows a permission list, because native code has no permissions to list.

+
+
+
+ + +
+ +
+ + +
+
+ + Fits v26.11.1 + Prereleases +
+
+
+
+
Cogwork
Reviewed
+

Gears, item tubes and engines, an excavator, an assembly bench.

+
0.2.0 · Zorro909
+
+
+
+
Structures
CI-built
+

Overworld buildings and hamlets, a wand and /structure.

+
0.1.2 · Zorro909/weft-mods
+
+
+
+
+
+
+

Install Cogwork 0.2.0?

+

for Weft v26.11.1 · 2.6 MB

+
+
Listed in the Weft mod index Reviewed 2026-11-04 · index signature valid
+
Download matches the index SHA-256 989b2191…e0a4
+
Built by GitHub Actions from public source Zorro909/weft-mods @ cceea78 · release.yml
+
Built for this version mod interface f470b1cb · needs base (ships with Weft)
+
+
Mods are programs. Cogwork can do anything you can do on this computer. These checks show where it came from. They don't show that it's harmless.
+
+ + + + View source +
+
+
+
+
+ + +
+

3 · Installing from a file. This is allowed, but nothing about the file can be checked, so the dialog says so. You type the mod's name to confirm, which takes more than a reflex click. Afterwards the mod keeps its red Unverified badge.

+
+
+
+ + +
+
+
+
+

mytweak.zip is unverified

+

from your computer · 1.1 MB

+
+
Not in the Weft mod index or any of your sources
+
No build record Nobody can tell which source code this file was built from
+
Version fit unknown Weft checks it at startup. If it doesn't fit, the game won't start.
+
Looks like a Weft mod mytweak.dll, libmytweak.so, assets/ · nothing else in the zip
+
+
Only install files from people you trust. A mod can read, change or delete your files and send them anywhere.
+
Type mytweak to install
mytw
+
+ + +
+
+
+
+
+ + +
+

4 · Home. Play shows which mods will load. If the game exits during mod loading, the launcher reads the log and names the mod, so the player doesn't just get "code 1".

+
+
+
+ + +
+

WEFT

+
Ready: v26.11.1
+
+ + +
+
2 mods · Cogwork, StructuresChange
+
+ Structures stopped Weft from starting. +
"mod structures was built against another mod ABI" (from logs/v26.11.1.log)
+
+
+
+
+
+
+ + + + From 93a7890b68f7f2ccb0c53dcda8127a698ed57973 Mon Sep 17 00:00:00 2001 From: Zorro909 Date: Wed, 4 Nov 2026 19:13:26 +0000 Subject: [PATCH 002/156] Add a mod manager Each installed game version gets its own mod set, handed to the game with --mods. A mod that is off is left out of that folder, because the engine runs code even from mods it was told to skip. Mods come from the signed Weft mod index (Reviewed), from GitHub repos the player adds (Unreviewed), or from a zip on disk (Unverified, with a typed confirmation). Every download is pinned by SHA-256 and its zip layout is checked before it is stored. Play is refused when a turned-on mod was built for another game version, and when the game still exits while loading mods, Home names the mod from the log. Also: a shared download module for versions and mods, an i18n key check that runs before every build, and a mock backend (npm run dev:mock) that runs the UI in a browser. --- package-lock.json | 55 +- package.json | 5 +- scripts/check-i18n.mjs | 37 + src-tauri/Cargo.lock | 71 ++ src-tauri/Cargo.toml | 5 +- src-tauri/capabilities/default.json | 1 + src-tauri/src/download.rs | 152 ++++ src-tauri/src/install.rs | 140 +-- src-tauri/src/launch.rs | 123 ++- src-tauri/src/lib.rs | 22 +- src-tauri/src/mods/abi.rs | 118 +++ src-tauri/src/mods/archive.rs | 237 +++++ src-tauri/src/mods/catalog.rs | 306 +++++++ src-tauri/src/mods/commands.rs | 829 ++++++++++++++++++ src-tauri/src/mods/diagnose.rs | 121 +++ src-tauri/src/mods/error.rs | 91 ++ src-tauri/src/mods/index.rs | 480 ++++++++++ src-tauri/src/mods/mod.rs | 17 + src-tauri/src/mods/paths.rs | 61 ++ src-tauri/src/mods/profile.rs | 498 +++++++++++ src-tauri/src/mods/sources.rs | 396 +++++++++ src-tauri/src/mods/testdata/index.json | 1 + .../src/mods/testdata/index.json.minisig | 4 + src-tauri/src/mods/testdata/test.pub | 2 + src-tauri/src/mods/version.rs | 90 ++ src/App.tsx | 29 +- src/components/Dialog.tsx | 70 ++ src/components/Tabs.tsx | 10 +- src/components/VersionSelect.tsx | 6 +- src/components/icons.ts | 10 + src/components/mods/BrowseList.tsx | 159 ++++ src/components/mods/CheckList.tsx | 32 + src/components/mods/FileInstallDialog.tsx | 182 ++++ src/components/mods/InstallDialog.tsx | 181 ++++ src/components/mods/InstalledList.tsx | 156 ++++ src/components/mods/ModIcon.tsx | 59 ++ src/components/mods/ModProgressBar.tsx | 30 + src/components/mods/SourcesDialog.tsx | 117 +++ src/components/mods/TrustBadge.tsx | 36 + src/components/mods/glyphs.ts | 9 + src/dev/mockBackend.ts | 435 +++++++++ src/hooks/useGameState.tsx | 8 +- src/hooks/useMods.tsx | 214 +++++ src/i18n/en.json | 183 +++- src/i18n/es.json | 190 +++- src/i18n/index.ts | 18 + src/i18n/pt-BR.json | 183 +++- src/i18n/ru.json | 190 +++- src/main.tsx | 21 +- src/mods/api.ts | 29 + src/mods/select.ts | 87 ++ src/mods/types.ts | 145 +++ src/pages/Home.tsx | 121 ++- src/pages/Mods.tsx | 217 +++++ src/pages/Versions.tsx | 10 +- src/style.css | 147 +++- 56 files changed, 6888 insertions(+), 258 deletions(-) create mode 100644 scripts/check-i18n.mjs create mode 100644 src-tauri/src/download.rs create mode 100644 src-tauri/src/mods/abi.rs create mode 100644 src-tauri/src/mods/archive.rs create mode 100644 src-tauri/src/mods/catalog.rs create mode 100644 src-tauri/src/mods/commands.rs create mode 100644 src-tauri/src/mods/diagnose.rs create mode 100644 src-tauri/src/mods/error.rs create mode 100644 src-tauri/src/mods/index.rs create mode 100644 src-tauri/src/mods/mod.rs create mode 100644 src-tauri/src/mods/paths.rs create mode 100644 src-tauri/src/mods/profile.rs create mode 100644 src-tauri/src/mods/sources.rs create mode 100644 src-tauri/src/mods/testdata/index.json create mode 100644 src-tauri/src/mods/testdata/index.json.minisig create mode 100644 src-tauri/src/mods/testdata/test.pub create mode 100644 src-tauri/src/mods/version.rs create mode 100644 src/components/Dialog.tsx create mode 100644 src/components/icons.ts create mode 100644 src/components/mods/BrowseList.tsx create mode 100644 src/components/mods/CheckList.tsx create mode 100644 src/components/mods/FileInstallDialog.tsx create mode 100644 src/components/mods/InstallDialog.tsx create mode 100644 src/components/mods/InstalledList.tsx create mode 100644 src/components/mods/ModIcon.tsx create mode 100644 src/components/mods/ModProgressBar.tsx create mode 100644 src/components/mods/SourcesDialog.tsx create mode 100644 src/components/mods/TrustBadge.tsx create mode 100644 src/components/mods/glyphs.ts create mode 100644 src/dev/mockBackend.ts create mode 100644 src/hooks/useMods.tsx create mode 100644 src/mods/api.ts create mode 100644 src/mods/select.ts create mode 100644 src/mods/types.ts create mode 100644 src/pages/Mods.tsx diff --git a/package-lock.json b/package-lock.json index 9501616..bb48369 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MPL-2.0", "dependencies": { "@tauri-apps/api": "^2", + "@tauri-apps/plugin-dialog": "^2.8.4", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-process": "^2.4.0", "@tauri-apps/plugin-updater": "^2.14.1", @@ -143,9 +144,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -163,9 +161,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -183,9 +178,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -203,9 +195,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -223,9 +212,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -243,9 +229,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -404,9 +387,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -424,9 +404,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -444,9 +421,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -464,9 +438,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -484,9 +455,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -545,6 +513,15 @@ "node": ">= 10" } }, + "node_modules/@tauri-apps/plugin-dialog": { + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.8.4.tgz", + "integrity": "sha512-+6ZOiXenOaxXwb8qCbBPdT2jX2cFPaVVHGIskrpmJLpmWj+qKRTD6X/z9x7hTdSQn7NedcoXCkWGej4a7dC4lw==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.12.0" + } + }, "node_modules/@tauri-apps/plugin-opener": { "version": "2.7.2", "license": "MIT OR Apache-2.0", @@ -799,9 +776,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -823,9 +797,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -847,9 +818,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -871,9 +839,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/package.json b/package.json index b58c853..d4645dc 100644 --- a/package.json +++ b/package.json @@ -7,12 +7,15 @@ "scripts": { "release": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/release.ps1", "dev": "vite", - "build": "tsc && vite build", + "dev:mock": "vite --mode mock", + "build": "node scripts/check-i18n.mjs && tsc && vite build", + "check:i18n": "node scripts/check-i18n.mjs", "preview": "vite preview", "tauri": "tauri" }, "dependencies": { "@tauri-apps/api": "^2", + "@tauri-apps/plugin-dialog": "^2.8.4", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-process": "^2.4.0", "@tauri-apps/plugin-updater": "^2.14.1", diff --git a/scripts/check-i18n.mjs b/scripts/check-i18n.mjs new file mode 100644 index 0000000..a57b9b1 --- /dev/null +++ b/scripts/check-i18n.mjs @@ -0,0 +1,37 @@ +// Fails when a language file lacks a key English has, has one English doesn't, or drops a +// `{n}` placeholder. Runs before every build: a missing string would show up as its key. +import { readFileSync } from "node:fs"; + +const dir = new URL("../src/i18n/", import.meta.url); +const locales = ["en", "pt-BR", "es", "ru"]; +const load = (l) => JSON.parse(readFileSync(new URL(`${l}.json`, dir), "utf8")); + +function flatten(obj, prefix = "", out = {}) { + for (const [k, v] of Object.entries(obj)) { + const key = prefix ? `${prefix}.${k}` : k; + if (v && typeof v === "object") flatten(v, key, out); + else out[key] = String(v); + } + return out; +} + +const placeholders = (s) => [...s.matchAll(/\{(\d+)\}/g)].map((m) => m[1]).sort().join(","); +const base = flatten(load("en")); +const problems = []; +for (const locale of locales.slice(1)) { + const other = flatten(load(locale)); + for (const key of Object.keys(base)) { + if (!(key in other)) problems.push(`${locale}: missing ${key}`); + else if (placeholders(base[key]) !== placeholders(other[key])) { + problems.push(`${locale}: ${key} has {${placeholders(other[key])}}, English has {${placeholders(base[key])}}`); + } + } + for (const key of Object.keys(other)) { + if (!(key in base)) problems.push(`${locale}: ${key} is not in en.json`); + } +} +if (problems.length) { + console.error(problems.join("\n")); + process.exit(1); +} +console.log(`i18n: ${Object.keys(base).length} keys, same in ${locales.join(", ")}`); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index feca8a9..668576e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3223,6 +3223,32 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "libc", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "percent-encoding", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.61.2", +] + [[package]] name = "ring" version = "0.17.14" @@ -4129,6 +4155,48 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-dialog" +version = "2.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce0fb9c2b6d34a9720627ed930ebc8af26f61e648695aecaeb03d5c33c5896f5" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.21", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d58eb1d4984e535f47a642ffec09a2da6f5c18a12b5902d12126cb7626d628b" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.21", + "toml 1.1.8+spec-1.1.0", + "url", +] + [[package]] name = "tauri-plugin-opener" version = "2.7.2" @@ -5041,8 +5109,10 @@ dependencies = [ name = "weft-launcher" version = "1.1.1" dependencies = [ + "base64 0.22.1", "flate2", "futures-util", + "minisign-verify", "reqwest 0.12.28", "serde", "serde_json", @@ -5050,6 +5120,7 @@ dependencies = [ "tar", "tauri", "tauri-build", + "tauri-plugin-dialog", "tauri-plugin-opener", "tauri-plugin-process", "tauri-plugin-single-instance", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ed18ce9..e2cff0b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -22,17 +22,20 @@ tauri-build = { version = "2", features = [] } tauri = { version = "2", features = [] } tauri-plugin-single-instance = "2" tauri-plugin-opener = "2" +tauri-plugin-dialog = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "gzip", "stream"] } thiserror = "2" -tokio = { version = "1", features = ["fs", "io-util", "process", "rt", "time"] } +tokio = { version = "1", features = ["fs", "io-util", "process", "rt", "sync", "time"] } time = { version = "0.3", features = ["serde", "formatting", "local-offset"] } futures-util = "0.3" sha2 = "0.10" zip = "0.6" flate2 = "1.0" tar = "0.4" +minisign-verify = "0.3" +base64 = "0.22" [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-updater = "2" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 297cfd4..211c400 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -6,6 +6,7 @@ "permissions": [ "core:default", "opener:default", + "dialog:allow-open", "updater:default", "process:allow-restart" ] diff --git a/src-tauri/src/download.rs b/src-tauri/src/download.rs new file mode 100644 index 0000000..4891151 --- /dev/null +++ b/src-tauri/src/download.rs @@ -0,0 +1,152 @@ +//! Streams a download to a file and hashes it on the way, for game versions and mods alike. + +use futures_util::StreamExt; +use reqwest::header::USER_AGENT; +use sha2::{Digest, Sha256}; +use std::path::Path; +use thiserror::Error; +use tokio::io::AsyncWriteExt; + +const USER_AGENT_VALUE: &str = concat!("weft-launcher/", env!("CARGO_PKG_VERSION")); + +#[derive(Debug, Error)] +pub enum DownloadError { + #[error("{0}")] + Network(#[from] reqwest::Error), + #[error("{0}")] + Io(#[from] std::io::Error), + #[error("HTTP {0}")] + Status(reqwest::StatusCode), + #[error("larger than {0} bytes")] + TooLarge(u64), +} + +#[derive(Debug, Clone, Copy)] +pub struct Progress { + pub downloaded: u64, + /// 0 when the server doesn't say. + pub total: u64, + pub percent: u32, + pub speed_mbps: f64, +} + +pub struct Downloaded { + pub bytes: u64, + /// Lowercase hex. + pub sha256: String, +} + +/// No total timeout: a slow connection may take long, as long as data keeps coming. +pub fn client() -> Result { + reqwest::Client::builder() + .connect_timeout(std::time::Duration::from_secs(30)) + .read_timeout(std::time::Duration::from_secs(60)) + .build() +} + +/// Reports about every 1% (or 100 KB when the size is unknown). `max_bytes` stops a download that +/// grows past what was announced. +pub async fn to_file( + client: &reqwest::Client, + url: &str, + dest: &Path, + max_bytes: Option, + mut on_progress: impl FnMut(Progress), +) -> Result { + let response = client + .get(url) + .header(USER_AGENT, USER_AGENT_VALUE) + .send() + .await?; + if !response.status().is_success() { + return Err(DownloadError::Status(response.status())); + } + let total = response.content_length().unwrap_or(0); + if let Some(max) = max_bytes + && total > max + { + return Err(DownloadError::TooLarge(max)); + } + + let mut file = tokio::fs::File::create(dest).await?; + let mut hasher = Sha256::new(); + let mut downloaded: u64 = 0; + let mut last_percent = 0u32; + let mut last_bytes = 0u64; + let mut last_time = std::time::Instant::now(); + #[cfg(debug_assertions)] + let start = std::time::Instant::now(); + + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk?; + downloaded += chunk.len() as u64; + if let Some(max) = max_bytes + && downloaded > max + { + return Err(DownloadError::TooLarge(max)); + } + hasher.update(&chunk); + file.write_all(&chunk).await?; + + let percent = if total > 0 { + ((downloaded as f64 / total as f64) * 100.0) as u32 + } else { + 0 + }; + let due = if total > 0 { + percent > last_percent + } else { + downloaded >= last_bytes + 100_000 + }; + if due { + let now = std::time::Instant::now(); + let elapsed = now.duration_since(last_time).as_secs_f64(); + let speed_mbps = if elapsed > 0.0 { + (downloaded.saturating_sub(last_bytes) as f64 * 8.0 / 1_000_000.0) / elapsed + } else { + 0.0 + }; + on_progress(Progress { + downloaded, + total, + percent, + speed_mbps, + }); + last_percent = percent; + last_bytes = downloaded; + last_time = now; + } + + // `WEFT_THROTTLE_KBPS=200 npm run tauri dev` shows the progress UI on a fast connection. + #[cfg(debug_assertions)] + if let Ok(val) = std::env::var("WEFT_THROTTLE_KBPS") + && let Ok(kbps) = val.parse::() + && kbps > 0.0 + { + let expected = downloaded as f64 / (kbps * 1024.0); + let actual = start.elapsed().as_secs_f64(); + if expected > actual { + tokio::time::sleep(std::time::Duration::from_secs_f64(expected - actual)).await; + } + } + } + file.flush().await?; + + Ok(Downloaded { + bytes: downloaded, + sha256: format!("{:x}", hasher.finalize()), + }) +} + +pub fn sha256_hex(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +/// SHA-256 of a file on disk, lowercase hex. +pub fn sha256_file(path: &Path) -> std::io::Result { + let mut file = std::fs::File::open(path)?; + let mut hasher = Sha256::new(); + std::io::copy(&mut file, &mut hasher)?; + Ok(format!("{:x}", hasher.finalize())) +} diff --git a/src-tauri/src/install.rs b/src-tauri/src/install.rs index f08c3df..cfa5d55 100644 --- a/src-tauri/src/install.rs +++ b/src-tauri/src/install.rs @@ -1,7 +1,4 @@ -use futures_util::StreamExt; -use reqwest::header::USER_AGENT; use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; use std::collections::HashSet; use std::fs::File as StdFile; use std::path::{Component, Path, PathBuf}; @@ -9,7 +6,6 @@ use std::sync::{Arc, Mutex}; use tauri::{Emitter, Manager}; use thiserror::Error; use tokio::fs; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; use zip::result::ZipError; #[cfg(unix)] @@ -19,7 +15,6 @@ use crate::config::WEFT_REPO; use crate::error::AppError; use crate::launch::running_game; -const USER_AGENT_VALUE: &str = concat!("weft-launcher/", env!("CARGO_PKG_VERSION")); const TEMP_EXTRACT_PREFIX: &str = ".tmp-"; const REMOVING_PREFIX: &str = ".removing-"; @@ -234,107 +229,30 @@ async fn download_and_install( fs::remove_dir_all(&version_dir).await?; } - // No total timeout: a slow connection may take long, as long as data keeps coming. - let client = reqwest::Client::builder() - .connect_timeout(std::time::Duration::from_secs(30)) - .read_timeout(std::time::Duration::from_secs(60)) - .build()?; - - let response = client - .get(asset_url) - .header(USER_AGENT, USER_AGENT_VALUE) - .send() - .await - .map_err(|e| InstallError::DownloadFailed(e.to_string()))?; - - if !response.status().is_success() { - return Err(InstallError::DownloadFailed(format!("HTTP {}", response.status())).into()); - } - - let total_size = response.content_length().unwrap_or(0); - let mut downloaded: u64 = 0; - let mut last_reported_percent = 0u32; - let mut last_reported_bytes = 0u64; - let mut last_report_time = std::time::Instant::now(); - - #[cfg(debug_assertions)] - let download_start = std::time::Instant::now(); - - let mut file = fs::File::create(temp_download).await?; - - // Stream download with progress - let mut stream = response.bytes_stream(); - while let Some(chunk_result) = stream.next().await { - let chunk = chunk_result?; - - file.write_all(&chunk).await?; - - downloaded += chunk.len() as u64; - - // Throttle progress: emit every ~1% or 100KB - let percent = if total_size > 0 { - ((downloaded as f64 / total_size as f64) * 100.0) as u32 - } else { - 0 - }; - let should_report = if total_size > 0 { - percent > last_reported_percent - } else { - downloaded >= last_reported_bytes + 100_000 - }; - - if should_report { - let now = std::time::Instant::now(); - let elapsed = now.duration_since(last_report_time).as_secs_f64(); - let bytes_since_last = downloaded.saturating_sub(last_reported_bytes); - let speed_mbps = if elapsed > 0.0 { - (bytes_since_last as f64 * 8.0 / 1_000_000.0) / elapsed - } else { - 0.0 - }; - - let _ = app_handle.emit( - "install-progress", - serde_json::json!({ - "tag": tag, - "downloaded": downloaded, - "total": total_size, - "percent": percent, - "stage": "downloading", - "speedMbps": speed_mbps - }), - ); - last_reported_percent = percent; - last_reported_bytes = downloaded; - last_report_time = now; - } - - #[cfg(debug_assertions)] - { - if let Ok(val) = std::env::var("WEFT_THROTTLE_KBPS") - && let Ok(kbps) = val.parse::() - && kbps > 0.0 - { - let bytes_per_sec = kbps * 1024.0; - let expected_duration = downloaded as f64 / bytes_per_sec; - let actual_duration = download_start.elapsed().as_secs_f64(); - if expected_duration > actual_duration { - let sleep_secs = expected_duration - actual_duration; - tokio::time::sleep(std::time::Duration::from_secs_f64(sleep_secs)).await; - } - } - } - } - - file.flush().await?; - drop(file); + let client = crate::download::client()?; + let tag_owned = tag.to_string(); + let downloaded = crate::download::to_file(&client, asset_url, temp_download, None, |p| { + let _ = app_handle.emit( + "install-progress", + serde_json::json!({ + "tag": tag_owned, + "downloaded": p.downloaded, + "total": p.total, + "percent": p.percent, + "stage": "downloading", + "speedMbps": p.speed_mbps + }), + ); + }) + .await + .map_err(|e| InstallError::DownloadFailed(e.to_string()))?; + let total_size = downloaded.bytes; - // Final download progress let _ = app_handle.emit( "install-progress", serde_json::json!({ "tag": tag, - "downloaded": downloaded, + "downloaded": downloaded.bytes, "total": total_size, "percent": 100, "stage": "verifying", @@ -342,26 +260,15 @@ async fn download_and_install( }), ); - // Verify SHA256 - let mut file = fs::File::open(temp_download).await?; - let mut hasher = Sha256::new(); - let mut buffer = vec![0u8; 8192]; - loop { - let n = file.read(&mut buffer).await?; - if n == 0 { - break; - } - hasher.update(&buffer[..n]); - } - let actual = format!("{:x}", hasher.finalize()); - if !actual.eq_ignore_ascii_case(expected_sha256) { + // Hashed while it downloaded. + if !downloaded.sha256.eq_ignore_ascii_case(expected_sha256) { return Err(InstallError::Sha256Mismatch { expected: expected_sha256.to_string(), - actual, + actual: downloaded.sha256, } .into()); } - drop(file); + let downloaded = downloaded.bytes; // Extract to temp directory let _ = app_handle.emit( @@ -672,6 +579,7 @@ pub async fn remove_version(app_handle: tauri::AppHandle, tag: String) -> Result Ok(_) => { // Rename succeeded, now delete the renamed folder fs::remove_dir_all(&removing_dir).await?; + crate::mods::profile::forget_version(&app_handle, &tag); Ok(()) } Err(e) => { diff --git a/src-tauri/src/launch.rs b/src-tauri/src/launch.rs index 6548171..e2c80c5 100644 --- a/src-tauri/src/launch.rs +++ b/src-tauri/src/launch.rs @@ -34,7 +34,7 @@ pub enum LaunchError { } /// Remove ANSI escape codes from a string. -fn strip_ansi_codes(input: &str) -> String { +pub(crate) fn strip_ansi_codes(input: &str) -> String { let mut result = String::with_capacity(input.len()); let mut chars = input.chars().peekable(); @@ -112,6 +112,8 @@ pub struct GameStateEvent { /// The game only ends with an error of its own, a panic or a fault when something went /// wrong. On Unix a signal leaves no exit code at all, so the code alone can't tell. pub crashed: bool, + /// The mod that stopped the game from starting, when the log names one. + pub mod_failure: Option, } /// Tags of the games this launcher has started and is waiting for. @@ -179,6 +181,17 @@ pub async fn launch_version(app_handle: AppHandle, tag: String) -> Result<(), Ap if !executable.exists() { return Err(LaunchError::ExecutableNotFound(tag).into()); } + if let Some(running) = running_game(&app_handle) { + return Err(if running == tag { + LaunchError::AlreadyRunning(tag) + } else { + LaunchError::AnotherGameRunning(running) + } + .into()); + } + + // Refuses a mod set the game would refuse, before anything starts. + let mods_dir = crate::mods::prepare_launch(&app_handle, &tag).await?; let games = running_games(&app_handle); @@ -194,22 +207,56 @@ pub async fn launch_version(app_handle: AppHandle, tag: String) -> Result<(), Ap running.insert(tag.clone()); } - if let Err(e) = start_game(&app_handle, &games, &tag, &executable, &version_dir).await { + let launch = Launch { + tag: &tag, + executable: &executable, + version_dir: &version_dir, + mods_dir: mods_dir.as_deref(), + }; + if let Err(e) = start_game(&app_handle, &games, launch).await { games.0.lock().unwrap().remove(&tag); return Err(e.into()); } Ok(()) } +struct Launch<'a> { + tag: &'a str, + executable: &'a Path, + version_dir: &'a Path, + mods_dir: Option<&'a Path>, +} + +/// The first mod failure logged since `start`, the size of the log when this run began. +async fn mod_failure_since( + log_path: &Path, + start: u64, +) -> Option { + use tokio::io::{AsyncReadExt, AsyncSeekExt}; + let mut file = fs::File::open(log_path).await.ok()?; + file.seek(std::io::SeekFrom::Start(start)).await.ok()?; + let mut bytes = Vec::new(); + file.take(4 * 1024 * 1024) + .read_to_end(&mut bytes) + .await + .ok()?; + crate::mods::diagnose::find(&String::from_utf8_lossy(&bytes)) +} + async fn start_game( app_handle: &AppHandle, games: &RunningGames, - tag: &str, - executable: &Path, - version_dir: &Path, + launch: Launch<'_>, ) -> Result<(), LaunchError> { + let Launch { + tag, + executable, + version_dir, + mods_dir, + } = launch; let logs_dir = get_logs_dir(app_handle)?; let log_path = logs_dir.join(format!("{tag}.log")); + let log_start = fs::metadata(&log_path).await.map(|m| m.len()).unwrap_or(0); let log_stdout = fs::OpenOptions::new() .create(true) .append(true) @@ -221,6 +268,10 @@ async fn start_game( cmd.current_dir(version_dir) .stdout(Stdio::piped()) .stderr(Stdio::piped()); + // Searched before the player's own mods folder, and the first copy of a mod wins. + if let Some(dir) = mods_dir { + cmd.arg("--mods").arg(dir); + } #[cfg(target_os = "windows")] { @@ -243,6 +294,7 @@ async fn start_game( state: "running".to_string(), exit_code: None, crashed: false, + mod_failure: None, }, ); @@ -258,13 +310,20 @@ async fn start_game( let _ = tokio::time::timeout(Duration::from_secs(2), logs).await; games.0.lock().unwrap().remove(&tag); + let crashed = status.is_some_and(|s| !s.success()); + let mod_failure = if crashed { + mod_failure_since(&log_path, log_start).await + } else { + None + }; let _ = app_handle.emit( "game-state", GameStateEvent { tag, state: "stopped".to_string(), exit_code: status.and_then(|s| s.code()), - crashed: status.is_some_and(|s| !s.success()), + crashed, + mod_failure, }, ); }); @@ -279,12 +338,22 @@ pub async fn get_running_versions(app_handle: AppHandle) -> Result, #[tauri::command] pub async fn open_game_folder(app_handle: AppHandle, _tag: String) -> Result<(), AppError> { - let logs_dir = get_logs_dir(&app_handle)?; + match find_game_folder(&app_handle).await { + Some(folder) => Ok(open_path(&app_handle, &folder)?), + None => Err(LaunchError::GameFolderNotFound( + "not in the logs yet, start the game once".to_string(), + ) + .into()), + } +} + +/// The game's data folder, as the newest log that names one says. The game prints it at start. +pub async fn find_game_folder(app_handle: &AppHandle) -> Option { + let logs_dir = get_logs_dir(app_handle).ok()?; - // Read all log files, sort by modification time (newest first) - let mut entries = fs::read_dir(&logs_dir).await?; - let mut log_files: Vec<_> = Vec::new(); - while let Some(entry) = entries.next_entry().await? { + let mut entries = fs::read_dir(&logs_dir).await.ok()?; + let mut log_files = Vec::new(); + while let Ok(Some(entry)) = entries.next_entry().await { let path = entry.path(); if path.extension().and_then(|s| s.to_str()) == Some("log") && let Ok(metadata) = entry.metadata().await @@ -293,33 +362,23 @@ pub async fn open_game_folder(app_handle: AppHandle, _tag: String) -> Result<(), log_files.push((path, modified)); } } - - // Sort by modification time, newest first log_files.sort_by_key(|a| std::cmp::Reverse(a.1)); - // Search each log file for the "game folder" line for (log_path, _) in log_files { - let file = fs::File::open(&log_path).await?; - let reader = BufReader::new(file); - let mut lines = reader.lines(); - + let Ok(file) = fs::File::open(&log_path).await else { + continue; + }; + let mut lines = BufReader::new(file).lines(); while let Ok(Some(line)) = lines.next_line().await { - if let Some(game_folder) = extract_game_folder_path(&line) { - let game_folder_path = PathBuf::from(&game_folder); - if game_folder_path.exists() { - open_path(&app_handle, &game_folder_path)?; - return Ok(()); - } - // Path from log doesn't exist, continue searching + // A folder that no longer exists may still be named in an old log. + if let Some(folder) = extract_game_folder_path(&line).map(PathBuf::from) + && folder.exists() + { + return Some(folder); } } } - - // No valid game folder found in any log - Err( - LaunchError::GameFolderNotFound("not in the logs yet, start the game once".to_string()) - .into(), - ) + None } #[tauri::command] @@ -338,7 +397,7 @@ pub async fn open_log(app_handle: AppHandle, tag: String) -> Result<(), AppError Ok(()) } -fn open_path(app_handle: &AppHandle, path: &Path) -> Result<(), LaunchError> { +pub(crate) fn open_path(app_handle: &AppHandle, path: &Path) -> Result<(), LaunchError> { // Programs spawned from the AppImage crash on its bundled libraries; reveal goes over D-Bus. if cfg!(target_os = "linux") && std::env::var_os("APPDIR").is_some() { app_handle.opener().reveal_item_in_dir(path)?; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b6f75fd..fe6b8f6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,7 +1,9 @@ pub mod config; +pub mod download; pub mod error; pub mod install; pub mod launch; +pub mod mods; pub mod releases; use tauri::Manager; @@ -16,7 +18,8 @@ pub fn run() { let _ = w.set_focus(); }); })) - .plugin(tauri_plugin_opener::init()); + .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_dialog::init()); #[cfg(desktop)] { @@ -28,6 +31,8 @@ pub fn run() { builder .manage(install::ActiveInstalls::default()) .manage(launch::RunningGames::default()) + .manage(mods::catalog::CatalogState::default()) + .manage(mods::commands::ModsLock::default()) .setup(|app| { install::sweep_leftovers(app.handle()); Ok(()) @@ -40,7 +45,20 @@ pub fn run() { launch::launch_version, launch::get_running_versions, launch::open_game_folder, - launch::open_log + launch::open_log, + mods::commands::list_mod_catalog, + mods::commands::get_mod_sources, + mods::commands::add_mod_source, + mods::commands::remove_mod_source, + mods::commands::get_mod_profile, + mods::commands::install_mod, + mods::commands::inspect_mod_file, + mods::commands::install_mod_file, + mods::commands::set_mod_enabled, + mods::commands::remove_mod, + mods::commands::copy_mod_profile, + mods::commands::get_mod_icon, + mods::commands::open_player_mods_folder ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/mods/abi.rs b/src-tauri/src/mods/abi.rs new file mode 100644 index 0000000..675f26d --- /dev/null +++ b/src-tauri/src/mods/abi.rs @@ -0,0 +1,118 @@ +//! The mod interface ("ABI") of a game version. Weft loads a mod only if the mod was built against +//! a byte-identical `crates/abi/src/lib.rs`: `ABI_VERSION` is the FNV-1a-64 hash of that file. So +//! the launcher can tell what a version accepts without running it, from the file at its tag. + +use std::collections::BTreeMap; +use std::time::Duration; +use tauri::AppHandle; + +use super::error::ModsError; +use super::index::WeftAbi; +use super::paths::{cache_dir, write_atomic}; +use crate::config::WEFT_REPO; + +pub fn fnv1a64(bytes: &[u8]) -> u64 { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for &b in bytes { + h = (h ^ b as u64).wrapping_mul(0x0100_0000_01b3); + } + h +} + +pub fn format_abi(hash: u64) -> String { + format!("{hash:016x}") +} + +pub fn is_abi(s: &str) -> bool { + s.len() == 16 && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) +} + +/// A git ref that can go into a URL path as it is. +pub fn is_safe_ref(s: &str) -> bool { + !s.is_empty() + && s.len() <= 100 + && !s.starts_with('.') + && s.bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_')) +} + +fn cache_file(app: &AppHandle) -> Result { + Ok(cache_dir(app)?.join("abi.json")) +} + +fn read_cache(app: &AppHandle) -> BTreeMap { + cache_file(app) + .ok() + .and_then(|p| std::fs::read(p).ok()) + .and_then(|b| serde_json::from_slice(&b).ok()) + .unwrap_or_default() +} + +/// The ABI of a cached tag, without going to the network. A tag's file never changes. +pub fn cached_abi(app: &AppHandle, tag: &str, table: &[WeftAbi]) -> Option { + table + .iter() + .find(|w| w.tag == tag) + .map(|w| w.abi.clone()) + .or_else(|| read_cache(app).remove(tag)) +} + +pub async fn abi_for_tag( + app: &AppHandle, + client: &reqwest::Client, + tag: &str, + table: &[WeftAbi], +) -> Result { + if let Some(abi) = cached_abi(app, tag, table) { + return Ok(abi); + } + if !is_safe_ref(tag) { + return Err(ModsError::InvalidName(tag.to_string())); + } + let url = format!("https://raw.githubusercontent.com/{WEFT_REPO}/{tag}/crates/abi/src/lib.rs"); + let response = client + .get(&url) + .timeout(Duration::from_secs(15)) + .send() + .await + .map_err(|_| ModsError::AbiUnavailable(tag.to_string()))?; + if !response.status().is_success() { + return Err(ModsError::AbiUnavailable(tag.to_string())); + } + let bytes = response + .bytes() + .await + .map_err(|_| ModsError::AbiUnavailable(tag.to_string()))?; + let abi = format_abi(fnv1a64(&bytes)); + + let mut cache = read_cache(app); + cache.insert(tag.to_string(), abi.clone()); + // Without the cache the hash is just fetched again next time. + if let Ok(path) = cache_file(app) { + let _ = serde_json::to_vec_pretty(&cache).map(|b| write_atomic(&path, &b)); + } + Ok(abi) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fnv_matches_the_engine() { + // The engine's own constants: an empty file hashes to the offset basis. + assert_eq!(format_abi(fnv1a64(b"")), "cbf29ce484222325"); + assert_eq!(format_abi(fnv1a64(b"a")), "af63dc4c8601ec8c"); + } + + #[test] + fn abi_and_ref_shapes() { + assert!(is_abi("f470b1cbb1f9ad91")); + assert!(!is_abi("F470B1CBB1F9AD91")); + assert!(!is_abi("f470b1cb")); + assert!(is_safe_ref("v26.11.1")); + for bad in ["", "../x", "v1/../../x", "a?b", "a#b", ".hidden"] { + assert!(!is_safe_ref(bad), "{bad:?}"); + } + } +} diff --git a/src-tauri/src/mods/archive.rs b/src-tauri/src/mods/archive.rs new file mode 100644 index 0000000..de376f8 --- /dev/null +++ b/src-tauri/src/mods/archive.rs @@ -0,0 +1,237 @@ +//! What a mod zip may contain before it is stored: the mod's library for each system and its +//! `assets/`, nothing else. The launcher never unpacks or runs a mod; this only reads the zip's +//! table of contents, so a strange file is refused before the game ever sees it. + +use serde::Serialize; +use std::io::Read; +use std::path::{Component, Path}; + +use super::error::ModsError; +use super::index::is_mod_id; + +const MAX_ENTRIES: usize = 20_000; +const MAX_TOTAL_BYTES: u64 = 1024 * 1024 * 1024; +const MAX_RATIO: u64 = 200; +pub const MAX_FILE_BYTES: u64 = 512 * 1024 * 1024; + +/// `windows-x64`, `linux-x64` or `macos-arm64`; empty where the game isn't built. +pub fn current_platform() -> &'static str { + if cfg!(all(target_os = "windows", target_arch = "x86_64")) { + "windows-x64" + } else if cfg!(all(target_os = "linux", target_arch = "x86_64")) { + "linux-x64" + } else if cfg!(all(target_os = "macos", target_arch = "aarch64")) { + "macos-arm64" + } else { + "" + } +} + +fn platform_of_library(name: &str) -> Option<(&'static str, &str)> { + if let Some(id) = name.strip_suffix(".dll") { + return Some(("windows-x64", id)); + } + let rest = name.strip_prefix("lib")?; + if let Some(id) = rest.strip_suffix(".so") { + return Some(("linux-x64", id)); + } + rest.strip_suffix(".dylib").map(|id| ("macos-arm64", id)) +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ArchiveInfo { + pub id: String, + pub libraries: Vec, + pub platforms: Vec, + pub has_assets: bool, + pub entries: usize, +} + +fn bad(reason: impl Into) -> ModsError { + ModsError::ArchiveInvalid(reason.into()) +} + +/// Checks the layout of a mod zip. With `expected_id`, the library names must say that id. +pub fn inspect( + reader: R, + expected_id: Option<&str>, +) -> Result { + let mut zip = zip::ZipArchive::new(reader)?; + if zip.len() > MAX_ENTRIES { + return Err(bad("too many files")); + } + + let mut id: Option = expected_id.map(str::to_string); + let mut libraries = Vec::new(); + let mut platforms = Vec::new(); + let mut has_assets = false; + let mut total: u64 = 0; + + for i in 0..zip.len() { + let entry = zip.by_index_raw(i)?; + let raw = entry.name().to_string(); + if raw.contains('\\') || raw.contains('\0') { + return Err(bad(format!("odd file name {raw:?}"))); + } + let Some(path) = entry.enclosed_name().map(Path::to_path_buf) else { + return Err(bad(format!("unsafe path {raw:?}"))); + }; + if path + .components() + .any(|c| !matches!(c, Component::Normal(_))) + { + return Err(bad(format!("unsafe path {raw:?}"))); + } + if let Some(mode) = entry.unix_mode() + && mode & 0o170000 == 0o120000 + { + return Err(bad(format!("symbolic link {raw:?}"))); + } + total = total.saturating_add(entry.size()); + if total > MAX_TOTAL_BYTES { + return Err(bad("unpacks to more than 1 GB")); + } + if entry.size() > 1024 * 1024 && entry.size() / entry.compressed_size().max(1) > MAX_RATIO { + return Err(bad(format!("{raw} is compressed suspiciously well"))); + } + + let mut parts = path.components(); + let first = parts + .next() + .and_then(|c| c.as_os_str().to_str()) + .unwrap_or(""); + let nested = parts.next().is_some(); + + if first == "assets" { + has_assets = true; + continue; + } + if nested || entry.is_dir() { + return Err(bad(format!("unexpected folder {raw:?}"))); + } + let Some((platform, lib_id)) = platform_of_library(first) else { + return Err(bad(format!("unexpected file {raw:?}"))); + }; + if !is_mod_id(lib_id) { + return Err(bad(format!("library name {raw:?}"))); + } + match &id { + Some(known) if known != lib_id => { + return Err(bad(format!( + "{raw} belongs to mod {lib_id:?}, not {known:?}" + ))); + } + Some(_) => {} + None => id = Some(lib_id.to_string()), + } + if platforms.contains(&platform.to_string()) { + return Err(bad(format!("two libraries for {platform}"))); + } + platforms.push(platform.to_string()); + libraries.push(first.to_string()); + } + + let Some(id) = id.filter(|_| !libraries.is_empty()) else { + return Err(bad("no mod library inside")); + }; + platforms.sort(); + libraries.sort(); + Ok(ArchiveInfo { + id, + libraries, + platforms, + has_assets, + entries: zip.len(), + }) +} + +/// The name and description a mod gives itself in `assets/lang/en_us.lang`. Shown as what the +/// file says it is, nothing more. +pub fn read_lang(path: &Path, id: &str) -> (Option, Option) { + let read = || -> Option { + let file = std::fs::File::open(path).ok()?; + let mut zip = zip::ZipArchive::new(file).ok()?; + let entry = zip.by_name("assets/lang/en_us.lang").ok()?; + let mut text = String::new(); + entry.take(64 * 1024).read_to_string(&mut text).ok()?; + Some(text) + }; + let Some(text) = read() else { + return (None, None); + }; + let value = |key: &str| { + text.lines().find_map(|l| { + let (k, v) = l.split_once('=')?; + (k.trim() == key) + .then(|| v.trim().chars().take(140).collect::()) + .filter(|v| !v.is_empty()) + }) + }; + ( + value(&format!("mod.{id}.name")), + value(&format!("mod.{id}.description")), + ) +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use std::io::{Cursor, Write}; + use zip::write::FileOptions; + + pub fn make_zip(files: &[(&str, &[u8])]) -> Vec { + let mut buf = Cursor::new(Vec::new()); + { + let mut zip = zip::ZipWriter::new(&mut buf); + for (name, body) in files { + if name.ends_with('/') { + zip.add_directory(*name, FileOptions::default()).unwrap(); + } else { + zip.start_file(*name, FileOptions::default()).unwrap(); + zip.write_all(body).unwrap(); + } + } + zip.finish().unwrap(); + } + buf.into_inner() + } + + fn check(files: &[(&str, &[u8])], id: Option<&str>) -> Result { + inspect(Cursor::new(make_zip(files)), id) + } + + #[test] + fn a_real_mod_layout_passes() { + let info = check( + &[ + ("cogwork.dll", b"pe"), + ("libcogwork.so", b"elf"), + ("libcogwork.dylib", b"macho"), + ("assets/", b""), + ("assets/lang/en_us.lang", b"mod.cogwork.name = Cogwork"), + ], + None, + ) + .unwrap(); + assert_eq!(info.id, "cogwork"); + assert_eq!(info.platforms, ["linux-x64", "macos-arm64", "windows-x64"]); + assert!(info.has_assets); + } + + #[test] + fn anything_else_is_refused() { + let cases: &[&[(&str, &[u8])]] = &[ + &[("cogwork.dll", b""), ("run.exe", b"")], + &[("cogwork.dll", b""), ("libother.so", b"")], + &[("cogwork/cogwork.dll", b"")], + &[("../cogwork.dll", b"")], + &[("assets/x.png", b"")], + &[("Cogwork.dll", b"")], + ]; + for files in cases { + assert!(check(files, None).is_err(), "{files:?}"); + } + assert!(check(&[("cogwork.dll", b"")], Some("structures")).is_err()); + } +} diff --git a/src-tauri/src/mods/catalog.rs b/src-tauri/src/mods/catalog.rs new file mode 100644 index 0000000..7a49ad2 --- /dev/null +++ b/src-tauri/src/mods/catalog.rs @@ -0,0 +1,306 @@ +//! Everything installable: the reviewed index plus the player's sources, as one list. The backend +//! keeps the last list it built, and installs only what is in it: the page names a file by its +//! hash, and the URL and hash always come from here, never from the page. + +use serde::Serialize; +use std::sync::Mutex; + +use super::archive::current_platform; +use super::index::{Channel, Freshness, Index, IndexMod, IndexVersion, WeftAbi}; + +#[derive(Debug, Clone, Copy, Serialize, serde::Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum Trust { + /// In the signed index: someone reviewed exactly these bytes. + Reviewed, + /// From a source the player added. Built and published by its repo, not reviewed. + Unreviewed, + /// A file from the player's computer. Nothing about where it came from can be checked. + Unverified, +} + +#[derive(Debug, Clone, Serialize, serde::Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum Origin { + Index, + Source { repo: String }, + File { name: String }, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CatalogVersion { + pub version: String, + pub channel: Channel, + pub tag: String, + pub published: String, + pub source_repo: String, + pub source_commit: String, + pub weft_tag: Option, + pub abi: String, + pub deps: Vec, + pub platforms: Vec, + pub sha256: String, + pub size: u64, + pub url: String, + pub provenance: Option, + pub reviewed: Option, + pub trust: Trust, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProvenanceView { + pub repo: Option, + pub workflow: Option, + pub git_ref: Option, + pub verified_at: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewedView { + pub by: String, + pub at: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CatalogMod { + /// Unique across origins: the index and a source may both have a `cogwork`. + pub key: String, + pub id: String, + pub name: String, + pub summary: String, + pub authors: Vec, + pub license: Option, + pub homepage: Option, + pub icon: Option, + pub origin: Origin, + pub versions: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct IndexStatus { + pub freshness: Freshness, + pub generated: Option, + pub error: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SourceStatus { + pub repo: String, + pub mods: usize, + pub error: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Catalog { + pub mods: Vec, + pub revoked: Vec, + pub index: IndexStatus, + pub sources: Vec, + pub weft: Vec, + pub platform: &'static str, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RevokedView { + pub sha256: String, + pub reason: String, +} + +impl Catalog { + pub fn find(&self, sha256: &str) -> Option<(&CatalogMod, &CatalogVersion)> { + self.mods.iter().find_map(|m| { + m.versions + .iter() + .find(|v| v.sha256 == sha256) + .map(|v| (m, v)) + }) + } + + pub fn revocation(&self, sha256: &str) -> Option<&RevokedView> { + self.revoked.iter().find(|r| r.sha256 == sha256) + } +} + +#[derive(Default)] +pub struct CatalogState(pub Mutex>); + +fn version_view(v: &IndexVersion, trust: Trust) -> CatalogVersion { + CatalogVersion { + version: v.version.clone(), + channel: v.channel, + tag: v.tag.clone(), + published: v.published.clone(), + source_repo: v.source.repo.clone(), + source_commit: v.source.commit.clone(), + weft_tag: v.weft.tag.clone(), + abi: v.weft.abi.clone(), + deps: v.deps.iter().map(|d| d.id.clone()).collect(), + platforms: v.platforms.clone(), + sha256: v.asset.sha256.to_ascii_lowercase(), + size: v.asset.size, + url: v.asset.url.clone(), + provenance: v.provenance.as_ref().map(|p| ProvenanceView { + repo: p.repo.clone(), + workflow: p.workflow.clone(), + git_ref: p.git_ref.clone(), + verified_at: p.verified_at.clone(), + }), + reviewed: v.reviewed.as_ref().map(|r| ReviewedView { + by: r.by.clone(), + at: r.at.clone(), + }), + trust, + } +} + +fn mod_view(m: &IndexMod, origin: Origin, trust: Trust, skip: &[String]) -> CatalogMod { + let key = match &origin { + Origin::Index => format!("index:{}", m.id), + Origin::Source { repo } => format!("source:{repo}:{}", m.id), + Origin::File { name } => format!("file:{name}"), + }; + let mut versions: Vec = m + .versions + .iter() + .filter(|v| !skip.contains(&v.asset.sha256.to_ascii_lowercase())) + .map(|v| version_view(v, trust)) + .collect(); + versions.sort_by(|a, b| super::version::compare(&b.version, &a.version)); + CatalogMod { + key, + id: m.id.clone(), + name: m.name.clone(), + summary: m.summary.clone(), + authors: m.authors.clone(), + license: m.license.clone(), + homepage: m.homepage.clone(), + icon: m.icon.clone(), + origin, + versions, + } +} + +/// One list from the index and every source. A source release that is also in the index shows +/// once, as reviewed. +pub fn build( + index: Option<&Index>, + index_status: IndexStatus, + sources: Vec<(String, Result, crate::error::AppError>)>, +) -> Catalog { + let mut mods = Vec::new(); + let mut reviewed_hashes = Vec::new(); + if let Some(index) = index { + for m in &index.mods { + reviewed_hashes.extend( + m.versions + .iter() + .map(|v| v.asset.sha256.to_ascii_lowercase()), + ); + mods.push(mod_view(m, Origin::Index, Trust::Reviewed, &[])); + } + } + let mut statuses = Vec::new(); + for (repo, result) in sources { + match result { + Ok(list) => { + let mut count = 0; + for m in &list { + let view = mod_view( + m, + Origin::Source { repo: repo.clone() }, + Trust::Unreviewed, + &reviewed_hashes, + ); + if !view.versions.is_empty() { + count += 1; + mods.push(view); + } + } + statuses.push(SourceStatus { + repo, + mods: count, + error: None, + }); + } + Err(e) => statuses.push(SourceStatus { + repo, + mods: 0, + error: Some(e), + }), + } + } + mods.sort_by_key(|m| m.name.to_lowercase()); + Catalog { + mods, + revoked: index + .map(|i| { + i.revoked + .iter() + .map(|r| RevokedView { + sha256: r.sha256.clone(), + reason: r.reason.clone(), + }) + .collect() + }) + .unwrap_or_default(), + index: index_status, + sources: statuses, + weft: index.map(|i| i.weft.clone()).unwrap_or_default(), + platform: current_platform(), + } +} + +#[cfg(test)] +mod tests { + use super::super::index::tests::version; + use super::*; + + fn index_mod(id: &str, repo: &str, sha: &str) -> IndexMod { + IndexMod { + id: id.into(), + name: id.into(), + summary: String::new(), + authors: vec![], + license: None, + homepage: None, + icon: None, + versions: vec![version(id, repo, sha)], + } + } + + #[test] + fn source_copy_of_a_reviewed_file_shows_once() { + let sha = "a".repeat(64); + let index = Index { + schema: 1, + generated: "2026-11-04T00:00:00Z".into(), + weft: vec![], + mods: vec![index_mod("cogwork", "Zorro909/weft-mods", &sha)], + revoked: vec![], + }; + let status = IndexStatus { + freshness: Freshness::Fresh, + generated: None, + error: None, + }; + let catalog = build( + Some(&index), + status, + vec![( + "Zorro909/weft-mods".into(), + Ok(vec![index_mod("cogwork", "Zorro909/weft-mods", &sha)]), + )], + ); + assert_eq!(catalog.mods.len(), 1); + assert_eq!(catalog.find(&sha).unwrap().1.trust, Trust::Reviewed); + assert_eq!(catalog.sources[0].mods, 0); + } +} diff --git a/src-tauri/src/mods/commands.rs b/src-tauri/src/mods/commands.rs new file mode 100644 index 0000000..ee90ed4 --- /dev/null +++ b/src-tauri/src/mods/commands.rs @@ -0,0 +1,829 @@ +//! The mod manager's Tauri commands. Every change to a profile goes through `ModsLock`, so two +//! clicks never write `mods.lock.json` at once; downloads happen before taking it. + +use serde::Serialize; +use std::path::{Path, PathBuf}; +use std::time::Duration; +use tauri::{AppHandle, Emitter, Manager}; + +use super::abi::{abi_for_tag, cached_abi}; +use super::archive::{self, ArchiveInfo, MAX_FILE_BYTES, current_platform}; +use super::catalog::{ + self, Catalog, CatalogMod, CatalogState, CatalogVersion, IndexStatus, Origin, Trust, +}; +use super::error::ModsError; +use super::index::{self, Channel}; +use super::paths::{downloads_dir, versions_dir}; +use super::profile::{ + self, BUILTIN, Lock, LockEntry, ProfileReport, best_version, read_lock, reconcile, store_path, + write_lock, +}; +use super::sources; +use crate::download::{self, sha256_file}; +use crate::error::AppError; + +#[derive(Default)] +pub struct ModsLock(pub tokio::sync::Mutex<()>); + +fn http() -> Result { + Ok(reqwest::Client::builder() + .connect_timeout(Duration::from_secs(15)) + .build()?) +} + +fn executable_name() -> &'static str { + if cfg!(target_os = "windows") { + "weft.exe" + } else { + "weft" + } +} + +fn ensure_installed(app: &AppHandle, tag: &str) -> Result<(), ModsError> { + crate::install::validate_name(tag).map_err(|_| ModsError::InvalidName(tag.to_string()))?; + if versions_dir(app)? + .join(tag) + .join(executable_name()) + .exists() + { + Ok(()) + } else { + Err(ModsError::VersionNotInstalled(tag.to_string())) + } +} + +/// The running game read its mods at start; changing them under it would only confuse. +fn ensure_not_running(app: &AppHandle, tag: &str) -> Result<(), ModsError> { + match crate::launch::running_game(app) { + Some(running) if running == tag => Err(ModsError::GameRunning(running)), + _ => Ok(()), + } +} + +// --------------------------------------------------------------------------------------------- +// Catalogue + +async fn load_catalog(app: &AppHandle, refresh: bool) -> Catalog { + let client = match http() { + Ok(c) => c, + Err(_) => reqwest::Client::new(), + }; + let state = index::load(app, &client, refresh).await; + let status = IndexStatus { + freshness: state.freshness, + generated: state.index.as_ref().map(|i| i.generated.clone()), + error: state.error.map(AppError::from), + }; + let mut fetched = Vec::new(); + for repo in sources::list(app) { + let result = sources::fetch(app, &client, &repo, refresh) + .await + .map_err(AppError::from); + fetched.push((repo, result)); + } + let catalog = catalog::build(state.index.as_ref(), status, fetched); + *app.state::().0.lock().unwrap() = Some(catalog.clone()); + catalog +} + +/// The list the page last saw, or the cached one: never waits on the network. +async fn current_catalog(app: &AppHandle) -> Catalog { + let held = app.state::().0.lock().unwrap().clone(); + match held { + Some(c) => c, + None => load_catalog(app, false).await, + } +} + +#[tauri::command] +pub async fn list_mod_catalog(app: AppHandle, force_refresh: bool) -> Result { + Ok(load_catalog(&app, force_refresh).await) +} + +#[tauri::command] +pub async fn get_mod_sources(app: AppHandle) -> Result, AppError> { + Ok(sources::list(&app)) +} + +#[tauri::command] +pub async fn add_mod_source(app: AppHandle, repo: String) -> Result, AppError> { + let repo = sources::normalize(&repo)?; + sources::exists(&http()?, &repo).await?; + let mut repos = sources::list(&app); + if !repos.iter().any(|r| r.eq_ignore_ascii_case(&repo)) { + repos.push(repo); + sources::save(&app, &repos)?; + } + Ok(repos) +} + +#[tauri::command] +pub async fn remove_mod_source(app: AppHandle, repo: String) -> Result, AppError> { + let mut repos = sources::list(&app); + repos.retain(|r| !r.eq_ignore_ascii_case(&repo)); + sources::save(&app, &repos)?; + Ok(repos) +} + +// --------------------------------------------------------------------------------------------- +// Profiles + +async fn version_abi( + app: &AppHandle, + catalog: &Catalog, + tag: &str, + network: bool, +) -> (Option, Option) { + if let Some(abi) = cached_abi(app, tag, &catalog.weft) { + return (Some(abi), None); + } + if !network { + return ( + None, + Some(ModsError::AbiUnavailable(tag.to_string()).into()), + ); + } + let client = match http() { + Ok(c) => c, + Err(e) => return (None, Some(e.into())), + }; + match abi_for_tag(app, &client, tag, &catalog.weft).await { + Ok(abi) => (Some(abi), None), + Err(e) => (None, Some(e.into())), + } +} + +/// Mods the player put into their own Weft folder, which every version loads. +async fn unmanaged(app: &AppHandle) -> Vec { + let Some(folder) = crate::launch::find_game_folder(app).await else { + return Vec::new(); + }; + let Ok(entries) = std::fs::read_dir(folder.join("mods")) else { + return Vec::new(); + }; + let mut names: Vec = entries + .flatten() + .map(|e| e.file_name().to_string_lossy().to_string()) + .filter(|n| !n.starts_with('.')) + .collect(); + names.sort(); + names +} + +async fn build_report( + app: &AppHandle, + tag: &str, + lock: &Lock, + catalog: &Catalog, + network: bool, +) -> ProfileReport { + let (abi, abi_error) = version_abi(app, catalog, tag, network).await; + profile::report( + tag, + lock, + abi, + abi_error, + Some(catalog), + unmanaged(app).await, + ) +} + +#[tauri::command] +pub async fn get_mod_profile(app: AppHandle, tag: String) -> Result { + ensure_installed(&app, &tag)?; + let catalog = current_catalog(&app).await; + let guard = app.state::(); + let _held = guard.0.lock().await; + let mut lock = read_lock(&app, &tag)?; + if profile::apply_revocations(&mut lock, &catalog) { + write_lock(&app, &tag, &lock)?; + reconcile(&app, &tag, &lock)?; + } + Ok(build_report(&app, &tag, &lock, &catalog, true).await) +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ChangeResult { + pub report: ProfileReport, + /// Mods downloaded or replaced by this change, dependencies included. + pub installed: Vec, + /// Installed mods turned on because something needs them. + pub also_enabled: Vec, + /// Mods turned off because what they need went away. + pub also_disabled: Vec, + /// Dependencies nothing in the catalogue provides for this version. + pub missing: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct ModProgress<'a> { + sha256: &'a str, + mod_id: &'a str, + stage: &'a str, + percent: u32, +} + +fn emit_progress(app: &AppHandle, sha256: &str, mod_id: &str, stage: &str, percent: u32) { + let _ = app.emit( + "mod-progress", + ModProgress { + sha256, + mod_id, + stage, + percent, + }, + ); +} + +fn temp_file(app: &AppHandle, sha256: &str) -> Result { + let dir = downloads_dir(app)?; + std::fs::create_dir_all(&dir)?; + Ok(dir.join(format!("mod-{sha256}.part"))) +} + +/// Checks a downloaded or copied file and moves it into the store under its hash. +fn admit( + app: &AppHandle, + tmp: &Path, + sha256: &str, + id: Option<&str>, +) -> Result { + let info = archive::inspect(std::fs::File::open(tmp)?, id)?; + if !info.platforms.iter().any(|p| p == current_platform()) { + return Err(ModsError::NotForPlatform(info.id)); + } + let stored = store_path(app, sha256)?; + std::fs::create_dir_all(stored.parent().unwrap())?; + std::fs::rename(tmp, &stored)?; + Ok(info) +} + +async fn fetch_to_store( + app: &AppHandle, + m: &CatalogMod, + v: &CatalogVersion, +) -> Result<(), ModsError> { + let stored = store_path(app, &v.sha256)?; + if stored.exists() && sha256_file(&stored)? == v.sha256 { + return Ok(()); + } + let tmp = temp_file(app, &v.sha256)?; + emit_progress(app, &v.sha256, &m.id, "downloading", 0); + let client = download::client()?; + let limit = v.size.saturating_add(1024).min(MAX_FILE_BYTES); + let result = download::to_file(&client, &v.url, &tmp, Some(limit), |p| { + emit_progress(app, &v.sha256, &m.id, "downloading", p.percent); + }) + .await; + let done = match result { + Ok(d) => d, + Err(e) => { + let _ = std::fs::remove_file(&tmp); + return Err(ModsError::Http { + status: 0, + url: format!("{} ({e})", v.url), + }); + } + }; + emit_progress(app, &v.sha256, &m.id, "verifying", 100); + if done.sha256 != v.sha256 { + let _ = std::fs::remove_file(&tmp); + return Err(ModsError::Sha256Mismatch { + expected: v.sha256.clone(), + actual: done.sha256, + }); + } + admit(app, &tmp, &v.sha256, Some(&m.id)).inspect_err(|_| { + let _ = std::fs::remove_file(&tmp); + })?; + Ok(()) +} + +struct Plan { + picks: Vec<(CatalogMod, CatalogVersion)>, + enable: Vec, + missing: Vec, +} + +/// The requested versions plus whatever they need that isn't on yet. A dependency comes from the +/// index first, then from the same source as the mod that needs it. +fn plan( + catalog: &Catalog, + lock: &Lock, + wanted: Vec<(CatalogMod, CatalogVersion)>, + abi: &str, +) -> Plan { + let mut plan = Plan { + picks: Vec::new(), + enable: Vec::new(), + missing: Vec::new(), + }; + let mut todo = wanted; + while let Some((m, v)) = todo.pop() { + if plan.picks.iter().any(|(p, _)| p.id == m.id) { + continue; + } + for dep in &v.deps { + if BUILTIN.contains(&dep.as_str()) + || plan.picks.iter().any(|(p, _)| &p.id == dep) + || todo.iter().any(|(p, _)| &p.id == dep) + { + continue; + } + if let Some(have) = lock.get(dep) + && have.abi.as_deref().is_none_or(|a| a == abi) + && catalog.revocation(&have.sha256).is_none() + { + if !have.enabled && !plan.enable.contains(dep) { + plan.enable.push(dep.clone()); + } + continue; + } + let mut keys = vec![format!("index:{dep}")]; + if let Origin::Source { repo } = &m.origin { + keys.push(format!("source:{repo}:{dep}")); + } + match keys + .iter() + .find_map(|k| best_version(catalog, k, abi, v.channel)) + { + Some((dm, dv)) => todo.push((dm.clone(), dv.clone())), + None => { + if !plan.missing.contains(dep) { + plan.missing.push(dep.clone()); + } + } + } + } + plan.picks.push((m, v)); + } + plan +} + +fn check_installable( + catalog: &Catalog, + v: &CatalogVersion, + abi: &str, + name: &str, +) -> Result<(), ModsError> { + if let Some(r) = catalog.revocation(&v.sha256) { + return Err(ModsError::Revoked(format!("{name}: {}", r.reason))); + } + if !v.platforms.iter().any(|p| p == current_platform()) { + return Err(ModsError::NotForPlatform(name.to_string())); + } + if v.abi != abi { + return Err(ModsError::Incompatible(name.to_string())); + } + Ok(()) +} + +async fn install_picks( + app: &AppHandle, + tag: &str, + catalog: &Catalog, + wanted: Vec<(CatalogMod, CatalogVersion)>, +) -> Result { + let (abi, abi_error) = version_abi(app, catalog, tag, true).await; + let Some(abi) = abi else { + return Err(abi_error + .map(|e| ModsError::AbiUnavailable(e.detail.unwrap_or_default())) + .unwrap_or_else(|| ModsError::AbiUnavailable(tag.to_string()))); + }; + for (m, v) in &wanted { + check_installable(catalog, v, &abi, &m.name)?; + } + let snapshot = read_lock(app, tag)?; + let plan = plan(catalog, &snapshot, wanted, &abi); + for (m, v) in &plan.picks { + fetch_to_store(app, m, v).await?; + } + + let guard = app.state::(); + let _held = guard.0.lock().await; + let mut lock = read_lock(app, tag)?; + let mut installed = Vec::new(); + for (m, v) in &plan.picks { + emit_progress(app, &v.sha256, &m.id, "installing", 100); + lock.put(LockEntry::from_catalog(m, v)); + installed.push(m.id.clone()); + } + for id in &plan.enable { + if let Some(e) = lock.get_mut(id) { + e.enabled = true; + } + } + write_lock(app, tag, &lock)?; + reconcile(app, tag, &lock)?; + let _ = profile::collect_garbage(app); + Ok(ChangeResult { + report: build_report(app, tag, &lock, catalog, false).await, + installed, + also_enabled: plan.enable, + also_disabled: Vec::new(), + missing: plan.missing, + }) +} + +/// Installs the catalogue version with this hash, and what it needs. Installing another version +/// of an installed mod replaces it. +#[tauri::command] +pub async fn install_mod( + app: AppHandle, + tag: String, + sha256: String, +) -> Result { + ensure_installed(&app, &tag)?; + ensure_not_running(&app, &tag)?; + let catalog = current_catalog(&app).await; + let (m, v) = catalog + .find(&sha256.to_ascii_lowercase()) + .map(|(m, v)| (m.clone(), v.clone())) + .ok_or_else(|| ModsError::NotInCatalog(sha256.clone()))?; + Ok(install_picks(&app, &tag, &catalog, vec![(m, v)]).await?) +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CatalogMatch { + pub key: String, + pub name: String, + pub version: String, + pub trust: Trust, + pub fits_version: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FileInspection { + pub file_name: String, + pub size: u64, + pub sha256: String, + pub archive: Option, + /// Why the file can't be a mod, if it can't. + pub problem: Option, + /// What the mod's own language file calls it. Only what the file says about itself. + pub name: Option, + pub summary: Option, + pub fits_system: bool, + pub catalog_match: Option, + pub revoked_reason: Option, + /// The installed version of a mod with the same id, which this file would replace. + pub replaces: Option, +} + +/// What the launcher can tell about a zip from the player's computer, before installing it. +#[tauri::command] +pub async fn inspect_mod_file( + app: AppHandle, + tag: String, + path: String, +) -> Result { + ensure_installed(&app, &tag)?; + let path = PathBuf::from(path); + let meta = std::fs::metadata(&path)?; + let file_name = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + if meta.len() > MAX_FILE_BYTES { + return Err(ModsError::ArchiveInvalid("larger than 512 MB".into()).into()); + } + let sha256 = sha256_file(&path)?; + let inspected = std::fs::File::open(&path) + .map_err(ModsError::from) + .and_then(|f| archive::inspect(f, None)); + let (archive, problem) = match inspected { + Ok(info) => (Some(info), None), + Err(e) => (None, Some(AppError::from(e))), + }; + let (name, summary) = archive + .as_ref() + .map(|a| archive::read_lang(&path, &a.id)) + .unwrap_or_default(); + let catalog = current_catalog(&app).await; + let (abi, _) = version_abi(&app, &catalog, &tag, true).await; + let catalog_match = catalog.find(&sha256).map(|(m, v)| CatalogMatch { + key: m.key.clone(), + name: m.name.clone(), + version: v.version.clone(), + trust: v.trust, + fits_version: abi.as_deref() == Some(v.abi.as_str()), + }); + let lock = read_lock(&app, &tag)?; + let replaces = archive + .as_ref() + .and_then(|a| lock.get(&a.id)) + .map(|e| e.version.clone().unwrap_or_else(|| e.name.clone())); + Ok(FileInspection { + file_name, + size: meta.len(), + fits_system: archive + .as_ref() + .is_some_and(|a| a.platforms.iter().any(|p| p == current_platform())), + archive, + problem, + name, + summary, + revoked_reason: catalog.revocation(&sha256).map(|r| r.reason.clone()), + catalog_match, + replaces, + sha256, + }) +} + +/// Installs a zip from the player's computer. `sha256` is what `inspect_mod_file` showed, so a +/// file swapped in between is refused. A file that is in the catalogue keeps its trust. +#[tauri::command] +pub async fn install_mod_file( + app: AppHandle, + tag: String, + path: String, + sha256: String, +) -> Result { + ensure_installed(&app, &tag)?; + ensure_not_running(&app, &tag)?; + let catalog = current_catalog(&app).await; + if let Some(r) = catalog.revocation(&sha256) { + return Err(ModsError::Revoked(r.reason.clone()).into()); + } + if let Some((m, v)) = catalog.find(&sha256) { + let wanted = vec![(m.clone(), v.clone())]; + return Ok(install_picks(&app, &tag, &catalog, wanted).await?); + } + + let source = PathBuf::from(&path); + let tmp = temp_file(&app, &sha256)?; + std::fs::copy(&source, &tmp)?; + let copied = sha256_file(&tmp)?; + if copied != sha256 { + let _ = std::fs::remove_file(&tmp); + return Err(ModsError::FileChanged.into()); + } + let info = admit(&app, &tmp, &sha256, None).inspect_err(|_| { + let _ = std::fs::remove_file(&tmp); + })?; + let (name, summary) = archive::read_lang(&store_path(&app, &sha256)?, &info.id); + let file_name = source + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + let entry = LockEntry { + id: info.id.clone(), + name: name.unwrap_or_else(|| info.id.clone()), + summary: summary.unwrap_or_default(), + version: None, + channel: None, + sha256: sha256.clone(), + size: std::fs::metadata(store_path(&app, &sha256)?)?.len(), + enabled: true, + trust: Trust::Unverified, + origin: Origin::File { name: file_name }, + abi: None, + weft_tag: None, + deps: Vec::new(), + tag: None, + source_repo: None, + source_commit: None, + icon: None, + installed_at: profile::now(), + }; + + let guard = app.state::(); + let _held = guard.0.lock().await; + let mut lock = read_lock(&app, &tag)?; + lock.put(entry); + write_lock(&app, &tag, &lock)?; + reconcile(&app, &tag, &lock)?; + let _ = profile::collect_garbage(&app); + Ok(ChangeResult { + report: build_report(&app, &tag, &lock, &catalog, false).await, + installed: vec![info.id], + also_enabled: Vec::new(), + also_disabled: Vec::new(), + missing: Vec::new(), + }) +} + +/// Turning a mod on turns on the installed mods it needs; turning it off turns off what needs it. +#[tauri::command] +pub async fn set_mod_enabled( + app: AppHandle, + tag: String, + id: String, + enabled: bool, +) -> Result { + ensure_installed(&app, &tag)?; + ensure_not_running(&app, &tag)?; + let catalog = current_catalog(&app).await; + let guard = app.state::(); + let _held = guard.0.lock().await; + let mut lock = read_lock(&app, &tag)?; + let entry = lock + .get(&id) + .ok_or_else(|| ModsError::NotInstalled(id.clone()))?; + if enabled && let Some(r) = catalog.revocation(&entry.sha256) { + return Err(ModsError::Revoked(r.reason.clone()).into()); + } + let (mut also_enabled, mut also_disabled) = (Vec::new(), Vec::new()); + if enabled { + for dep in lock.deps_of(&id) { + if let Some(d) = lock.get_mut(&dep) + && !d.enabled + { + d.enabled = true; + also_enabled.push(dep); + } + } + } else { + for dependent in lock.dependents_of(&id) { + if let Some(d) = lock.get_mut(&dependent) { + d.enabled = false; + also_disabled.push(dependent); + } + } + } + lock.get_mut(&id).unwrap().enabled = enabled; + write_lock(&app, &tag, &lock)?; + reconcile(&app, &tag, &lock)?; + Ok(ChangeResult { + report: build_report(&app, &tag, &lock, &catalog, false).await, + installed: Vec::new(), + also_enabled, + also_disabled, + missing: Vec::new(), + }) +} + +#[tauri::command] +pub async fn remove_mod(app: AppHandle, tag: String, id: String) -> Result { + ensure_installed(&app, &tag)?; + ensure_not_running(&app, &tag)?; + let catalog = current_catalog(&app).await; + let guard = app.state::(); + let _held = guard.0.lock().await; + let mut lock = read_lock(&app, &tag)?; + if lock.get(&id).is_none() { + return Err(ModsError::NotInstalled(id).into()); + } + let also_disabled = lock.dependents_of(&id); + for dependent in &also_disabled { + if let Some(d) = lock.get_mut(dependent) { + d.enabled = false; + } + } + lock.mods.retain(|m| m.id != id); + write_lock(&app, &tag, &lock)?; + reconcile(&app, &tag, &lock)?; + let _ = profile::collect_garbage(&app); + Ok(ChangeResult { + report: build_report(&app, &tag, &lock, &catalog, false).await, + installed: Vec::new(), + also_enabled: Vec::new(), + also_disabled, + missing: Vec::new(), + }) +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CopyResult { + #[serde(flatten)] + pub change: ChangeResult, + /// Turned-on mods of the old version with no build for the new one yet. + pub left_behind: Vec, +} + +/// Brings the turned-on mods of one version to another, each as the build made for the new one. +#[tauri::command] +pub async fn copy_mod_profile( + app: AppHandle, + from: String, + to: String, +) -> Result { + ensure_installed(&app, &to)?; + ensure_not_running(&app, &to)?; + let catalog = current_catalog(&app).await; + let (abi, _) = version_abi(&app, &catalog, &to, true).await; + let abi = abi.ok_or_else(|| ModsError::AbiUnavailable(to.clone()))?; + let source = read_lock(&app, &from)?; + let mut wanted = Vec::new(); + let mut left_behind = Vec::new(); + for entry in source.mods.iter().filter(|m| m.enabled) { + let pick = entry.catalog_key().and_then(|key| { + best_version( + &catalog, + &key, + &abi, + entry.channel.unwrap_or(Channel::Release), + ) + }); + match pick { + Some((m, v)) => wanted.push((m.clone(), v.clone())), + None => left_behind.push(entry.name.clone()), + } + } + let change = if wanted.is_empty() { + let lock = read_lock(&app, &to)?; + ChangeResult { + report: build_report(&app, &to, &lock, &catalog, false).await, + installed: Vec::new(), + also_enabled: Vec::new(), + also_disabled: Vec::new(), + missing: Vec::new(), + } + } else { + install_picks(&app, &to, &catalog, wanted).await? + }; + Ok(CopyResult { + change, + left_behind, + }) +} + +/// A mod icon as a `data:` URL: the page may not load images from the network. +#[tauri::command] +pub async fn get_mod_icon(app: AppHandle, url: String) -> Result { + use base64::Engine; + let known = app + .state::() + .0 + .lock() + .unwrap() + .as_ref() + .is_some_and(|c| { + c.mods + .iter() + .any(|m| m.icon.as_deref() == Some(url.as_str())) + }); + if !known || !url.starts_with("https://") { + return Err(ModsError::NotInCatalog(url).into()); + } + let cache = super::paths::cache_dir(&app)? + .join("mod-icons") + .join(format!( + "{}.png", + &crate::download::sha256_hex(url.as_bytes())[..32] + )); + let bytes = match std::fs::read(&cache) { + Ok(b) => b, + Err(_) => { + let response = http()? + .get(&url) + .timeout(Duration::from_secs(15)) + .send() + .await + .map_err(ModsError::from)?; + if !response.status().is_success() { + return Err(ModsError::Http { + status: response.status().as_u16(), + url, + } + .into()); + } + let b = response.bytes().await.map_err(ModsError::from)?.to_vec(); + if b.len() > 512 * 1024 || !b.starts_with(b"\x89PNG\r\n\x1a\n") { + return Err(ModsError::ArchiveInvalid("icon is not a small PNG".into()).into()); + } + let _ = super::paths::write_atomic(&cache, &b); + b + } + }; + Ok(format!( + "data:image/png;base64,{}", + base64::engine::general_purpose::STANDARD.encode(bytes) + )) +} + +#[tauri::command] +pub async fn open_player_mods_folder(app: AppHandle) -> Result<(), AppError> { + let folder = crate::launch::find_game_folder(&app).await.ok_or_else(|| { + crate::launch::LaunchError::GameFolderNotFound("start the game once".into()) + })?; + let mods = folder.join("mods"); + crate::launch::open_path(&app, if mods.exists() { &mods } else { &folder })?; + Ok(()) +} + +/// Before Play: the folder to hand to `--mods`, or why the game would not start. Never waits +/// long on the network; without a known ABI the game itself has the last word. +pub async fn prepare_launch(app: &AppHandle, tag: &str) -> Result, AppError> { + let catalog = current_catalog(app).await; + let guard = app.state::(); + let _held = guard.0.lock().await; + let mut lock = read_lock(app, tag)?; + if profile::apply_revocations(&mut lock, &catalog) { + write_lock(app, tag, &lock)?; + } + reconcile(app, tag, &lock)?; + if !lock.mods.iter().any(|m| m.enabled) { + return Ok(None); + } + let report = build_report(app, tag, &lock, &catalog, true).await; + if !report.blocking.is_empty() { + return Err(ModsError::Blocked(report.blocking.join(", ")).into()); + } + Ok(Some(profile::mods_dir(app, tag)?)) +} diff --git a/src-tauri/src/mods/diagnose.rs b/src-tauri/src/mods/diagnose.rs new file mode 100644 index 0000000..036b8f2 --- /dev/null +++ b/src-tauri/src/mods/diagnose.rs @@ -0,0 +1,121 @@ +//! Names the mod that stopped the game from starting. Weft exits with code 1 and leaves the +//! reason in its log; these are the engine's own messages (`crates/engine/src/modloader.rs`). + +use serde::Serialize; + +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ModFailure { + /// `wrongVersion`, `loadFailed`, `notAMod`, `wrongName`, `missingDep` or `initPanicked`. + pub kind: &'static str, + pub mod_id: Option, + /// For `missingDep`: the mod that is missing. + pub other: Option, + /// The log line, so the player can search for it. + pub line: String, +} + +fn word_after<'a>(line: &'a str, prefix: &str) -> Option<&'a str> { + let rest = &line[line.find(prefix)? + prefix.len()..]; + let end = rest + .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_')) + .unwrap_or(rest.len()); + (end > 0).then(|| &rest[..end]) +} + +/// `…/mods/libcogwork.so` or `C:\…\123-cogwork.dll` → `cogwork`. +fn id_from_library(path: &str) -> Option { + let file = path.rsplit(['/', '\\']).next()?; + let stem = file + .strip_suffix(".dll") + .or_else(|| file.strip_suffix(".so")) + .or_else(|| file.strip_suffix(".dylib"))?; + // The engine loads a copy named `-`. + let stem = stem.split_once('-').map_or(stem, |(pid, rest)| { + if pid.bytes().all(|b| b.is_ascii_digit()) { + rest + } else { + stem + } + }); + let id = stem.strip_prefix("lib").unwrap_or(stem); + (!id.is_empty()).then(|| id.to_string()) +} + +fn classify(line: &str) -> Option { + let failure = |kind, mod_id: Option<&str>, other: Option<&str>| ModFailure { + kind, + mod_id: mod_id.map(str::to_string), + other: other.map(str::to_string), + line: line.trim().to_string(), + }; + if line.contains("was built against another mod ABI") { + return Some(failure("wrongVersion", word_after(line, "mod "), None)); + } + if line.contains(" requires missing mod ") { + return Some(failure( + "missingDep", + word_after(line, "mod "), + word_after(line, " requires missing mod "), + )); + } + if line.contains(" is the crate ") { + return Some(failure("wrongName", word_after(line, "mod "), None)); + } + if line.contains("loading mod ") { + return Some(failure( + "loadFailed", + word_after(line, "loading mod "), + None, + )); + } + if let Some(at) = line.find(" is not a Weft mod") { + let path = line[..at].rsplit(' ').next().unwrap_or(""); + return Some(ModFailure { + mod_id: id_from_library(path), + ..failure("notAMod", None, None) + }); + } + if line.contains("init panicked") { + return Some(failure("initPanicked", None, None)); + } + None +} + +/// The first mod failure in the log of one run. +pub fn find(log: &str) -> Option { + log.lines() + .map(crate::launch::strip_ansi_codes) + .find_map(|l| classify(&l)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn engine_messages() { + let log = "[stderr] \x1b[31mERROR\x1b[0m weft: mod structures was built against another mod ABI (5f0569a6bea71b81, this engine has f470b1cbb1f9ad91); rebuild it\n"; + let f = find(log).unwrap(); + assert_eq!(f.kind, "wrongVersion"); + assert_eq!(f.mod_id.as_deref(), Some("structures")); + + let f = find("ERROR mod cogwork requires missing mod showcase").unwrap(); + assert_eq!( + (f.kind, f.mod_id.as_deref(), f.other.as_deref()), + ("missingDep", Some("cogwork"), Some("showcase")) + ); + + let f = + find("ERROR /home/u/.cache/weft/mods/4242-libmytweak.so is not a Weft mod").unwrap(); + assert_eq!((f.kind, f.mod_id.as_deref()), ("notAMod", Some("mytweak"))); + + let f = find("ERROR loading mod cogwork from /x/libcogwork.so: undefined symbol").unwrap(); + assert_eq!( + (f.kind, f.mod_id.as_deref()), + ("loadFailed", Some("cogwork")) + ); + + assert!(find("INFO loaded mod cogwork 0.2.0 (load position 1)").is_none()); + } +} diff --git a/src-tauri/src/mods/error.rs b/src-tauri/src/mods/error.rs new file mode 100644 index 0000000..7aa2674 --- /dev/null +++ b/src-tauri/src/mods/error.rs @@ -0,0 +1,91 @@ +use thiserror::Error; + +use crate::error::AppError; + +#[derive(Debug, Error)] +pub enum ModsError { + #[error("Network error: {0}")] + Network(#[from] reqwest::Error), + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + #[error("Serialization error: {0}")] + Serde(#[from] serde_json::Error), + #[error("Zip error: {0}")] + Zip(#[from] zip::result::ZipError), + #[error("Unexpected HTTP status {status} for {url}")] + Http { status: u16, url: String }, + #[error("GitHub API rate limit exceeded")] + RateLimited, + #[error("Index signature invalid: {0}")] + IndexSignatureInvalid(String), + #[error("Index is older than the one already verified")] + IndexRollback, + #[error("Index unusable: {0}")] + IndexInvalid(String), + #[error("Mod interface of {0} unknown")] + AbiUnavailable(String), + #[error("Not in the catalog: {0}")] + NotInCatalog(String), + #[error("{0} is not built for this game version")] + Incompatible(String), + #[error("{0} is not built for this system")] + NotForPlatform(String), + #[error("Revoked: {0}")] + Revoked(String), + #[error("Not a Weft mod: {0}")] + ArchiveInvalid(String), + #[error("SHA256 mismatch: expected {expected}, got {actual}")] + Sha256Mismatch { expected: String, actual: String }, + #[error("The file changed since it was checked")] + FileChanged, + #[error("Mod not installed: {0}")] + NotInstalled(String), + #[error("Mods stop the game from starting: {0}")] + Blocked(String), + #[error("Invalid source: {0}")] + SourceInvalid(String), + #[error("Source not found: {0}")] + SourceNotFound(String), + #[error("Invalid name: {0}")] + InvalidName(String), + #[error("Version not installed: {0}")] + VersionNotInstalled(String), + #[error("The game is running: {0}")] + GameRunning(String), +} + +impl From for AppError { + fn from(err: ModsError) -> Self { + use ModsError::*; + let (code, detail) = match err { + Network(e) => ("Network", Some(e.to_string())), + Io(e) => ("Io", Some(e.to_string())), + Serde(e) => ("Serde", Some(e.to_string())), + Zip(e) => ("ModArchiveInvalid", Some(e.to_string())), + Http { status, url } => ("Network", Some(format!("HTTP {status} ({url})"))), + RateLimited => ("RateLimited", None), + IndexSignatureInvalid(d) => ("IndexSignatureInvalid", Some(d)), + IndexRollback => ("IndexRollback", None), + IndexInvalid(d) => ("IndexInvalid", Some(d)), + AbiUnavailable(tag) => ("AbiUnavailable", Some(tag)), + NotInCatalog(d) => ("ModNotInCatalog", Some(d)), + Incompatible(d) => ("ModIncompatible", Some(d)), + NotForPlatform(d) => ("ModNotForPlatform", Some(d)), + Revoked(d) => ("ModRevoked", Some(d)), + ArchiveInvalid(d) => ("ModArchiveInvalid", Some(d)), + Sha256Mismatch { expected, actual } => ( + "Sha256Mismatch", + Some(format!("expected {expected}, got {actual}")), + ), + FileChanged => ("ModFileChanged", None), + NotInstalled(id) => ("ModNotInstalled", Some(id)), + Blocked(ids) => ("ModsBlocked", Some(ids)), + SourceInvalid(d) => ("ModSourceInvalid", Some(d)), + SourceNotFound(d) => ("ModSourceNotFound", Some(d)), + InvalidName(d) => ("InvalidName", Some(d)), + VersionNotInstalled(tag) => ("NotInstalled", Some(tag)), + GameRunning(tag) => ("GameRunning", Some(tag)), + }; + AppError::new(code, detail) + } +} diff --git a/src-tauri/src/mods/index.rs b/src-tauri/src/mods/index.rs new file mode 100644 index 0000000..f0f00aa --- /dev/null +++ b/src-tauri/src/mods/index.rs @@ -0,0 +1,480 @@ +//! The curated mod index: `index.json` plus its minisign signature, as described in +//! `docs/mod-manager/INDEX_FORMAT.md`. Only an index signed with the key below is used. The last +//! one that verified is kept, so the catalogue works offline and an older index can't replace it. + +use minisign_verify::{PublicKey, Signature}; +use serde::{Deserialize, Serialize}; +use std::time::Duration; +use tauri::AppHandle; +use time::OffsetDateTime; +use time::format_description::well_known::Rfc3339; + +use super::abi::{is_abi, is_safe_ref}; +use super::error::ModsError; +use super::paths::{cache_dir, write_atomic}; + +pub const INDEX_URL: &str = "https://zorro909.github.io/weft-mods-index/v1/index.json"; + +/// `keys/index.pub` of the index repository. +pub const INDEX_PUBLIC_KEY: &str = "RWQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + +const MAX_INDEX_BYTES: usize = 8 * 1024 * 1024; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Index { + pub schema: u32, + pub generated: String, + #[serde(default)] + pub weft: Vec, + #[serde(default)] + pub mods: Vec, + #[serde(default)] + pub revoked: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct WeftAbi { + pub tag: String, + pub commit: String, + pub abi: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IndexMod { + pub id: String, + pub name: String, + #[serde(default)] + pub summary: String, + #[serde(default)] + pub authors: Vec, + #[serde(default)] + pub license: Option, + #[serde(default)] + pub homepage: Option, + #[serde(default)] + pub icon: Option, + #[serde(default)] + pub versions: Vec, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum Channel { + Release, + Prerelease, + Experimental, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IndexVersion { + pub version: String, + pub channel: Channel, + pub tag: String, + #[serde(default)] + pub published: String, + pub source: SourceRef, + pub weft: WeftRef, + #[serde(default)] + pub deps: Vec, + #[serde(default)] + pub platforms: Vec, + pub asset: Asset, + #[serde(default)] + pub provenance: Option, + #[serde(default)] + pub reviewed: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SourceRef { + pub repo: String, + pub commit: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WeftRef { + #[serde(default)] + pub tag: Option, + #[serde(default)] + pub commit: String, + pub abi: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Dep { + pub id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Asset { + pub name: String, + pub url: String, + pub size: u64, + pub sha256: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Provenance { + pub kind: String, + #[serde(default)] + pub repo: Option, + #[serde(default)] + pub workflow: Option, + #[serde(default, rename = "ref")] + pub git_ref: Option, + #[serde(default)] + pub verified_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Reviewed { + pub by: String, + pub at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Revoked { + pub sha256: String, + #[serde(default)] + pub reason: String, + #[serde(default)] + pub at: String, +} + +pub fn is_mod_id(s: &str) -> bool { + !s.is_empty() + && s.len() <= 64 + && s.bytes() + .all(|b| matches!(b, b'a'..=b'z' | b'0'..=b'9' | b'_')) +} + +pub fn is_sha256(s: &str) -> bool { + s.len() == 64 && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) +} + +pub fn is_repo(s: &str) -> bool { + let Some((owner, name)) = s.split_once('/') else { + return false; + }; + let part = |p: &str| { + !p.is_empty() + && p.len() <= 100 + && p != "." + && p != ".." + && p.bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.')) + }; + part(owner) && part(name) +} + +/// The contract's rules for one version of one mod. A version that breaks them is left out. +pub fn version_problem(id: &str, v: &IndexVersion) -> Option { + if !is_repo(&v.source.repo) { + return Some(format!("source repo {:?}", v.source.repo)); + } + if v.asset.name != format!("{id}.zip") { + return Some(format!("asset name {:?}", v.asset.name)); + } + let prefix = format!("https://github.com/{}/releases/download/", v.source.repo); + if !v.asset.url.starts_with(&prefix) || v.asset.url.contains("/../") { + return Some(format!("asset url {:?}", v.asset.url)); + } + if !is_sha256(&v.asset.sha256) { + return Some("sha256".into()); + } + if !is_abi(&v.weft.abi) { + return Some("abi".into()); + } + if let Some(tag) = &v.weft.tag + && !is_safe_ref(tag) + { + return Some(format!("weft tag {tag:?}")); + } + if v.deps.iter().any(|d| !is_mod_id(&d.id)) { + return Some("deps".into()); + } + None +} + +/// Keeps what follows the contract and drops the rest, so one bad entry doesn't hide the others. +pub fn sanitize(mut index: Index) -> Index { + index.mods.retain(|m| is_mod_id(&m.id)); + for m in &mut index.mods { + let id = m.id.clone(); + m.versions.retain(|v| match version_problem(&id, v) { + None => true, + Some(problem) => { + eprintln!("mod index: dropping {id} {}: {problem}", v.version); + false + } + }); + m.icon = m.icon.take().filter(|u| u.starts_with("https://")); + m.homepage = m.homepage.take().filter(|u| u.starts_with("https://")); + } + index.mods.retain(|m| !m.versions.is_empty()); + index.weft.retain(|w| is_safe_ref(&w.tag) && is_abi(&w.abi)); + index.revoked.retain(|r| is_sha256(&r.sha256)); + index +} + +pub fn verify(bytes: &[u8], signature: &str, public_key: &str) -> Result { + let key = PublicKey::from_base64(public_key.trim()) + .or_else(|_| PublicKey::decode(public_key)) + .map_err(|e| ModsError::IndexSignatureInvalid(format!("public key: {e}")))?; + let signature = Signature::decode(signature) + .map_err(|e| ModsError::IndexSignatureInvalid(e.to_string()))?; + key.verify(bytes, &signature, false) + .map_err(|e| ModsError::IndexSignatureInvalid(e.to_string()))?; + let index: Index = + serde_json::from_slice(bytes).map_err(|e| ModsError::IndexInvalid(e.to_string()))?; + if index.schema != 1 { + return Err(ModsError::IndexInvalid(format!("schema {}", index.schema))); + } + Ok(sanitize(index)) +} + +fn generated_at(index: &Index) -> Option { + OffsetDateTime::parse(&index.generated, &Rfc3339).ok() +} + +#[derive(Debug, Clone, Copy, Serialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum Freshness { + /// Fetched and verified just now. + Fresh, + /// The last verified copy: offline, or the network answer wasn't usable. + Cached, + /// Nothing verified yet. + Unavailable, +} + +pub struct IndexState { + pub index: Option, + pub freshness: Freshness, + pub error: Option, +} + +fn public_key() -> String { + #[cfg(debug_assertions)] + if let Ok(key) = std::env::var("WEFT_MOD_INDEX_PUBKEY") { + return key; + } + INDEX_PUBLIC_KEY.to_string() +} + +fn index_url() -> String { + #[cfg(debug_assertions)] + if let Ok(url) = std::env::var("WEFT_MOD_INDEX_URL") { + return url; + } + INDEX_URL.to_string() +} + +fn cached(app: &AppHandle) -> Option { + let dir = cache_dir(app).ok()?; + let bytes = std::fs::read(dir.join("mod-index.json")).ok()?; + let sig = std::fs::read_to_string(dir.join("mod-index.json.minisig")).ok()?; + // Checked again: the cache is only a file on disk. + verify(&bytes, &sig, &public_key()).ok() +} + +async fn fetch_text(client: &reqwest::Client, url: &str) -> Result, ModsError> { + let response = client + .get(url) + .timeout(Duration::from_secs(15)) + .send() + .await?; + let status = response.status().as_u16(); + if status != 200 { + return Err(ModsError::Http { + status, + url: url.to_string(), + }); + } + if response.content_length().unwrap_or(0) as usize > MAX_INDEX_BYTES { + return Err(ModsError::IndexInvalid("too large".into())); + } + let bytes = response.bytes().await?; + if bytes.len() > MAX_INDEX_BYTES { + return Err(ModsError::IndexInvalid("too large".into())); + } + Ok(bytes.to_vec()) +} + +/// The newest index this launcher can trust. With `refresh` false only the cache is read. +pub async fn load(app: &AppHandle, client: &reqwest::Client, refresh: bool) -> IndexState { + let cached = cached(app); + if !refresh { + let freshness = if cached.is_some() { + Freshness::Cached + } else { + Freshness::Unavailable + }; + return IndexState { + index: cached, + freshness, + error: None, + }; + } + + let url = index_url(); + let fetched = async { + let bytes = fetch_text(client, &url).await?; + let sig = fetch_text(client, &format!("{url}.minisig")).await?; + let sig = String::from_utf8(sig) + .map_err(|_| ModsError::IndexSignatureInvalid("not text".into()))?; + let index = verify(&bytes, &sig, &public_key())?; + Ok::<_, ModsError>((bytes, sig, index)) + } + .await; + + match fetched { + Ok((bytes, sig, index)) => { + if let Some(old) = &cached + && let (Some(new_at), Some(old_at)) = (generated_at(&index), generated_at(old)) + && new_at < old_at + { + return IndexState { + index: cached, + freshness: Freshness::Cached, + error: Some(ModsError::IndexRollback), + }; + } + if let Ok(dir) = cache_dir(app) { + // Signature first: an index without its signature is never trusted from disk. + let _ = write_atomic(&dir.join("mod-index.json.minisig"), sig.as_bytes()); + let _ = write_atomic(&dir.join("mod-index.json"), &bytes); + } + IndexState { + index: Some(index), + freshness: Freshness::Fresh, + error: None, + } + } + Err(error) => { + let freshness = if cached.is_some() { + Freshness::Cached + } else { + Freshness::Unavailable + }; + IndexState { + index: cached, + freshness, + error: Some(error), + } + } + } +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + + pub fn version(id: &str, repo: &str, sha: &str) -> IndexVersion { + IndexVersion { + version: "0.2.0".into(), + channel: Channel::Release, + tag: format!("{id}-v0.2.0"), + published: "2026-11-04T00:00:00Z".into(), + source: SourceRef { + repo: repo.into(), + commit: "c".repeat(40), + }, + weft: WeftRef { + tag: Some("v26.11.1".into()), + commit: "f".repeat(40), + abi: "f470b1cbb1f9ad91".into(), + }, + deps: vec![Dep { id: "base".into() }], + platforms: vec!["linux-x64".into()], + asset: Asset { + name: format!("{id}.zip"), + url: format!("https://github.com/{repo}/releases/download/{id}-v0.2.0/{id}.zip"), + size: 10, + sha256: sha.into(), + }, + provenance: None, + reviewed: None, + } + } + + #[test] + fn contract_rules_drop_bad_versions() { + let sha = "a".repeat(64); + assert!( + version_problem("cogwork", &version("cogwork", "Zorro909/weft-mods", &sha)).is_none() + ); + + let mut v = version("cogwork", "Zorro909/weft-mods", &sha); + v.asset.url = "https://evil.example/cogwork.zip".into(); + assert!(version_problem("cogwork", &v).is_some()); + + let mut v = version("cogwork", "Zorro909/weft-mods", &sha); + v.asset.url = "https://github.com/Someone/else/releases/download/x/cogwork.zip".into(); + assert!( + version_problem("cogwork", &v).is_some(), + "url must be in the source repo" + ); + + let mut v = version("cogwork", "Zorro909/weft-mods", &sha); + v.asset.name = "other.zip".into(); + assert!(version_problem("cogwork", &v).is_some()); + + assert!( + version_problem("cogwork", &version("cogwork", "Zorro909/weft-mods", "ABC")).is_some() + ); + assert!(version_problem("cogwork", &version("cogwork", "../x", &sha)).is_some()); + } + + #[test] + fn ids_and_repos() { + assert!(is_mod_id("cogwork")); + assert!(is_mod_id("my_mod2")); + assert!(!is_mod_id("Cogwork")); + assert!(!is_mod_id("a-b")); + assert!(!is_mod_id("")); + assert!(is_repo("Zorro909/weft-mods")); + assert!(!is_repo("Zorro909")); + assert!(!is_repo("a/b/c")); + assert!(!is_repo("../b")); + } + + // Signed with a throw-away key made for these tests (`minisign -G -W`); the private half was + // never kept. The real key belongs to the index repository. + const FIXTURE: &[u8] = include_bytes!("testdata/index.json"); + const FIXTURE_SIG: &str = include_str!("testdata/index.json.minisig"); + const FIXTURE_KEY: &str = include_str!("testdata/test.pub"); + + #[test] + fn a_signed_index_verifies() { + let index = verify(FIXTURE, FIXTURE_SIG, FIXTURE_KEY).unwrap(); + assert_eq!(index.mods[0].id, "cogwork"); + assert_eq!(index.weft[0].abi, "f470b1cbb1f9ad91"); + // The bare base64 line works too, as embedded in the launcher. + let key_line = FIXTURE_KEY.lines().nth(1).unwrap(); + assert!(verify(FIXTURE, FIXTURE_SIG, key_line).is_ok()); + } + + #[test] + fn any_change_breaks_the_signature() { + let mut tampered = FIXTURE.to_vec(); + let at = tampered.windows(7).position(|w| w == b"Cogwork").unwrap(); + tampered[at] = b'K'; + assert!(matches!( + verify(&tampered, FIXTURE_SIG, FIXTURE_KEY), + Err(ModsError::IndexSignatureInvalid(_)) + )); + assert!(matches!( + verify(FIXTURE, "not a signature", FIXTURE_KEY), + Err(ModsError::IndexSignatureInvalid(_)) + )); + } + + #[test] + fn another_key_is_refused() { + assert!(matches!( + verify(FIXTURE, FIXTURE_SIG, INDEX_PUBLIC_KEY), + Err(ModsError::IndexSignatureInvalid(_)) + )); + } +} diff --git a/src-tauri/src/mods/mod.rs b/src-tauri/src/mods/mod.rs new file mode 100644 index 0000000..94ae7d9 --- /dev/null +++ b/src-tauri/src/mods/mod.rs @@ -0,0 +1,17 @@ +//! The mod manager: a per-version mod set handed to the game with `--mods`, a signed index of +//! reviewed mods, GitHub repos as extra sources, and the checks between them and the game. +//! Design: `docs/mod-manager/PROPOSAL.md`; index contract: `docs/mod-manager/INDEX_FORMAT.md`. + +pub mod abi; +pub mod archive; +pub mod catalog; +pub mod commands; +pub mod diagnose; +pub mod error; +pub mod index; +pub mod paths; +pub mod profile; +pub mod sources; +pub mod version; + +pub use commands::prepare_launch; diff --git a/src-tauri/src/mods/paths.rs b/src-tauri/src/mods/paths.rs new file mode 100644 index 0000000..58a18df --- /dev/null +++ b/src-tauri/src/mods/paths.rs @@ -0,0 +1,61 @@ +//! Where the mod manager keeps its files, all under the launcher's own data folder: +//! +//! ```text +//! mods/store/.zip every mod file, verified once, named by its hash +//! mods/sources.json GitHub repos the player added as sources +//! profiles//mods/.zip the turned-on mods of one game version, handed to `--mods` +//! profiles//mods.lock.json everything installed for that version +//! cache/mod-index.json(.minisig) the last index whose signature verified +//! ``` + +use std::path::PathBuf; +use tauri::{AppHandle, Manager}; + +use super::error::ModsError; +use crate::install::validate_name; + +fn app_data(app: &AppHandle) -> Result { + app.path() + .app_data_dir() + .map_err(|e| ModsError::Io(std::io::Error::other(e))) +} + +pub fn store_dir(app: &AppHandle) -> Result { + Ok(app_data(app)?.join("mods").join("store")) +} + +pub fn sources_file(app: &AppHandle) -> Result { + Ok(app_data(app)?.join("mods").join("sources.json")) +} + +pub fn profiles_dir(app: &AppHandle) -> Result { + Ok(app_data(app)?.join("profiles")) +} + +pub fn profile_dir(app: &AppHandle, tag: &str) -> Result { + validate_name(tag).map_err(|_| ModsError::InvalidName(tag.to_string()))?; + Ok(profiles_dir(app)?.join(tag)) +} + +pub fn cache_dir(app: &AppHandle) -> Result { + Ok(app_data(app)?.join("cache")) +} + +pub fn downloads_dir(app: &AppHandle) -> Result { + Ok(app_data(app)?.join("downloads")) +} + +pub fn versions_dir(app: &AppHandle) -> Result { + Ok(app_data(app)?.join("versions")) +} + +/// Writes through a temporary file, so a crash never leaves half a file behind. +pub fn write_atomic(path: &std::path::Path, bytes: &[u8]) -> Result<(), ModsError> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let tmp = path.with_extension("tmp"); + std::fs::write(&tmp, bytes)?; + std::fs::rename(&tmp, path)?; + Ok(()) +} diff --git a/src-tauri/src/mods/profile.rs b/src-tauri/src/mods/profile.rs new file mode 100644 index 0000000..0e5a3ae --- /dev/null +++ b/src-tauri/src/mods/profile.rs @@ -0,0 +1,498 @@ +//! The mod set of one game version. `mods.lock.json` lists what is installed; the `mods/` folder +//! beside it holds exactly the turned-on ones and is what the game gets with `--mods`. A mod +//! that is off is not in that folder at all: the engine runs code even from mods it was told to +//! skip, so leaving the file out is the only real "off". + +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeSet, HashSet}; +use std::path::{Path, PathBuf}; +use tauri::AppHandle; + +use super::archive::current_platform; +use super::catalog::{Catalog, CatalogMod, CatalogVersion, Origin, Trust}; +use super::error::ModsError; +use super::index::Channel; +use super::paths::{profile_dir, profiles_dir, store_dir, write_atomic}; +use crate::download::sha256_file; +use crate::error::AppError; + +/// Ships inside every Weft; never installed or checked by the launcher. +pub const BUILTIN: &[&str] = &["base"]; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LockEntry { + pub id: String, + pub name: String, + #[serde(default)] + pub summary: String, + pub version: Option, + pub channel: Option, + pub sha256: String, + pub size: u64, + pub enabled: bool, + pub trust: Trust, + pub origin: Origin, + /// Unknown for a file from the player's computer: only the game can read it from the library. + pub abi: Option, + pub weft_tag: Option, + #[serde(default)] + pub deps: Vec, + pub tag: Option, + pub source_repo: Option, + pub source_commit: Option, + pub icon: Option, + pub installed_at: String, +} + +impl LockEntry { + pub fn from_catalog(m: &CatalogMod, v: &CatalogVersion) -> Self { + LockEntry { + id: m.id.clone(), + name: m.name.clone(), + summary: m.summary.clone(), + version: Some(v.version.clone()), + channel: Some(v.channel), + sha256: v.sha256.clone(), + size: v.size, + enabled: true, + trust: v.trust, + origin: m.origin.clone(), + abi: Some(v.abi.clone()), + weft_tag: v.weft_tag.clone(), + deps: v.deps.clone(), + tag: Some(v.tag.clone()), + source_repo: Some(v.source_repo.clone()), + source_commit: Some(v.source_commit.clone()), + icon: m.icon.clone(), + installed_at: now(), + } + } + + pub fn catalog_key(&self) -> Option { + match &self.origin { + Origin::Index => Some(format!("index:{}", self.id)), + Origin::Source { repo } => Some(format!("source:{repo}:{}", self.id)), + Origin::File { .. } => None, + } + } +} + +pub fn now() -> String { + time::OffsetDateTime::now_utc() + .format(&time::format_description::well_known::Rfc3339) + .unwrap_or_default() +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Lock { + pub schema: u32, + pub mods: Vec, +} + +impl Lock { + pub fn get(&self, id: &str) -> Option<&LockEntry> { + self.mods.iter().find(|m| m.id == id) + } + + pub fn get_mut(&mut self, id: &str) -> Option<&mut LockEntry> { + self.mods.iter_mut().find(|m| m.id == id) + } + + /// Adds the entry, or replaces the installed mod with the same id. + pub fn put(&mut self, entry: LockEntry) { + match self.get_mut(&entry.id) { + Some(existing) => *existing = entry, + None => { + self.mods.push(entry); + self.mods.sort_by(|a, b| a.id.cmp(&b.id)); + } + } + } + + /// Installed mods `id` needs, directly or through others. + pub fn deps_of(&self, id: &str) -> Vec { + let mut out = Vec::new(); + let mut todo = vec![id.to_string()]; + while let Some(next) = todo.pop() { + let Some(entry) = self.get(&next) else { + continue; + }; + for d in &entry.deps { + if !out.contains(d) && d != id && self.get(d).is_some() { + out.push(d.clone()); + todo.push(d.clone()); + } + } + } + out + } + + /// Turned-on mods that need `id`, directly or through others. + pub fn dependents_of(&self, id: &str) -> Vec { + let mut out: Vec = Vec::new(); + let mut todo = vec![id.to_string()]; + while let Some(next) = todo.pop() { + for m in self.mods.iter().filter(|m| m.enabled) { + if m.deps.contains(&next) && !out.contains(&m.id) && m.id != id { + out.push(m.id.clone()); + todo.push(m.id.clone()); + } + } + } + out + } +} + +fn lock_path(app: &AppHandle, tag: &str) -> Result { + Ok(profile_dir(app, tag)?.join("mods.lock.json")) +} + +pub fn mods_dir(app: &AppHandle, tag: &str) -> Result { + Ok(profile_dir(app, tag)?.join("mods")) +} + +pub fn read_lock(app: &AppHandle, tag: &str) -> Result { + let path = lock_path(app, tag)?; + match std::fs::read(&path) { + Ok(bytes) => Ok(serde_json::from_slice(&bytes)?), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Lock { + schema: 1, + mods: Vec::new(), + }), + Err(e) => Err(e.into()), + } +} + +pub fn write_lock(app: &AppHandle, tag: &str, lock: &Lock) -> Result<(), ModsError> { + write_atomic(&lock_path(app, tag)?, &serde_json::to_vec_pretty(lock)?) +} + +pub fn store_path(app: &AppHandle, sha256: &str) -> Result { + Ok(store_dir(app)?.join(format!("{sha256}.zip"))) +} + +fn link_or_copy(from: &Path, to: &Path) -> std::io::Result<()> { + if std::fs::hard_link(from, to).is_ok() { + return Ok(()); + } + std::fs::copy(from, to).map(|_| ()) +} + +/// Makes the profile's `mods/` hold exactly the turned-on mods, each with the bytes that were +/// verified at install. A file changed since then is replaced from the store; a changed store +/// file is an error, never handed to the game. +pub fn reconcile(app: &AppHandle, tag: &str, lock: &Lock) -> Result<(), ModsError> { + let dir = mods_dir(app, tag)?; + std::fs::create_dir_all(&dir)?; + let wanted: Vec<&LockEntry> = lock.mods.iter().filter(|m| m.enabled).collect(); + + for entry in std::fs::read_dir(&dir)?.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if !wanted.iter().any(|m| format!("{}.zip", m.id) == name) { + let path = entry.path(); + if path.is_dir() { + std::fs::remove_dir_all(&path)?; + } else { + std::fs::remove_file(&path)?; + } + } + } + + for m in wanted { + let target = dir.join(format!("{}.zip", m.id)); + if target.exists() && sha256_file(&target)? == m.sha256 { + continue; + } + let _ = std::fs::remove_file(&target); + let stored = store_path(app, &m.sha256)?; + let actual = sha256_file(&stored).map_err(|_| ModsError::NotInstalled(m.id.clone()))?; + if actual != m.sha256 { + return Err(ModsError::Sha256Mismatch { + expected: m.sha256.clone(), + actual, + }); + } + link_or_copy(&stored, &target)?; + } + Ok(()) +} + +/// Deletes stored files no profile uses any more. +pub fn collect_garbage(app: &AppHandle) -> Result<(), ModsError> { + let mut used = HashSet::new(); + if let Ok(entries) = std::fs::read_dir(profiles_dir(app)?) { + for entry in entries.flatten() { + let tag = entry.file_name().to_string_lossy().to_string(); + if let Ok(lock) = read_lock(app, &tag) { + used.extend(lock.mods.into_iter().map(|m| format!("{}.zip", m.sha256))); + } + } + } + if let Ok(entries) = std::fs::read_dir(store_dir(app)?) { + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if !used.contains(&name) { + let _ = std::fs::remove_file(entry.path()); + } + } + } + Ok(()) +} + +/// A deleted game version takes its mod set along. +pub fn forget_version(app: &AppHandle, tag: &str) { + if let Ok(dir) = profile_dir(app, tag) { + let _ = std::fs::remove_dir_all(dir); + } + let _ = collect_garbage(app); +} + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum ModStatus { + /// On, and fits this version as far as the launcher can tell. + Ready, + Off, + /// Built for another game version: Weft would refuse to start. + WrongVersion, + /// Needs a mod that isn't on. + MissingDeps, + /// Pulled from the index. Turned off and never handed to the game. + Revoked, + /// A file from the player's computer: whether it fits is only known when the game starts. + Unchecked, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateView { + pub version: String, + pub sha256: String, + pub channel: Channel, + pub trust: Trust, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InstalledMod { + #[serde(flatten)] + pub entry: LockEntry, + pub status: ModStatus, + pub missing_deps: Vec, + pub revoked_reason: Option, + pub update: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProfileReport { + pub tag: String, + pub version_abi: Option, + pub abi_error: Option, + pub mods: Vec, + /// Turned-on mods that would stop the game from starting. + pub blocking: Vec, + /// What sits in the player's own Weft mods folder. Loads into every version, unchecked. + pub unmanaged: Vec, +} + +fn channel_rank(c: Channel) -> u8 { + match c { + Channel::Release => 0, + Channel::Prerelease => 1, + Channel::Experimental => 2, + } +} + +/// The newest version of `key` that fits `abi` here, no less stable than `at_most`. +pub fn best_version<'a>( + catalog: &'a Catalog, + key: &str, + abi: &str, + at_most: Channel, +) -> Option<(&'a CatalogMod, &'a CatalogVersion)> { + let m = catalog.mods.iter().find(|m| m.key == key)?; + m.versions + .iter() + .filter(|v| v.abi == abi) + .filter(|v| v.platforms.iter().any(|p| p == current_platform())) + .filter(|v| channel_rank(v.channel) <= channel_rank(at_most)) + .filter(|v| catalog.revocation(&v.sha256).is_none()) + .max_by(|a, b| { + super::version::compare(&a.version, &b.version) + .then(channel_rank(b.channel).cmp(&channel_rank(a.channel))) + }) + .map(|v| (m, v)) +} + +/// A newer build for this version, or any fitting build for a mod that doesn't fit. +fn update_for( + entry: &LockEntry, + abi: Option<&str>, + catalog: Option<&Catalog>, +) -> Option { + let catalog = catalog?; + let abi = abi?; + let key = entry.catalog_key()?; + let at_most = entry.channel.unwrap_or(Channel::Release); + let (_, v) = best_version(catalog, &key, abi, at_most)?; + let fits_now = entry.abi.as_deref() == Some(abi); + let newer = entry + .version + .as_deref() + .is_none_or(|cur| super::version::compare(&v.version, cur).is_gt()); + if v.sha256 == entry.sha256 || (fits_now && !newer) { + return None; + } + Some(UpdateView { + version: v.version.clone(), + sha256: v.sha256.clone(), + channel: v.channel, + trust: v.trust, + }) +} + +/// What the launcher knows about each installed mod, and what would stop the game. +pub fn report( + tag: &str, + lock: &Lock, + version_abi: Option, + abi_error: Option, + catalog: Option<&Catalog>, + unmanaged: Vec, +) -> ProfileReport { + let enabled: BTreeSet<&str> = lock + .mods + .iter() + .filter(|m| m.enabled) + .map(|m| m.id.as_str()) + .collect(); + let mut mods = Vec::new(); + let mut blocking = Vec::new(); + for entry in &lock.mods { + let revoked = catalog.and_then(|c| c.revocation(&entry.sha256)); + let missing: Vec = entry + .deps + .iter() + .filter(|d| !BUILTIN.contains(&d.as_str()) && !enabled.contains(d.as_str())) + .cloned() + .collect(); + let status = if revoked.is_some() { + ModStatus::Revoked + } else if !entry.enabled { + ModStatus::Off + } else if let (Some(have), Some(want)) = (entry.abi.as_deref(), version_abi.as_deref()) + && have != want + { + ModStatus::WrongVersion + } else if !missing.is_empty() { + ModStatus::MissingDeps + } else if entry.abi.is_none() || version_abi.is_none() { + ModStatus::Unchecked + } else { + ModStatus::Ready + }; + if matches!(status, ModStatus::WrongVersion | ModStatus::MissingDeps) { + blocking.push(entry.id.clone()); + } + mods.push(InstalledMod { + update: update_for(entry, version_abi.as_deref(), catalog), + entry: entry.clone(), + status, + missing_deps: missing, + revoked_reason: revoked.map(|r| r.reason.clone()), + }); + } + ProfileReport { + tag: tag.to_string(), + version_abi, + abi_error, + mods, + blocking, + unmanaged, + } +} + +/// Turns off every revoked mod. Returns whether anything changed. +pub fn apply_revocations(lock: &mut Lock, catalog: &Catalog) -> bool { + let mut changed = false; + for m in &mut lock.mods { + if m.enabled && catalog.revocation(&m.sha256).is_some() { + m.enabled = false; + changed = true; + } + } + changed +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(id: &str, deps: &[&str], abi: Option<&str>) -> LockEntry { + LockEntry { + id: id.into(), + name: id.into(), + summary: String::new(), + version: Some("0.1.0".into()), + channel: Some(Channel::Release), + sha256: format!("{id:a>64}"), + size: 1, + enabled: true, + trust: Trust::Reviewed, + origin: Origin::Index, + abi: abi.map(str::to_string), + weft_tag: None, + deps: deps.iter().map(|d| d.to_string()).collect(), + tag: None, + source_repo: None, + source_commit: None, + icon: None, + installed_at: String::new(), + } + } + + const ABI: &str = "f470b1cbb1f9ad91"; + + #[test] + fn statuses_and_blocking() { + let mut lock = Lock { + schema: 1, + mods: vec![ + entry("cogwork", &["base", "showcase"], Some(ABI)), + entry("showcase", &[], Some(ABI)), + entry("old", &[], Some("5f0569a6bea71b81")), + entry("file", &[], None), + ], + }; + lock.get_mut("showcase").unwrap().enabled = false; + let r = report("v1", &lock, Some(ABI.into()), None, None, vec![]); + let status = |id: &str| r.mods.iter().find(|m| m.entry.id == id).unwrap().status; + assert_eq!(status("cogwork"), ModStatus::MissingDeps); + assert_eq!(status("showcase"), ModStatus::Off); + assert_eq!(status("old"), ModStatus::WrongVersion); + assert_eq!(status("file"), ModStatus::Unchecked); + assert_eq!(r.blocking, ["cogwork", "old"]); + } + + #[test] + fn dependency_walks() { + let lock = Lock { + schema: 1, + mods: vec![ + entry("a", &["b"], Some(ABI)), + entry("b", &["c", "base"], Some(ABI)), + entry("c", &[], Some(ABI)), + entry("d", &[], Some(ABI)), + ], + }; + let mut deps = lock.deps_of("a"); + deps.sort(); + assert_eq!(deps, ["b", "c"]); + let mut dependents = lock.dependents_of("c"); + dependents.sort(); + assert_eq!(dependents, ["a", "b"]); + assert!(lock.dependents_of("d").is_empty()); + } +} diff --git a/src-tauri/src/mods/sources.rs b/src-tauri/src/mods/sources.rs new file mode 100644 index 0000000..5115e14 --- /dev/null +++ b/src-tauri/src/mods/sources.rs @@ -0,0 +1,396 @@ +//! GitHub repos the player added as mod sources. A release counts when it carries `.zip` and +//! `.mod.json` (see `docs/mod-manager/INDEX_FORMAT.md`). Nobody reviewed these, which the +//! catalogue says; what is checked is that the description and the zip agree with GitHub's own +//! record of the release. + +use reqwest::header::{ETAG, IF_NONE_MATCH, USER_AGENT}; +use serde::{Deserialize, Serialize}; +use std::time::Duration; +use tauri::AppHandle; + +use super::error::ModsError; +use super::index::{ + Asset, Channel, Dep, IndexMod, IndexVersion, Provenance, SourceRef, WeftRef, is_repo, + version_problem, +}; +use super::paths::{cache_dir, sources_file, write_atomic}; + +const USER_AGENT_VALUE: &str = concat!("weft-launcher/", env!("CARGO_PKG_VERSION")); +const MAX_MOD_JSON_BYTES: usize = 64 * 1024; + +#[derive(Debug, Default, Serialize, Deserialize)] +struct SourcesFile { + repos: Vec, +} + +pub fn list(app: &AppHandle) -> Vec { + sources_file(app) + .ok() + .and_then(|p| std::fs::read(p).ok()) + .and_then(|b| serde_json::from_slice::(&b).ok()) + .map(|f| f.repos.into_iter().filter(|r| is_repo(r)).collect()) + .unwrap_or_default() +} + +pub fn save(app: &AppHandle, repos: &[String]) -> Result<(), ModsError> { + let file = SourcesFile { + repos: repos.to_vec(), + }; + write_atomic(&sources_file(app)?, &serde_json::to_vec_pretty(&file)?) +} + +/// Accepts `owner/repo` or a github.com URL of the repo. +pub fn normalize(input: &str) -> Result { + let trimmed = input.trim().trim_end_matches('/'); + let trimmed = trimmed.strip_suffix(".git").unwrap_or(trimmed); + let repo = ["https://github.com/", "http://github.com/", "github.com/"] + .iter() + .find_map(|p| trimmed.strip_prefix(p)) + .unwrap_or(trimmed); + if is_repo(repo) { + Ok(repo.to_string()) + } else { + Err(ModsError::SourceInvalid(input.to_string())) + } +} + +/// `.mod.json`: one version of one mod, with the mod's own fields beside it. +#[derive(Debug, Clone, Deserialize)] +pub struct ModJson { + pub schema: u32, + pub id: String, + pub name: String, + #[serde(default)] + pub summary: String, + #[serde(default)] + pub authors: Vec, + #[serde(default)] + pub license: Option, + #[serde(default)] + pub homepage: Option, + #[serde(default)] + pub icon: Option, + pub version: String, + pub channel: Channel, + pub tag: String, + #[serde(default)] + pub published: String, + pub source: SourceRef, + pub weft: WeftRef, + #[serde(default)] + pub deps: Vec, + #[serde(default)] + pub platforms: Vec, + pub asset: Asset, + #[serde(default)] + pub provenance: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct GhAsset { + name: String, + browser_download_url: String, + size: u64, + #[serde(default)] + digest: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct GhRelease { + tag_name: String, + #[serde(default)] + draft: bool, + prerelease: bool, + #[serde(default)] + published_at: Option, + assets: Vec, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +struct SourceCache { + etag: Option, + mods: Vec, +} + +fn cache_path(app: &AppHandle, repo: &str) -> Result { + Ok(cache_dir(app)? + .join("mod-sources") + .join(format!("{}.json", repo.replace('/', "__")))) +} + +/// Checks one release's description against GitHub's record of the same release. +fn accept(repo: &str, release: &GhRelease, zip: &GhAsset, json: ModJson) -> Option { + let stem = zip.name.strip_suffix(".zip")?; + let digest = zip.digest.as_deref()?.strip_prefix("sha256:")?; + let agrees = json.schema == 1 + && json.id == stem + && json.tag == release.tag_name + && json.source.repo.eq_ignore_ascii_case(repo) + && json.asset.name == zip.name + && json.asset.url == zip.browser_download_url + && json.asset.size == zip.size + && json.asset.sha256.eq_ignore_ascii_case(digest); + if !agrees { + return None; + } + let mut channel = json.channel; + // A release GitHub calls a prerelease never shows up as a plain release. + if release.prerelease && channel == Channel::Release { + channel = Channel::Prerelease; + } + let version = IndexVersion { + version: json.version, + channel, + tag: json.tag, + published: release.published_at.clone().unwrap_or(json.published), + source: SourceRef { + repo: repo.to_string(), + commit: json.source.commit, + }, + weft: json.weft, + deps: json.deps, + platforms: json.platforms, + asset: Asset { + sha256: digest.to_ascii_lowercase(), + ..json.asset + }, + provenance: json.provenance, + reviewed: None, + }; + if version_problem(&json.id, &version).is_some() { + return None; + } + Some(IndexMod { + id: json.id, + name: json.name, + summary: json.summary, + authors: json.authors, + license: json.license, + homepage: json.homepage.filter(|u| u.starts_with("https://")), + icon: json.icon.filter(|u| u.starts_with("https://")), + versions: vec![version], + }) +} + +/// Folds one entry per release into one entry per mod, newest version first. +fn merge(entries: Vec) -> Vec { + let mut mods: Vec = Vec::new(); + for entry in entries { + match mods.iter_mut().find(|m| m.id == entry.id) { + Some(m) => m.versions.extend(entry.versions), + None => mods.push(entry), + } + } + for m in &mut mods { + m.versions + .sort_by(|a, b| super::version::compare(&b.version, &a.version)); + } + mods +} + +async fn get_mod_json(client: &reqwest::Client, url: &str) -> Option { + let response = client + .get(url) + .header(USER_AGENT, USER_AGENT_VALUE) + .timeout(Duration::from_secs(15)) + .send() + .await + .ok()?; + if !response.status().is_success() { + return None; + } + let bytes = response.bytes().await.ok()?; + if bytes.len() > MAX_MOD_JSON_BYTES { + return None; + } + serde_json::from_slice(&bytes).ok() +} + +/// The mods of one source. Falls back to the last good answer when GitHub can't be reached. +pub async fn fetch( + app: &AppHandle, + client: &reqwest::Client, + repo: &str, + refresh: bool, +) -> Result, ModsError> { + let path = cache_path(app, repo)?; + let cached: Option = std::fs::read(&path) + .ok() + .and_then(|b| serde_json::from_slice(&b).ok()); + if !refresh && let Some(c) = cached { + return Ok(c.mods); + } + + let url = format!("https://api.github.com/repos/{repo}/releases?per_page=50"); + let mut request = client + .get(&url) + .header(USER_AGENT, USER_AGENT_VALUE) + .timeout(Duration::from_secs(15)); + if let Some(etag) = cached.as_ref().and_then(|c| c.etag.as_deref()) { + request = request.header(IF_NONE_MATCH, etag); + } + let response = match request.send().await { + Ok(r) => r, + Err(e) => return cached.map(|c| c.mods).ok_or(e.into()), + }; + let etag = response + .headers() + .get(ETAG) + .and_then(|v| v.to_str().ok()) + .map(str::to_string); + match response.status().as_u16() { + 200 => {} + 304 => return Ok(cached.map(|c| c.mods).unwrap_or_default()), + 404 => return Err(ModsError::SourceNotFound(repo.to_string())), + 403 | 429 => return cached.map(|c| c.mods).ok_or(ModsError::RateLimited), + status => { + return cached + .map(|c| c.mods) + .ok_or(ModsError::Http { status, url }); + } + } + let releases: Vec = response.json().await?; + + let mut entries = Vec::new(); + for release in releases.iter().filter(|r| !r.draft) { + for json_asset in release + .assets + .iter() + .filter(|a| a.name.ends_with(".mod.json")) + { + let stem = json_asset.name.trim_end_matches(".mod.json"); + let Some(zip) = release + .assets + .iter() + .find(|a| a.name == format!("{stem}.zip")) + else { + continue; + }; + let Some(json) = get_mod_json(client, &json_asset.browser_download_url).await else { + continue; + }; + if let Some(entry) = accept(repo, release, zip, json) { + entries.push(entry); + } + } + } + let mods = merge(entries); + let cache = SourceCache { + etag, + mods: mods.clone(), + }; + let _ = serde_json::to_vec(&cache).map(|b| write_atomic(&path, &b)); + Ok(mods) +} + +/// Whether the repo exists, before it is added. +pub async fn exists(client: &reqwest::Client, repo: &str) -> Result<(), ModsError> { + let url = format!("https://api.github.com/repos/{repo}"); + let response = client + .get(&url) + .header(USER_AGENT, USER_AGENT_VALUE) + .timeout(Duration::from_secs(15)) + .send() + .await?; + match response.status().as_u16() { + 200 => Ok(()), + 404 => Err(ModsError::SourceNotFound(repo.to_string())), + 403 | 429 => Err(ModsError::RateLimited), + status => Err(ModsError::Http { status, url }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn release(prerelease: bool) -> (GhRelease, GhAsset) { + let zip = GhAsset { + name: "cogwork.zip".into(), + browser_download_url: + "https://github.com/Zorro909/weft-mods/releases/download/cogwork-v0.2.0/cogwork.zip" + .into(), + size: 10, + digest: Some(format!("sha256:{}", "a".repeat(64))), + }; + let r = GhRelease { + tag_name: "cogwork-v0.2.0".into(), + draft: false, + prerelease, + published_at: Some("2026-11-04T00:00:00Z".into()), + assets: vec![zip.clone()], + }; + (r, zip) + } + + fn json() -> ModJson { + let v = + super::super::index::tests::version("cogwork", "Zorro909/weft-mods", &"a".repeat(64)); + ModJson { + schema: 1, + id: "cogwork".into(), + name: "Cogwork".into(), + summary: String::new(), + authors: vec![], + license: None, + homepage: None, + icon: None, + version: v.version, + channel: v.channel, + tag: v.tag, + published: v.published, + source: v.source, + weft: v.weft, + deps: v.deps, + platforms: v.platforms, + asset: v.asset, + provenance: None, + } + } + + #[test] + fn description_must_agree_with_github() { + let (r, zip) = release(false); + assert!(accept("Zorro909/weft-mods", &r, &zip, json()).is_some()); + + let mut j = json(); + j.asset.sha256 = "b".repeat(64); + assert!( + accept("Zorro909/weft-mods", &r, &zip, j).is_none(), + "digest differs" + ); + + let mut j = json(); + j.tag = "cogwork-v9".into(); + assert!( + accept("Zorro909/weft-mods", &r, &zip, j).is_none(), + "tag differs" + ); + + assert!( + accept("Other/repo", &r, &zip, json()).is_none(), + "repo differs" + ); + } + + #[test] + fn github_prerelease_wins() { + let (r, zip) = release(true); + let m = accept("Zorro909/weft-mods", &r, &zip, json()).unwrap(); + assert_eq!(m.versions[0].channel, Channel::Prerelease); + } + + #[test] + fn repo_input_forms() { + assert_eq!( + normalize("Zorro909/weft-mods").unwrap(), + "Zorro909/weft-mods" + ); + assert_eq!( + normalize(" https://github.com/Zorro909/weft-mods.git/ ").unwrap(), + "Zorro909/weft-mods" + ); + assert!(normalize("https://gitlab.com/a/b").is_err()); + assert!(normalize("nope").is_err()); + } +} diff --git a/src-tauri/src/mods/testdata/index.json b/src-tauri/src/mods/testdata/index.json new file mode 100644 index 0000000..4356eca --- /dev/null +++ b/src-tauri/src/mods/testdata/index.json @@ -0,0 +1 @@ +{"schema":1,"generated":"2026-11-04T18:00:00Z","weft":[{"tag":"v26.11.1","commit":"ff9c7128511fc0dce7719b67c9a59274d70bdd5a","abi":"f470b1cbb1f9ad91"}],"mods":[{"id":"cogwork","name":"Cogwork","summary":"Gears","authors":["Zorro909"],"license":null,"homepage":null,"icon":null,"versions":[{"version":"0.2.0","channel":"release","tag":"cogwork-v0.2.0","published":"2026-11-04T00:00:00Z","source":{"repo":"Zorro909/weft-mods","commit":"cceea7805df5902d62017d2008d1bb29ed84e152"},"weft":{"tag":"v26.11.1","commit":"ff9c7128511fc0dce7719b67c9a59274d70bdd5a","abi":"f470b1cbb1f9ad91"},"deps":[{"id":"base"}],"platforms":["windows-x64","linux-x64","macos-arm64"],"asset":{"name":"cogwork.zip","url":"https://github.com/Zorro909/weft-mods/releases/download/cogwork-v0.2.0/cogwork.zip","size":2642469,"sha256":"989b2191aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"provenance":null,"reviewed":{"by":"Zorro909","at":"2026-11-04"}}]}],"revoked":[]} diff --git a/src-tauri/src/mods/testdata/index.json.minisig b/src-tauri/src/mods/testdata/index.json.minisig new file mode 100644 index 0000000..30c9507 --- /dev/null +++ b/src-tauri/src/mods/testdata/index.json.minisig @@ -0,0 +1,4 @@ +untrusted comment: signature from minisign secret key +RUROq6v/++BGPAr5Js76VsVq7EGSPRYcNjNkIFxZ6jNakuhvZDWV3CESll/WvEWacFNBsqsFJ7wCsagm9uSn97XO+PEv6QICIA4= +trusted comment: test fixture +FB1d/8sDgLDzQ52JLJ70/zwyih9B9yjtw5Jb9wpnI8bZipsUU/KbQ+i/OpNuanKpzNAb2plhR1KGJtbeDo8NAg== diff --git a/src-tauri/src/mods/testdata/test.pub b/src-tauri/src/mods/testdata/test.pub new file mode 100644 index 0000000..c913bd9 --- /dev/null +++ b/src-tauri/src/mods/testdata/test.pub @@ -0,0 +1,2 @@ +untrusted comment: minisign public key 3C46E0FBFFABAB4E +RWROq6v/++BGPNeKSeypLw4KkzVB4KN1ysJqNO7j9XdXwXCwbnRpqHwT diff --git a/src-tauri/src/mods/version.rs b/src-tauri/src/mods/version.rs new file mode 100644 index 0000000..4c78155 --- /dev/null +++ b/src-tauri/src/mods/version.rs @@ -0,0 +1,90 @@ +//! Ordering of mod versions such as `0.2.0`, `0.2.1-rc.1` and `0.1.2-experimental.4563878.917596d`: +//! semver precedence, which is all the mods use. + +use std::cmp::Ordering; + +fn split(v: &str) -> (Vec, Option<&str>) { + let v = v.strip_prefix('v').unwrap_or(v); + let v = v.split('+').next().unwrap_or(v); + let (core, pre) = match v.split_once('-') { + Some((c, p)) => (c, Some(p)), + None => (v, None), + }; + let nums = core.split('.').map(|n| n.parse().unwrap_or(0)).collect(); + (nums, pre) +} + +fn cmp_pre(a: &str, b: &str) -> Ordering { + let mut ai = a.split('.'); + let mut bi = b.split('.'); + loop { + match (ai.next(), bi.next()) { + (None, None) => return Ordering::Equal, + (None, Some(_)) => return Ordering::Less, + (Some(_), None) => return Ordering::Greater, + (Some(x), Some(y)) => { + let ord = match (x.parse::(), y.parse::()) { + (Ok(x), Ok(y)) => x.cmp(&y), + (Ok(_), Err(_)) => Ordering::Less, + (Err(_), Ok(_)) => Ordering::Greater, + (Err(_), Err(_)) => x.cmp(y), + }; + if ord != Ordering::Equal { + return ord; + } + } + } + } +} + +pub fn compare(a: &str, b: &str) -> Ordering { + let (an, ap) = split(a); + let (bn, bp) = split(b); + let len = an.len().max(bn.len()); + for i in 0..len { + let ord = an + .get(i) + .copied() + .unwrap_or(0) + .cmp(&bn.get(i).copied().unwrap_or(0)); + if ord != Ordering::Equal { + return ord; + } + } + match (ap, bp) { + (None, None) => Ordering::Equal, + (None, Some(_)) => Ordering::Greater, + (Some(_), None) => Ordering::Less, + (Some(x), Some(y)) => cmp_pre(x, y), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn semver_precedence() { + let mut v = vec![ + "0.2.0", + "0.1.2", + "0.2.1-rc.1", + "0.2.1", + "0.2.1-rc.2", + "0.10.0", + ]; + v.sort_by(|a, b| compare(a, b)); + assert_eq!( + v, + [ + "0.1.2", + "0.2.0", + "0.2.1-rc.1", + "0.2.1-rc.2", + "0.2.1", + "0.10.0" + ] + ); + assert_eq!(compare("v1.0.0", "1.0"), Ordering::Equal); + } +} diff --git a/src/App.tsx b/src/App.tsx index d84e33c..c34f038 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,11 +4,13 @@ import { useI18n } from "./i18n/useI18n"; import { translateError } from "./i18n/errors"; import { useInstall } from "./hooks/useInstall"; import { Sidebar } from "./components/Sidebar"; -import { Tabs } from "./components/Tabs"; +import { Tabs, TabId } from "./components/Tabs"; import { VoxelField } from "./components/VoxelField"; import { Home } from "./pages/Home"; import { VersionsPage } from "./pages/Versions"; import { NotesPage } from "./pages/Notes"; +import { ModsPage } from "./pages/Mods"; +import { useMods } from "./hooks/useMods"; import { getLatestTag, getNewTag } from "./utils/version"; interface AssetInfo { @@ -28,7 +30,6 @@ interface Release { from_cache: boolean; } -type TabId = "home" | "versions" | "notes"; function lastPlayed(): string | null { try { @@ -41,6 +42,7 @@ function lastPlayed(): string | null { function App() { const { t, ready } = useI18n(); const { installedVersions } = useInstall(); + const { profiles } = useMods(); const [releases, setReleases] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -50,6 +52,13 @@ function App() { const latestTag = useMemo(() => getLatestTag(releases), [releases]); const newTag = useMemo(() => getNewTag(latestTag, installedVersions), [latestTag, installedVersions]); + const modsAttention = useMemo( + () => + Object.values(profiles).some( + (p) => p.blocking.length > 0 || p.mods.some((m) => m.update !== null && m.status !== "off") + ), + [profiles] + ); /** Resolves to whether the list came from the cache. */ async function fetchReleases(forceRefresh = false): Promise { @@ -86,6 +95,7 @@ function App() { return ( setActiveTab("versions")} + onGoToMods={() => setActiveTab("mods")} latestTag={latestTag} selectedTag={selectedTag} onSelectTag={setSelectedTag} @@ -102,6 +112,14 @@ function App() { newTag={newTag} /> ); + case "mods": + return ( + setActiveTab("versions")} + /> + ); case "notes": return ; default: @@ -114,7 +132,12 @@ function App() {
- + {/* Keyed so a page always opens at its top, not where the last one was scrolled to. */}
{renderPage()} diff --git a/src/components/Dialog.tsx b/src/components/Dialog.tsx new file mode 100644 index 0000000..ad9c359 --- /dev/null +++ b/src/components/Dialog.tsx @@ -0,0 +1,70 @@ +import { ReactNode, useEffect, useId, useRef } from "react"; + +interface DialogProps { + title: ReactNode; + subtitle?: ReactNode; + onClose: () => void; + /** While true, Escape and the backdrop don't close it: work is in flight. */ + locked?: boolean; + children: ReactNode; +} + +const FOCUSABLE = 'button:not(:disabled), input:not(:disabled), [href], [tabindex]:not([tabindex="-1"])'; + +// Keeps focus inside while open and gives it back to whatever opened it. +export function Dialog({ title, subtitle, onClose, locked = false, children }: DialogProps) { + const box = useRef(null); + const titleId = useId(); + const close = useRef(onClose); + close.current = onClose; + const isLocked = useRef(locked); + isLocked.current = locked; + + useEffect(() => { + const opener = document.activeElement as HTMLElement | null; + const first = box.current?.querySelector("[data-autofocus]") ?? + box.current?.querySelector(FOCUSABLE); + // Without scrolling: a tall dialog should open at its title, not at its buttons. + first?.focus({ preventScroll: true }); + + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape" && !isLocked.current) { + e.stopPropagation(); + close.current(); + } else if (e.key === "Tab" && box.current) { + const items = [...box.current.querySelectorAll(FOCUSABLE)]; + if (items.length === 0) return; + const [head, tail] = [items[0], items[items.length - 1]]; + if (e.shiftKey && document.activeElement === head) { + e.preventDefault(); + tail.focus(); + } else if (!e.shiftKey && document.activeElement === tail) { + e.preventDefault(); + head.focus(); + } + } + }; + document.addEventListener("keydown", onKey, true); + return () => { + document.removeEventListener("keydown", onKey, true); + opener?.focus?.(); + }; + }, []); + + return ( +
{ + if (e.target === e.currentTarget && !isLocked.current) onClose(); + }} + > +
+

+ {title} +

+ {subtitle &&

{subtitle}

} + {children} +
+
+ ); +} diff --git a/src/components/Tabs.tsx b/src/components/Tabs.tsx index 610927a..c7701e0 100644 --- a/src/components/Tabs.tsx +++ b/src/components/Tabs.tsx @@ -2,20 +2,23 @@ import { useI18n } from "../i18n/useI18n"; import { LanguageMenu } from "./LanguageMenu"; import { UpdateButton } from "./UpdateButton"; -type TabId = "home" | "versions" | "notes"; +export type TabId = "home" | "versions" | "mods" | "notes"; interface TabsProps { activeTab: TabId; onChange: (tab: TabId) => void; hasNewVersions: boolean; + /** Something on the Mods tab wants a look: an update, or a mod that would stop the game. */ + modsAttention: boolean; } -export function Tabs({ activeTab, onChange, hasNewVersions }: TabsProps) { +export function Tabs({ activeTab, onChange, hasNewVersions, modsAttention }: TabsProps) { const { t } = useI18n(); const tabs: { id: TabId; label: string }[] = [ { id: "home", label: t("tabHome") }, { id: "versions", label: t("tabVersions") }, + { id: "mods", label: t("tabMods") }, { id: "notes", label: t("tabNotes") }, ]; @@ -33,7 +36,8 @@ export function Tabs({ activeTab, onChange, hasNewVersions }: TabsProps) { onClick={() => onChange(tab.id)} > {tab.label} - {tab.id === "versions" && hasNewVersions && } + {tab.id === "versions" && hasNewVersions && } + {tab.id === "mods" && modsAttention && } ))}
diff --git a/src/components/VersionSelect.tsx b/src/components/VersionSelect.tsx index e11fa60..e6b50d7 100644 --- a/src/components/VersionSelect.tsx +++ b/src/components/VersionSelect.tsx @@ -11,10 +11,12 @@ interface VersionSelectProps { onChange: (value: string) => void; disabled: boolean; label: string; + /** One line tall, for dialogs and page headers. */ + compact?: boolean; } // Not a native setQuery(e.target.value)} + placeholder={t("mods.searchPlaceholder")} + aria-label={t("mods.searchPlaceholder")} + spellCheck={false} + /> + + +
+ + {rows.length === 0 ? ( +
+

+ {catalogLoading && !catalog + ? t("loading") + : query + ? t("mods.browseEmpty") + : onlyFitting + ? t("mods.browseEmptyFits", tag) + : t("mods.browseEmptyAll")} +

+ {onlyFitting && !query && (catalog?.mods.length ?? 0) > 0 && ( + + )} +
+ ) : ( +
    + {rows.map(({ mod, best, installed }) => { + const shown = best ?? mod.versions[0]; + // Installed and nothing better to offer: say so instead of a button that can't work. + const isCurrent = installed && (!best || installed.sha256 === best.sha256); + const onlyPre = !best && fittingVersions(catalog, mod, abi, true).length > 0; + const p = best ? progress[best.sha256] : undefined; + return ( +
  • +
    + +
    + {mod.name} + + + {best && } + +
    +
    +

    {mod.summary || t("mods.noSummary")}

    +
    + + {best ? best.version : onlyPre ? t("mods.onlyPrerelease") : t("mods.notForVersion", tag)} ·{" "} + {mod.origin.kind === "source" ? mod.origin.repo : mod.authors.join(", ") || mod.id} + + {p ? ( + + ) : isCurrent ? ( + {t("mods.installed")} + ) : ( + + )} +
    +
  • + ); + })} +
+ )} + {open && ( + setOpen(null)} + /> + )} + + ); +} diff --git a/src/components/mods/CheckList.tsx b/src/components/mods/CheckList.tsx new file mode 100644 index 0000000..cea18f5 --- /dev/null +++ b/src/components/mods/CheckList.tsx @@ -0,0 +1,32 @@ +import { ReactNode } from "react"; +import { PixelIcon } from "../PixelIcon"; +import { CHECK, CROSS, QUESTION } from "./glyphs"; + +export type CheckState = "yes" | "no" | "unknown"; + +export interface Check { + state: CheckState; + title: ReactNode; + detail?: ReactNode; +} + +const GLYPH: Record = { yes: CHECK, no: CROSS, unknown: QUESTION }; + +/** What was checked, one line each. A list, so a screen reader reads it as one. */ +export function CheckList({ checks, label }: { checks: Check[]; label: string }) { + return ( +
    + {checks.map((c, i) => ( +
  • + + + + + {c.title} + {c.detail && {c.detail}} + +
  • + ))} +
+ ); +} diff --git a/src/components/mods/FileInstallDialog.tsx b/src/components/mods/FileInstallDialog.tsx new file mode 100644 index 0000000..71ad514 --- /dev/null +++ b/src/components/mods/FileInstallDialog.tsx @@ -0,0 +1,182 @@ +import { useEffect, useState } from "react"; +import { useI18n } from "../../i18n/useI18n"; +import { translateError } from "../../i18n/errors"; +import { useMods } from "../../hooks/useMods"; +import { modsApi } from "../../mods/api"; +import type { FileInspection } from "../../mods/types"; +import { shortSha } from "../../mods/select"; +import { Dialog } from "../Dialog"; +import { Check, CheckList } from "./CheckList"; +import { TrustBadge } from "./TrustBadge"; + +interface FileInstallDialogProps { + tag: string; + path: string; + onClose: () => void; +} + +const PLATFORM_LABEL: Record = { + "windows-x64": "Windows", + "linux-x64": "Linux", + "macos-arm64": "macOS", +}; + +/** A zip from the player's computer: what can be checked, and a typed "yes" when nothing could. */ +export function FileInstallDialog({ tag, path, onClose }: FileInstallDialogProps) { + const { t, formatSize } = useI18n(); + const { installFile, busy } = useMods(); + const [inspection, setInspection] = useState(null); + const [error, setError] = useState(null); + const [typed, setTyped] = useState(""); + const fileName = path.split(/[\\/]/).pop() ?? path; + + useEffect(() => { + modsApi + .inspectFile(tag, path) + .then(setInspection) + .catch((err) => setError(translateError(err))); + }, [tag, path]); + + if (!inspection) { + return ( + + {error ? ( +
+ {error} +
+ ) : ( +

+ {t("loading")} +

+ )} +
+ +
+
+ ); + } + + const { archive, catalogMatch: known } = inspection; + const id = archive?.id ?? ""; + const working = !!busy[`${tag}:${id}`]; + const unusable = + !archive || !inspection.fitsSystem || !!inspection.revokedReason || (known !== null && !known.fitsVersion); + const needsTyping = !known && !unusable; + const confirmed = !needsTyping || typed.trim() === id; + const shownName = known?.name ?? inspection.name ?? id; + + const checks: Check[] = []; + if (inspection.revokedReason) { + checks.push({ state: "no", title: t("mods.ckRevoked", inspection.revokedReason) }); + } + if (known) { + checks.push({ state: "yes", title: t("mods.ckFileKnown", known.name, known.version) }); + checks.push( + known.fitsVersion + ? { state: "yes", title: t("mods.ckFits") } + : { state: "no", title: t("mods.ckFileFitsNo", tag) } + ); + } else if (archive) { + checks.push({ state: "no", title: t("mods.ckFileNotKnown") }); + checks.push({ state: "no", title: t("mods.ckNoBuild"), detail: t("mods.ckNoBuildDetail") }); + checks.push({ state: "unknown", title: t("mods.ckFileFitUnknown"), detail: t("mods.ckFileFitUnknownDetail") }); + } + if (archive) { + checks.push( + inspection.fitsSystem + ? { + state: "yes", + title: t("mods.ckLooksLikeMod"), + detail: `${archive.libraries.join(", ")}${archive.hasAssets ? ", assets/" : ""} · ${t("mods.ckNothingElse")}`, + } + : { + state: "no", + title: t("mods.ckNotForSystem"), + detail: t("mods.ckNotForSystemDetail", archive.platforms.map((p) => PLATFORM_LABEL[p] ?? p).join(", ")), + } + ); + } else { + checks.push({ + state: "no", + title: t("mods.ckNotAMod"), + detail: inspection.problem ? translateError(inspection.problem) : undefined, + }); + } + + const confirm = async () => { + setError(null); + const err = await installFile(tag, path, inspection.sha256, id); + if (err) setError(err); + else onClose(); + }; + + const title = !archive + ? t("mods.fileNotAModTitle", fileName) + : known + ? t("mods.fileKnownTitle", fileName, known.name, known.version) + : t("mods.fileUnverifiedTitle", fileName); + + return ( + + {archive && }{" "} + {t("mods.fileFrom", formatSize(inspection.size))} · SHA-256 {shortSha(inspection.sha256)} + + } + > + {archive && !known && inspection.name && ( +

+ {t("mods.fileSaysItIs", shownName)} + {inspection.summary ? ` ${inspection.summary}` : ""} +

+ )} + + {!unusable &&

{t("mods.warnFile")}

} + {inspection.replaces && !unusable &&

{t("mods.ckReplaces", inspection.replaces)}

} + {needsTyping && ( + + )} + {error && ( +
+ {error} +
+ )} +
+ {!unusable && ( + + )} + +
+
+ ); +} diff --git a/src/components/mods/InstallDialog.tsx b/src/components/mods/InstallDialog.tsx new file mode 100644 index 0000000..ce5a279 --- /dev/null +++ b/src/components/mods/InstallDialog.tsx @@ -0,0 +1,181 @@ +import { useMemo, useState } from "react"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { useI18n } from "../../i18n/useI18n"; +import { useMods } from "../../hooks/useMods"; +import type { CatalogMod, CatalogVersion, InstalledMod } from "../../mods/types"; +import { fittingVersions, shortCommit, shortSha, sourceUrl } from "../../mods/select"; +import { Dialog } from "../Dialog"; +import { VersionSelect } from "../VersionSelect"; +import { Check, CheckList } from "./CheckList"; +import { ModProgressBar } from "./ModProgressBar"; +import { ChannelBadge, TrustBadge } from "./TrustBadge"; + +interface InstallDialogProps { + tag: string; + versionAbi: string | null; + mod: CatalogMod; + installed: InstalledMod | undefined; + allowPrerelease: boolean; + onClose: () => void; +} + +function buildChecks( + t: (k: string, ...a: string[]) => string, + v: CatalogVersion, + installedIds: Set +): Check[] { + const checks: Check[] = []; + if (v.trust === "reviewed" && v.reviewed) { + checks.push({ + state: "yes", + title: t("mods.ckIndex"), + detail: t("mods.ckIndexDetail", v.reviewed.by, v.reviewed.at), + }); + } else { + checks.push({ + state: "no", + title: t("mods.ckNotIndex"), + detail: t("mods.ckNotIndexDetail", v.sourceRepo), + }); + } + checks.push({ + state: "yes", + title: ( + <> + {t("mods.ckPinned")} · {shortSha(v.sha256)} + + ), + }); + if (v.provenance) { + checks.push({ + state: "yes", + title: t("mods.ckBuilt"), + detail: ( + <> + {v.provenance.repo ?? v.sourceRepo} @ {shortCommit(v.sourceCommit)} + {v.provenance.workflow ? ` · ${v.provenance.workflow.split("/").pop()}` : ""} + + ), + }); + } else { + checks.push({ state: "unknown", title: t("mods.ckNoBuild"), detail: t("mods.ckNoBuildDetail") }); + } + checks.push({ + state: "yes", + title: t("mods.ckFits"), + detail: ( + <> + {t("mods.ckFitsDetail")} {v.abi.slice(0, 8)} + + ), + }); + const deps = v.deps.filter((d) => d !== "base"); + if (deps.length) { + const toInstall = deps.filter((d) => !installedIds.has(d)); + checks.push({ + state: "yes", + title: t("mods.ckDeps", deps.join(", ")), + detail: toInstall.length ? t("mods.ckDepsDetail", toInstall.join(", ")) : t("mods.ckDepsHave"), + }); + } + return checks; +} + +export function InstallDialog({ tag, versionAbi, mod, installed, allowPrerelease, onClose }: InstallDialogProps) { + const { t, formatSize } = useI18n(); + const { catalog, install, busy, progress, profiles } = useMods(); + // An offered update stays pickable even when prereleases are hidden. + const versions = useMemo( + () => + fittingVersions(catalog, mod, versionAbi, true).filter( + (x) => allowPrerelease || x.channel === "release" || x.sha256 === installed?.update?.sha256 + ), + [catalog, mod, versionAbi, allowPrerelease, installed] + ); + const [sha, setSha] = useState(installed?.update?.sha256 ?? versions[0]?.sha256 ?? ""); + const [error, setError] = useState(null); + const v = versions.find((x) => x.sha256 === sha) ?? versions[0]; + const working = !!busy[`${tag}:${mod.id}`]; + const current = v ? progress[v.sha256] : undefined; + const installedIds = new Set((profiles[tag]?.mods ?? []).map((m) => m.id)); + + if (!v) return null; + const replacing = installed && installed.sha256 !== v.sha256; + const authors = mod.authors.join(", ") || v.sourceRepo; + const link = mod.homepage ?? sourceUrl(v); + + const confirm = async () => { + setError(null); + const err = await install(tag, v.sha256, mod.id); + if (err) setError(err); + else onClose(); + }; + + return ( + + {" "} + {t("mods.dlgFor", tag, formatSize(v.size))} · {t("mods.by", authors)} + + } + > + {(versions.length > 1 || (replacing && installed?.version)) && ( +
+ {versions.length > 1 && ( + <> + {t("mods.dlgVersion")} + ({ + value: x.sha256, + label: + x.channel === "release" + ? x.version + : `${x.version} · ${t(x.channel === "experimental" ? "mods.badgeExperimental" : "mods.badgePrerelease")}`, + }))} + onChange={setSha} + disabled={working} + label={t("mods.dlgVersion")} + compact + /> + + )} + {replacing && installed?.version && {t("mods.ckReplaces", installed.version)}} +
+ )} + +

+ {v.trust === "reviewed" ? t("mods.warnNative", mod.name) : t("mods.warnUnreviewed", mod.name, v.sourceRepo)} +

+ {error && ( +
+ {error} +
+ )} +
+ {working ? ( + + ) : ( + <> + + + + )} + + {link && ( + + )} +
+
+ ); +} diff --git a/src/components/mods/InstalledList.tsx b/src/components/mods/InstalledList.tsx new file mode 100644 index 0000000..e13a309 --- /dev/null +++ b/src/components/mods/InstalledList.tsx @@ -0,0 +1,156 @@ +import { useEffect, useState } from "react"; +import { useI18n } from "../../i18n/useI18n"; +import { useMods } from "../../hooks/useMods"; +import type { InstalledMod, ProfileReport } from "../../mods/types"; +import { StableLabel } from "../StableLabel"; +import { ModIcon } from "./ModIcon"; +import { ModProgressBar } from "./ModProgressBar"; +import { ChannelBadge, TrustBadge } from "./TrustBadge"; + +interface RowProps { + tag: string; + mod: InstalledMod; + onError: (message: string | null) => void; + locked: boolean; +} + +/** One line under the name: what the launcher knows, or what is wrong. */ +function useMeta(mod: InstalledMod, tag: string): { text: string; bad: boolean } { + const { t } = useI18n(); + switch (mod.status) { + case "off": + return { text: t("mods.statusOff"), bad: false }; + case "wrongVersion": + return { + text: mod.weftTag ? t("mods.statusWrongVersionTag", mod.weftTag) : t("mods.statusWrongVersion"), + bad: true, + }; + case "missingDeps": + return { text: t("mods.statusMissingDeps", mod.missingDeps.join(", ")), bad: true }; + case "revoked": + return { text: t("mods.statusRevoked", mod.revokedReason ?? ""), bad: true }; + case "unchecked": + return { + text: mod.origin.kind === "file" ? t("mods.statusUncheckedFile", mod.origin.name) : t("mods.statusUnchecked"), + bad: false, + }; + default: { + const parts = [mod.weftTag ? t("mods.metaBuiltFor", mod.weftTag) : t("mods.metaBuiltForThis", tag)]; + if (mod.origin.kind === "source") parts.push(t("mods.metaFromSource", mod.origin.repo)); + const deps = mod.deps.filter((d) => d !== "base"); + if (deps.length) parts.push(t("mods.metaNeeds", deps.join(", "))); + return { text: parts.join(" · "), bad: false }; + } + } +} + +function ModRow({ tag, mod, onError, locked }: RowProps) { + const { t } = useI18n(); + const { setEnabled, remove, install, busy, progress } = useMods(); + const [confirmRemove, setConfirmRemove] = useState(false); + const meta = useMeta(mod, tag); + const working = !!busy[`${tag}:${mod.id}`]; + const updating = mod.update ? progress[mod.update.sha256] : undefined; + const disabled = working || locked; + + useEffect(() => { + if (!confirmRemove) return; + const timer = setTimeout(() => setConfirmRemove(false), 4000); + return () => clearTimeout(timer); + }, [confirmRemove]); + + const act = async (work: () => Promise) => onError(await work()); + + const onRemove = () => { + if (!confirmRemove) return setConfirmRemove(true); + setConfirmRemove(false); + act(() => remove(tag, mod.id)); + }; + + const fix = mod.status === "wrongVersion"; + const rowClass = [ + "mod-row framed", + mod.enabled ? "" : "is-off", + meta.bad ? "has-problem" : "", + ].join(" "); + + return ( +
  • + +
    +
    + {mod.name} + {mod.version && {mod.version}} + + +
    +

    + {meta.text} +

    + {(updating || mod.update) && ( +
    + {updating ? ( + + ) : ( + mod.update && ( + + ) + )} +
    + )} +
    +
    + + +
    +
  • + ); +} + +interface InstalledListProps { + report: ProfileReport; + onError: (message: string | null) => void; + locked: boolean; +} + +export function InstalledList({ report, onError, locked }: InstalledListProps) { + const { t } = useI18n(); + // Problems first, then what's on, then what's off: what needs a look is on top. + const order = (m: InstalledMod) => + m.status === "wrongVersion" || m.status === "missingDeps" ? 0 : m.status === "revoked" ? 1 : m.enabled ? 2 : 3; + const mods = [...report.mods].sort((a, b) => order(a) - order(b) || a.name.localeCompare(b.name)); + return ( +
      + {mods.map((m) => ( + + ))} +
    + ); +} diff --git a/src/components/mods/ModIcon.tsx b/src/components/mods/ModIcon.tsx new file mode 100644 index 0000000..ec6eeb1 --- /dev/null +++ b/src/components/mods/ModIcon.tsx @@ -0,0 +1,59 @@ +import { useEffect, useState } from "react"; +import { modsApi } from "../../mods/api"; + +const loaded = new Map>(); + +function iconData(url: string): Promise { + let p = loaded.get(url); + if (!p) { + p = modsApi.icon(url).catch(() => null); + loaded.set(url, p); + } + return p; +} + +const COLORS = ["#e09632", "#8c97ad", "#4fa25e", "#c2503c", "#ffc457", "#9bb7e3"]; + +function hash(s: string): number { + let h = 2166136261; + for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 16777619); + return h >>> 0; +} + +/** A mirrored 5×5 pixel pattern from the id, so a mod without an icon still looks like itself. */ +function Glyph({ id }: { id: string }) { + const h = hash(id); + const rects = []; + for (let y = 0; y < 5; y++) { + for (let x = 0; x < 3; x++) { + if ((h >> (y * 3 + x)) & 1) { + rects.push(); + if (x < 2) rects.push(); + } + } + } + return ( + + ); +} + +export function ModIcon({ id, url }: { id: string; url: string | null }) { + const [src, setSrc] = useState(null); + + useEffect(() => { + let live = true; + setSrc(null); + if (url) iconData(url).then((d) => live && setSrc(d)); + return () => { + live = false; + }; + }, [url]); + + return ( + + ); +} diff --git a/src/components/mods/ModProgressBar.tsx b/src/components/mods/ModProgressBar.tsx new file mode 100644 index 0000000..deece16 --- /dev/null +++ b/src/components/mods/ModProgressBar.tsx @@ -0,0 +1,30 @@ +import { useI18n } from "../../i18n/useI18n"; +import type { ModProgress } from "../../mods/types"; + +export function ModProgressBar({ progress }: { progress: ModProgress | undefined }) { + const { t } = useI18n(); + const percent = progress?.percent ?? 0; + const label = + progress?.stage === "verifying" + ? t("mods.progressVerifying") + : progress?.stage === "installing" + ? t("mods.progressInstalling") + : t("mods.progressDownloading", String(percent)); + return ( +
    +
    +
    +
    + + {label} + +
    + ); +} diff --git a/src/components/mods/SourcesDialog.tsx b/src/components/mods/SourcesDialog.tsx new file mode 100644 index 0000000..0c78ae5 --- /dev/null +++ b/src/components/mods/SourcesDialog.tsx @@ -0,0 +1,117 @@ +import { useEffect, useState } from "react"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { useI18n } from "../../i18n/useI18n"; +import { translateError } from "../../i18n/errors"; +import { useMods } from "../../hooks/useMods"; +import { modsApi } from "../../mods/api"; +import { Dialog } from "../Dialog"; +import { TrustBadge } from "./TrustBadge"; + +const INDEX_REPO = "https://github.com/Zorro909/weft-mods-index"; + +export function SourcesDialog({ onClose }: { onClose: () => void }) { + const { t } = useI18n(); + const { catalog, refreshCatalog } = useMods(); + const [repos, setRepos] = useState([]); + const [input, setInput] = useState(""); + const [error, setError] = useState(null); + const [adding, setAdding] = useState(false); + const [changed, setChanged] = useState(false); + + useEffect(() => { + modsApi.sources().then(setRepos).catch(() => {}); + }, []); + + const add = async () => { + if (!input.trim()) return; + setAdding(true); + setError(null); + try { + setRepos(await modsApi.addSource(input)); + setInput(""); + setChanged(true); + } catch (err) { + setError(translateError(err)); + } finally { + setAdding(false); + } + }; + + const remove = async (repo: string) => { + setRepos(await modsApi.removeSource(repo)); + setChanged(true); + }; + + const close = () => { + if (changed) refreshCatalog(); + onClose(); + }; + + const status = (repo: string) => catalog?.sources.find((s) => s.repo === repo); + const index = catalog?.index; + + return ( + +

    {t("mods.sourcesIntro")}

    +
      +
    • + + {t("mods.sourcesIndex")} + + {index?.freshness === "fresh" + ? t("mods.sourcesIndexFresh", index.generated?.slice(0, 10) ?? "") + : index?.freshness === "cached" + ? t("mods.sourcesIndexCached", index.generated?.slice(0, 10) ?? "") + : t("mods.sourcesIndexNone")} + + +
    • + {repos.map((repo) => { + const s = status(repo); + return ( +
    • + + {repo} + + {s?.error ? translateError(s.error) : s ? t("mods.sourcesMods", String(s.mods)) : ""} + + +
    • + ); + })} +
    + +

    {t("mods.sourcesWarning")}

    + {error && ( +
    + {error} +
    + )} +
    + +
    +
    + ); +} diff --git a/src/components/mods/TrustBadge.tsx b/src/components/mods/TrustBadge.tsx new file mode 100644 index 0000000..d528667 --- /dev/null +++ b/src/components/mods/TrustBadge.tsx @@ -0,0 +1,36 @@ +import { useI18n } from "../../i18n/useI18n"; +import type { Channel, Trust } from "../../mods/types"; +import { PixelIcon } from "../PixelIcon"; +import { BANG, CHECK, HAMMER } from "./glyphs"; + +const GLYPH: Record = { reviewed: CHECK, unreviewed: HAMMER, unverified: BANG }; +const LABEL: Record = { + reviewed: "mods.badgeReviewed", + unreviewed: "mods.badgeUnreviewed", + unverified: "mods.badgeUnverified", +}; +const HINT: Record = { + reviewed: "mods.badgeReviewedHint", + unreviewed: "mods.badgeUnreviewedHint", + unverified: "mods.badgeUnverifiedHint", +}; + +export function TrustBadge({ trust }: { trust: Trust }) { + const { t } = useI18n(); + return ( + + + {t(LABEL[trust])} + + ); +} + +export function ChannelBadge({ channel }: { channel: Channel | null }) { + const { t } = useI18n(); + if (!channel || channel === "release") return null; + return ( + + {t(channel === "experimental" ? "mods.badgeExperimental" : "mods.badgePrerelease")} + + ); +} diff --git a/src/components/mods/glyphs.ts b/src/components/mods/glyphs.ts new file mode 100644 index 0000000..dacf86f --- /dev/null +++ b/src/components/mods/glyphs.ts @@ -0,0 +1,9 @@ +// Pixel glyphs on the font's grid, for PixelIcon. + +export const CHECK = ["......#", ".....##", "#...##.", "##.##..", ".###...", "..#...."]; +export const CROSS = ["#....#", "##..##", ".####.", ".####.", "##..##", "#....#"]; +export const BANG = ["##", "##", "##", "##", "..", "##"]; +export const QUESTION = [".####.", "##..##", "...##.", "..##..", "......", "..##.."]; +export const HAMMER = ["#####.", "#####.", "..#...", "..#...", "..#...", "..#..."]; +export const PLUS = ["..##..", "..##..", "######", "######", "..##..", "..##.."]; +export const ARROW_UP = ["..##..", ".####.", "######", "..##..", "..##..", "..##.."]; diff --git a/src/dev/mockBackend.ts b/src/dev/mockBackend.ts new file mode 100644 index 0000000..636bdfe --- /dev/null +++ b/src/dev/mockBackend.ts @@ -0,0 +1,435 @@ +// A stand-in for the Rust side, so the UI runs in a plain browser: `npm run dev:mock`. +// Pick a state with `?scenario=`: mods (default), empty, offline, crash, fresh, knownfile. +// It only imitates the answers; the real rules live in src-tauri/src/mods. + +import { mockIPC, mockWindows } from "@tauri-apps/api/mocks"; +import { emit } from "@tauri-apps/api/event"; +import type { + Catalog, + CatalogMod, + CatalogVersion, + FileInspection, + InstalledMod, + ProfileReport, +} from "../mods/types"; + +const NEW = "v26.11.1"; +const OLD = "v26.10.4"; +const ABI: Record = { [NEW]: "f470b1cbb1f9ad91", [OLD]: "5f0569a6bea71b81" }; +const scenario = new URLSearchParams(location.search).get("scenario") ?? "mods"; + +// Stable fake SHA-256s: FNV-1a over the whole seed, once per 8 hex digits. +const sha = (seed: string) => { + let out = ""; + for (let round = 0; out.length < 64; round++) { + let h = 2166136261 ^ round; + for (let i = 0; i < seed.length; i++) h = Math.imul(h ^ seed.charCodeAt(i), 16777619); + out += (h >>> 0).toString(16).padStart(8, "0"); + } + return out.slice(0, 64); +}; + +function version( + id: string, + v: string, + weftTag: string, + extra: Partial = {} +): CatalogVersion { + return { + version: v, + channel: "release", + tag: `${id}-v${v}`, + published: "2026-11-04T02:39:03Z", + sourceRepo: "Zorro909/weft-mods", + sourceCommit: "cceea7805df5902d62017d2008d1bb29ed84e152", + weftTag, + abi: ABI[weftTag], + deps: ["base"], + platforms: ["windows-x64", "linux-x64", "macos-arm64"], + sha256: sha(`${id}${v}${weftTag}`), + size: 2_642_469, + url: `https://github.com/Zorro909/weft-mods/releases/download/${id}-v${v}/${id}.zip`, + provenance: { + repo: "Zorro909/weft-mods", + workflow: ".github/workflows/release.yml", + gitRef: "refs/heads/main", + verifiedAt: "2026-11-04T18:00:00Z", + }, + reviewed: { by: "Zorro909", at: "2026-11-04" }, + trust: "reviewed", + ...extra, + }; +} + +const unreviewed = { trust: "unreviewed" as const, reviewed: null, provenance: null }; + +const catalogMods: CatalogMod[] = [ + { + key: "index:cogwork", + id: "cogwork", + name: "Cogwork", + summary: "Gears, item tubes and engines, an excavator, an assembly bench and a leveler.", + authors: ["Zorro909"], + license: "MPL-2.0", + homepage: "https://github.com/Zorro909/weft-mods/tree/main/cogwork", + icon: "mock://cogwork", + origin: { kind: "index" }, + versions: [ + version("cogwork", "0.2.1-rc.1", NEW, { channel: "prerelease" }), + version("cogwork", "0.2.0", NEW), + version("cogwork", "0.1.2", OLD), + ], + }, + { + key: "index:structures", + id: "structures", + name: "Structures", + summary: "Overworld buildings and hamlets, a wand, and /structure.", + authors: ["Zorro909"], + license: "MPL-2.0", + homepage: null, + icon: "mock://structures", + origin: { kind: "index" }, + versions: [version("structures", "0.1.3", NEW), version("structures", "0.1.2", NEW), version("structures", "0.1.1", OLD)], + }, + { + key: "index:showcase", + id: "showcase", + name: "Showcase", + summary: "Developer tool: plays and records films that mods define in code.", + authors: ["Zorro909"], + license: "MPL-2.0", + homepage: null, + icon: null, + origin: { kind: "index" }, + versions: [version("showcase", "0.1.1-rc.2", NEW, { channel: "prerelease", deps: [] })], + }, + { + key: "source:lantern-dev/lanterns:lanterns", + id: "lanterns", + name: "Lanterns", + summary: "Hanging lanterns in eight colours that sway in the wind and light up caves.", + authors: ["lantern-dev"], + license: null, + homepage: null, + icon: null, + origin: { kind: "source", repo: "lantern-dev/lanterns" }, + versions: [ + version("lanterns", "1.0.0", OLD, { ...unreviewed, sourceRepo: "lantern-dev/lanterns", sourceCommit: "9a1b2c3d4e5f60718293a4b5c6d7e8f901234567" }), + ], + }, + { + key: "source:lantern-dev/lanterns:glowmoss", + id: "glowmoss", + name: "Glowmoss", + summary: "A soft blue moss that spreads on damp stone and glows at night.", + authors: ["lantern-dev"], + license: "MIT", + homepage: null, + icon: null, + origin: { kind: "source", repo: "lantern-dev/lanterns" }, + versions: [ + version("glowmoss", "0.3.0", NEW, { ...unreviewed, sourceRepo: "lantern-dev/lanterns", sourceCommit: "1f2e3d4c5b6a79881726354a5b6c7d8e9f012345" }), + ], + }, +]; + +const catalog: Catalog = { + mods: catalogMods, + revoked: [], + index: { freshness: "fresh", generated: "2026-11-04T18:00:00Z", error: null }, + sources: [{ repo: "lantern-dev/lanterns", mods: 2, error: null }], + weft: [ + { tag: NEW, commit: "ff9c7128511fc0dce7719b67c9a59274d70bdd5a", abi: ABI[NEW] }, + { tag: OLD, commit: "34fa22b0000000000000000000000000000000000", abi: ABI[OLD] }, + ], + platform: "linux-x64", +}; +if (scenario === "offline") { + catalog.index = { freshness: "cached", generated: "2026-11-02T12:00:00Z", error: { code: "Network", detail: "offline" } }; + catalog.sources[0].error = { code: "Network", detail: "offline" }; +} + +type Entry = Omit; + +function entryFrom(mod: CatalogMod, v: CatalogVersion, enabled = true): Entry { + return { + id: mod.id, + name: mod.name, + summary: mod.summary, + version: v.version, + channel: v.channel, + sha256: v.sha256, + size: v.size, + enabled, + trust: v.trust, + origin: mod.origin, + abi: v.abi, + weftTag: v.weftTag, + deps: v.deps, + tag: v.tag, + sourceRepo: v.sourceRepo, + sourceCommit: v.sourceCommit, + icon: mod.icon, + installedAt: "2026-11-04T12:00:00Z", + }; +} + +const find = (key: string) => catalogMods.find((m) => m.key === key)!; +const pick = (key: string, v: string) => { + const m = find(key); + return entryFrom(m, m.versions.find((x) => x.version === v)!); +}; + +const fileEntry: Entry = { + id: "mytweak", + name: "My Tweak", + summary: "", + version: null, + channel: null, + sha256: sha("mytweak"), + size: 1_153_433, + enabled: true, + trust: "unverified", + origin: { kind: "file", name: "mytweak.zip" }, + abi: null, + weftTag: null, + deps: [], + tag: null, + sourceRepo: null, + sourceCommit: null, + icon: null, + installedAt: "2026-11-04T12:00:00Z", +}; + +const locks: Record = { [NEW]: [], [OLD]: [] }; +if (scenario === "mods" || scenario === "offline" || scenario === "crash") { + locks[NEW] = [ + pick("index:cogwork", "0.2.0"), + pick("index:structures", "0.1.2"), + { ...pick("index:showcase", "0.1.1-rc.2"), enabled: false }, + pick("source:lantern-dev/lanterns:lanterns", "1.0.0"), + fileEntry, + ]; +} +if (scenario === "empty" || scenario === "mods") { + locks[OLD] = [pick("index:cogwork", "0.1.2"), pick("index:structures", "0.1.1")]; +} +const unmanaged = scenario === "mods" ? ["oldtweak.zip"] : []; + +function report(tag: string): ProfileReport { + const abi = ABI[tag] ?? null; + const lock = locks[tag] ?? []; + const on = new Set(lock.filter((m) => m.enabled).map((m) => m.id)); + const mods: InstalledMod[] = lock.map((e) => { + const missing = e.deps.filter((d) => d !== "base" && !on.has(d)); + const status: InstalledMod["status"] = !e.enabled + ? "off" + : e.abi && e.abi !== abi + ? "wrongVersion" + : missing.length + ? "missingDeps" + : e.abi + ? "ready" + : "unchecked"; + const key = e.origin.kind === "index" ? `index:${e.id}` : e.origin.kind === "source" ? `source:${e.origin.repo}:${e.id}` : null; + const best = key + ? catalogMods + .find((m) => m.key === key) + ?.versions.find((v) => v.abi === abi && (v.channel === "release" || e.channel !== "release")) + : undefined; + const update = + best && best.sha256 !== e.sha256 && (status === "wrongVersion" || best.version > (e.version ?? "")) + ? { version: best.version, sha256: best.sha256, channel: best.channel, trust: best.trust } + : null; + return { ...e, status, missingDeps: missing, revokedReason: null, update }; + }); + return { + tag, + versionAbi: abi, + abiError: null, + mods, + blocking: mods.filter((m) => m.status === "wrongVersion" || m.status === "missingDeps").map((m) => m.id), + unmanaged, + }; +} + +const change = (tag: string, installed: string[] = [], alsoDisabled: string[] = []) => ({ + report: report(tag), + installed, + alsoEnabled: [], + alsoDisabled, + missing: [], +}); + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +async function fakeDownload(hash: string, modId: string) { + for (let p = 0; p <= 100; p += 10) { + await emit("mod-progress", { sha256: hash, modId, stage: "downloading", percent: p }); + await sleep(120); + } + await emit("mod-progress", { sha256: hash, modId, stage: "verifying", percent: 100 }); + await sleep(300); +} + +function iconUrl(id: string): string { + const art: Record = { + cogwork: ["...##...", ".#.##.#.", "########", ".##..##.", ".##..##.", "########", ".#.##.#.", "...##..."], + structures: ["...##...", "..####..", ".######.", "########", ".##..##.", ".##..##.", ".##..##.", ".######."], + }; + const rows = art[id]; + const c = document.createElement("canvas"); + c.width = c.height = 8; + const g = c.getContext("2d")!; + g.fillStyle = id === "cogwork" ? "#e09632" : "#8c97ad"; + rows.forEach((row, y) => [...row].forEach((ch, x) => ch === "#" && g.fillRect(x, y, 1, 1))); + return c.toDataURL("image/png"); +} + +const versions = scenario === "fresh" ? [] : [NEW, OLD]; + +export function installMockBackend() { + mockWindows("main"); + mockIPC( + async (cmd, raw) => { + const args = (raw ?? {}) as Record; + switch (cmd) { + case "plugin:app|version": + return "1.2.0"; + case "plugin:updater|check": + return null; + case "plugin:dialog|open": + return "/home/player/Downloads/mytweak.zip"; + case "plugin:opener|open_url": + case "plugin:opener|open_path": + return null; + case "list_versions": + return versions.map((tag, i) => ({ + tag, + name: tag, + published_at: i === 0 ? "2026-11-01T12:00:00Z" : "2026-10-20T12:00:00Z", + body: "", + prerelease: false, + asset: { name: `Weft-${tag}-linux-x64.tar.gz`, url: "", size: 48_000_000, sha256: "a".repeat(64) }, + from_cache: false, + })); + case "get_installed_versions": + return versions.map((tag) => ({ tag, path: `/versions/${tag}`, executable: "weft", installed_at: "2026-11-02T10:00:00Z" })); + case "get_running_versions": + return []; + case "launch_version": { + const r = report(args.tag); + if (r.blocking.length && scenario !== "crash") { + throw { code: "ModsBlocked", detail: r.blocking.join(", ") }; + } + await emit("game-state", { tag: args.tag, state: "running", exitCode: null, crashed: false, modFailure: null }); + setTimeout(() => { + emit("game-state", { + tag: args.tag, + state: "stopped", + exitCode: 1, + crashed: true, + modFailure: + scenario === "crash" + ? { + kind: "wrongVersion", + modId: "lanterns", + other: null, + line: "ERROR mod lanterns was built against another mod ABI (5f0569a6bea71b81, this engine has f470b1cbb1f9ad91)", + } + : null, + }); + }, 1500); + return null; + } + case "open_log": + case "open_game_folder": + case "open_player_mods_folder": + return null; + case "list_mod_catalog": + await sleep(args.forceRefresh ? 400 : 0); + return catalog; + case "get_mod_sources": + return catalog.sources.map((s) => s.repo); + case "add_mod_source": + catalog.sources.push({ repo: args.repo, mods: 0, error: null }); + return catalog.sources.map((s) => s.repo); + case "remove_mod_source": + catalog.sources = catalog.sources.filter((s) => s.repo !== args.repo); + return catalog.sources.map((s) => s.repo); + case "get_mod_profile": + return report(args.tag); + case "get_mod_icon": + return iconUrl(String(args.url).replace("mock://", "")); + case "install_mod": { + const m = catalogMods.find((x) => x.versions.some((v) => v.sha256 === args.sha256))!; + const v = m.versions.find((x) => x.sha256 === args.sha256)!; + await fakeDownload(v.sha256, m.id); + const lock = locks[args.tag]; + const at = lock.findIndex((e) => e.id === m.id); + if (at >= 0) lock[at] = entryFrom(m, v); + else lock.push(entryFrom(m, v)); + return change(args.tag, [m.id]); + } + case "inspect_mod_file": { + const known = + scenario === "knownfile" + ? { key: "index:cogwork", name: "Cogwork", version: "0.2.0", trust: "reviewed" as const, fitsVersion: true } + : null; + const inspection: FileInspection = { + fileName: "mytweak.zip", + size: 1_153_433, + sha256: sha("mytweak"), + archive: { id: "mytweak", libraries: ["libmytweak.so", "mytweak.dll"], platforms: ["linux-x64", "windows-x64"], hasAssets: true, entries: 14 }, + problem: null, + name: "My Tweak", + summary: "Makes torches brighter.", + fitsSystem: true, + catalogMatch: known, + revokedReason: null, + replaces: locks[args.tag].some((e) => e.id === "mytweak") ? "My Tweak" : null, + }; + await sleep(300); + return inspection; + } + case "install_mod_file": { + const lock = locks[args.tag]; + if (!lock.some((e) => e.id === "mytweak")) lock.push(fileEntry); + return change(args.tag, ["mytweak"]); + } + case "set_mod_enabled": { + const lock = locks[args.tag]; + const e = lock.find((x) => x.id === args.id)!; + e.enabled = args.enabled; + const off = args.enabled ? [] : lock.filter((x) => x.enabled && x.deps.includes(args.id)).map((x) => x.id); + off.forEach((id) => (lock.find((x) => x.id === id)!.enabled = false)); + await sleep(150); + return change(args.tag, [], off); + } + case "remove_mod": + locks[args.tag] = locks[args.tag].filter((x) => x.id !== args.id); + return change(args.tag); + case "copy_mod_profile": { + const from = locks[args.from].filter((e) => e.enabled); + const moved: string[] = []; + const left: string[] = []; + for (const e of from) { + const m = catalogMods.find((x) => x.id === e.id && x.origin.kind === e.origin.kind); + const v = m?.versions.find((x) => x.abi === ABI[args.to] && x.channel === "release"); + if (m && v) { + await fakeDownload(v.sha256, m.id); + locks[args.to].push(entryFrom(m, v)); + moved.push(m.id); + } else left.push(e.name); + } + return { ...change(args.to, moved), leftBehind: left }; + } + default: + console.warn("mock backend: unhandled", cmd, args); + return null; + } + }, + { shouldMockEvents: true } + ); +} diff --git a/src/hooks/useGameState.tsx b/src/hooks/useGameState.tsx index 38ddb30..9423f2e 100644 --- a/src/hooks/useGameState.tsx +++ b/src/hooks/useGameState.tsx @@ -2,17 +2,21 @@ import { createContext, useContext, useEffect, useState, useCallback, ReactNode import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { translateError } from "../i18n/errors"; +import type { ModFailure } from "../mods/types"; interface GameStateEvent { tag: string; state: "running" | "stopped"; exitCode: number | null; crashed: boolean; + modFailure: ModFailure | null; } export interface GameCrash { tag: string; exitCode: number | null; + /** The mod the log blames, when the game stopped while loading mods. */ + modFailure: ModFailure | null; } interface GameStateContextValue { @@ -55,7 +59,7 @@ export function GameStateProvider({ children }: { children: ReactNode }) { let unlisten: (() => void) | undefined; listen("game-state", (event) => { - const { tag, state, exitCode, crashed } = event.payload; + const { tag, state, exitCode, crashed, modFailure } = event.payload; setRunningTags((prev) => { const next = new Set(prev); if (state === "running") { @@ -66,7 +70,7 @@ export function GameStateProvider({ children }: { children: ReactNode }) { return next; }); if (state === "stopped" && crashed) { - setLastCrash({ tag, exitCode }); + setLastCrash({ tag, exitCode, modFailure: modFailure ?? null }); } }).then((fn) => { if (cancelled) fn(); diff --git a/src/hooks/useMods.tsx b/src/hooks/useMods.tsx new file mode 100644 index 0000000..9b3861a --- /dev/null +++ b/src/hooks/useMods.tsx @@ -0,0 +1,214 @@ +import { createContext, useCallback, useContext, useEffect, useRef, useState, ReactNode } from "react"; +import { listen } from "@tauri-apps/api/event"; +import { modsApi } from "../mods/api"; +import type { Catalog, ChangeResult, CopyResult, ModProgress, ProfileReport } from "../mods/types"; +import { translateError } from "../i18n/errors"; +import { useI18n } from "../i18n/useI18n"; + +export interface Notice { + tag: string; + tone: "ok" | "warn"; + lines: string[]; +} + +interface ModsContextValue { + catalog: Catalog | null; + catalogLoading: boolean; + refreshCatalog: () => Promise; + profiles: Record; + profileErrors: Record; + loadProfile: (tag: string) => Promise; + /** Download progress by file hash. */ + progress: Record; + /** Work in flight, by `:` or `:`. */ + busy: Record; + notice: Notice | null; + clearNotice: () => void; + install: (tag: string, sha256: string, modId: string) => Promise; + installFile: (tag: string, path: string, sha256: string, modId: string) => Promise; + setEnabled: (tag: string, id: string, enabled: boolean) => Promise; + remove: (tag: string, id: string) => Promise; + copyProfile: (from: string, to: string) => Promise; +} + +const ModsContext = createContext(null); + +function without(record: Record, key: string): Record { + const { [key]: _, ...rest } = record; + return rest; +} + +export function ModsProvider({ children }: { children: ReactNode }) { + const { t } = useI18n(); + const [catalog, setCatalog] = useState(null); + const [catalogLoading, setCatalogLoading] = useState(true); + const [profiles, setProfiles] = useState>({}); + const [profileErrors, setProfileErrors] = useState>({}); + const [progress, setProgress] = useState>({}); + const [busy, setBusy] = useState>({}); + const [notice, setNotice] = useState(null); + const loadedTags = useRef(new Set()); + + const loadProfile = useCallback(async (tag: string) => { + loadedTags.current.add(tag); + try { + const report = await modsApi.profile(tag); + setProfiles((p) => ({ ...p, [tag]: report })); + setProfileErrors((e) => without(e, tag)); + } catch (err) { + setProfileErrors((e) => ({ ...e, [tag]: translateError(err) })); + } + }, []); + + const refreshCatalog = useCallback(async () => { + setCatalogLoading(true); + try { + setCatalog(await modsApi.catalog(true)); + } catch (err) { + console.error("Mod catalogue:", err); + } finally { + setCatalogLoading(false); + } + // Revocations and updates come with the catalogue. + await Promise.all([...loadedTags.current].map((tag) => loadProfile(tag))); + }, [loadProfile]); + + // The cache shows something at once, then the network says what is new. + useEffect(() => { + modsApi + .catalog(false) + .then(setCatalog) + .catch(() => {}) + .finally(() => refreshCatalog()); + }, [refreshCatalog]); + + useEffect(() => { + let cancelled = false; + let unlisten: (() => void) | undefined; + listen("mod-progress", (e) => { + setProgress((p) => ({ ...p, [e.payload.sha256]: e.payload })); + }).then((fn) => { + if (cancelled) fn(); + else unlisten = fn; + }); + return () => { + cancelled = true; + unlisten?.(); + }; + }, []); + + const nameOf = useCallback( + (report: ProfileReport, id: string) => report.mods.find((m) => m.id === id)?.name ?? id, + [] + ); + + const describe = useCallback( + (tag: string, result: ChangeResult | CopyResult, primary: string | null) => { + const names = (ids: string[]) => ids.map((id) => nameOf(result.report, id)).join(", "); + const lines: string[] = []; + const deps = result.installed.filter((id) => id !== primary); + if (primary && result.installed.includes(primary)) { + lines.push(t("mods.noticeInstalled", nameOf(result.report, primary))); + } + if (deps.length && primary) lines.push(t("mods.noticeDepsInstalled", names(deps))); + if (result.alsoEnabled.length) lines.push(t("mods.noticeAlsoEnabled", names(result.alsoEnabled))); + if (result.alsoDisabled.length) lines.push(t("mods.noticeAlsoDisabled", names(result.alsoDisabled))); + if (result.missing.length) lines.push(t("mods.noticeMissing", result.missing.join(", "))); + if ("leftBehind" in result && result.leftBehind.length) { + lines.push(t("mods.noticeLeftBehind", result.leftBehind.join(", "))); + } + const warn = result.missing.length > 0 || ("leftBehind" in result && result.leftBehind.length > 0); + setNotice(lines.length ? { tag, tone: warn ? "warn" : "ok", lines } : null); + }, + [nameOf, t] + ); + + const run = useCallback( + async ( + tag: string, + key: string, + work: () => Promise, + after?: (r: R) => void + ): Promise => { + setBusy((b) => ({ ...b, [key]: true })); + try { + const result = await work(); + setProfiles((p) => ({ ...p, [tag]: result.report })); + after?.(result); + return null; + } catch (err) { + return translateError(err); + } finally { + setBusy((b) => without(b, key)); + } + }, + [] + ); + + const install = useCallback( + (tag: string, sha256: string, modId: string) => { + setProgress((p) => ({ ...p, [sha256]: { sha256, modId, stage: "downloading", percent: 0 } })); + return run(tag, `${tag}:${modId}`, () => modsApi.install(tag, sha256), (r) => describe(tag, r, modId)).finally( + () => setProgress((p) => without(p, sha256)) + ); + }, + [run, describe] + ); + + const installFile = useCallback( + (tag: string, path: string, sha256: string, modId: string) => + run(tag, `${tag}:${modId}`, () => modsApi.installFile(tag, path, sha256), (r) => describe(tag, r, modId)), + [run, describe] + ); + + const setEnabled = useCallback( + (tag: string, id: string, enabled: boolean) => + run(tag, `${tag}:${id}`, () => modsApi.setEnabled(tag, id, enabled), (r) => describe(tag, r, null)), + [run, describe] + ); + + const remove = useCallback( + (tag: string, id: string) => + run(tag, `${tag}:${id}`, () => modsApi.remove(tag, id), (r) => describe(tag, r, null)), + [run, describe] + ); + + const copyProfile = useCallback( + (from: string, to: string) => + run(to, `${to}:copy`, () => modsApi.copyProfile(from, to), (r) => { + const lines = [t("mods.noticeCopied", String(r.installed.length), from)]; + if (r.leftBehind.length) lines.push(t("mods.noticeLeftBehind", r.leftBehind.join(", "))); + if (r.missing.length) lines.push(t("mods.noticeMissing", r.missing.join(", "))); + setNotice({ tag: to, tone: r.leftBehind.length || r.missing.length ? "warn" : "ok", lines }); + }), + [run, t] + ); + + const clearNotice = useCallback(() => setNotice(null), []); + + const value: ModsContextValue = { + catalog, + catalogLoading, + refreshCatalog, + profiles, + profileErrors, + loadProfile, + progress, + busy, + notice, + clearNotice, + install, + installFile, + setEnabled, + remove, + copyProfile, + }; + + return {children}; +} + +export function useMods(): ModsContextValue { + const context = useContext(ModsContext); + if (!context) throw new Error("useMods must be used within a ModsProvider"); + return context; +} diff --git a/src/i18n/en.json b/src/i18n/en.json index 6b86f8b..f68c07b 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -1,9 +1,9 @@ { "updateAvailable": "UPDATE AVAILABLE", -"updateDownloading": "UPDATING {0}%", -"updateFailed": "UPDATE FAILED · RETRY", -"updateTooltip": "Update to version {0} and restart", -"updateCloseGame": "Close the game before updating", + "updateDownloading": "UPDATING {0}%", + "updateFailed": "UPDATE FAILED · RETRY", + "updateTooltip": "Update to version {0} and restart", + "updateCloseGame": "Close the game before updating", "latestBadge": "Latest", "appTitle": "Weft Launcher", "refreshButton": "Refresh list", @@ -36,6 +36,8 @@ "openLogButton": "Open log", "tabHome": "Home", "tabVersions": "Versions", + "tabMods": "Mods", + "newVersionsDot": "New versions available", "tabNotes": "Release Notes", "tabsLabel": "Main tabs", "selectVersionLabel": "Select version", @@ -78,7 +80,176 @@ "logDirCreateFailed": "Failed to create log directory: {0}", "gameFolderNotFound": "Game folder not found: {0}", "unsupportedPlatform": "Game folder location not confirmed on this platform", - "openerError": "Failed to open: {0}" + "openerError": "Failed to open: {0}", + "indexSignatureInvalid": "The mod index failed its signature check: {0}", + "indexRollback": "The mod index is older than the one already verified", + "indexInvalid": "The mod index can't be read: {0}", + "abiUnavailable": "Couldn't find out which mods fit {0}. Check your connection.", + "modNotInCatalog": "That mod isn't in the catalogue any more. Refresh and try again.", + "modIncompatible": "{0} isn't built for this game version", + "modNotForPlatform": "{0} has no library for this system", + "modRevoked": "Pulled from the index: {0}", + "modArchiveInvalid": "Not a Weft mod: {0}", + "modFileChanged": "The file changed after it was checked. Choose it again.", + "modNotInstalled": "Mod not installed: {0}", + "modsBlocked": "Weft won't start with these mods on: {0}. Fix them on the Mods tab.", + "modSourceInvalid": "Not a GitHub repo: {0}", + "modSourceNotFound": "No such GitHub repo: {0}" + }, + "mods": { + "title": "Mods", + "forVersion": "for", + "versionLabel": "Game version these mods are for", + "fromFile": "From file", + "fileFilter": "Weft mod", + "sources": "Sources", + "refresh": "Check for new mods", + "tabInstalled": "Installed", + "tabBrowse": "Browse", + "installedListLabel": "Mods installed for {0}", + "noVersions": "Install a game version first. Each version keeps its own mods.", + "emptyInstalled": "No mods for {0} yet.", + "emptyInstalledHint": "Each game version keeps its own mods, because a mod only loads into the version it was built for.", + "browseMods": "Browse mods", + "copyButton": "Bring mods from {0}", + "runningBanner": "{0} is running. Close it to change its mods.", + "blockingBanner": "Weft {0} won't start with these mods on: {1}.", + "unmanagedBanner": "Your own Weft mods folder has {0}. Mods there load in every version, and the launcher can't check them.", + "unmanagedShow": "Show", + "abiUnknown": "Couldn't check which mods fit {0}. The game checks them when it starts.", + "dismiss": "Dismiss", + "metaBuiltFor": "built for {0}", + "metaBuiltForThis": "fits {0}", + "metaFromSource": "from {0}", + "metaNeeds": "needs {0}", + "statusOff": "Off. The game doesn't see it.", + "statusWrongVersion": "Built for another game version. Weft won't start with it on.", + "statusWrongVersionTag": "Built for {0}. Weft won't start with it on.", + "statusMissingDeps": "Needs {0}, which isn't on.", + "statusRevoked": "Pulled from the index: {0}. Turned off.", + "statusUnchecked": "Can't be checked before the game starts.", + "statusUncheckedFile": "From your computer ({0}). Checked when the game starts.", + "on": "On", + "off": "Off", + "toggleLabel": "{0} on", + "remove": "Remove", + "removeConfirm": "Confirm", + "removeLabel": "Remove {0}", + "update": "Update to {0}", + "updateTooltip": "Install {0} in its place", + "updateButton": "Update", + "installFitting": "Get {0}", + "badgeReviewed": "Reviewed", + "badgeUnreviewed": "Unreviewed", + "badgeUnverified": "Unverified", + "badgeReviewedHint": "In the signed Weft mod index: someone reviewed exactly this file.", + "badgeUnreviewedHint": "From a GitHub repo you added. Published by that repo; nobody reviewed it.", + "badgeUnverifiedHint": "A file from your computer. Where it came from can't be checked.", + "badgePrerelease": "Prerelease", + "badgeExperimental": "Experimental", + "searchPlaceholder": "Search mods", + "filterFits": "Fits {0}", + "filterPrereleases": "Prereleases", + "install": "Install", + "installed": "Installed", + "notForVersion": "No build for {0}", + "onlyPrerelease": "Prereleases only", + "noSummary": "No description.", + "browseEmpty": "No mods match your search.", + "browseEmptyFits": "No mods have a build for {0} yet.", + "browseEmptyAll": "No mods found.", + "showAll": "Show all mods", + "indexCached": "Showing the last mod index the launcher verified. It couldn't check for a newer one.", + "indexUnavailable": "The Weft mod index can't be reached. Reviewed mods show up once it can.", + "indexInvalid": "The mod index that came in failed its signature check and was ignored.", + "indexRollback": "The mod index that came in is older than the one already verified, so it was ignored.", + "sourceError": "{0}: {1}", + "sourceGone": "this repo doesn't exist any more.", + "sourceUnreachable": "couldn't be reached. Showing what was found last time.", + "by": "by {0}", + "dlgInstallTitle": "Install {0} {1}?", + "dlgUpdateTitle": "Update to {0} {1}?", + "dlgFor": "for Weft {0} · {1}", + "dlgVersion": "Version", + "checksLabel": "What the launcher checked", + "ckIndex": "Listed in the Weft mod index", + "ckIndexDetail": "Reviewed by {0} on {1} · index signature verified", + "ckNotIndex": "Not in the Weft mod index", + "ckNotIndexDetail": "Published by {0}. Nobody reviewed it.", + "ckPinned": "Download pinned to SHA-256", + "ckBuilt": "Built by GitHub Actions from public source", + "ckNoBuild": "No build record", + "ckNoBuildDetail": "Nothing shows which source code this file was built from", + "ckFits": "Built for this game version", + "ckFitsDetail": "mod interface", + "ckDeps": "Needs {0}", + "ckDepsDetail": "Installed along with it: {0}", + "ckDepsHave": "Already installed", + "ckReplaces": "Replaces the installed {0}.", + "warnNative": "Mods are programs. {0} can do anything you can do on this computer. These checks show where it came from. They don't show that it's harmless.", + "warnUnreviewed": "Nobody reviewed {0}. It can do anything you can do on this computer. Install it only if you trust {1}.", + "viewSource": "View source", + "cancel": "Cancel", + "close": "Close", + "progressDownloading": "Downloading… {0}%", + "progressVerifying": "Checking the file…", + "progressInstalling": "Installing…", + "fileChecking": "Checking {0}…", + "fileUnverifiedTitle": "{0} is unverified", + "fileKnownTitle": "{0} is {1} {2}", + "fileNotAModTitle": "{0} can't be installed", + "fileFrom": "from your computer · {0}", + "fileSaysItIs": "It calls itself {0}.", + "ckFileNotKnown": "Not in the Weft mod index or any of your sources", + "ckFileKnown": "Same file as {0} {1} in the catalogue", + "ckFileFitUnknown": "Fit unknown", + "ckFileFitUnknownDetail": "Weft checks it when it starts. If it doesn't fit, the game won't start.", + "ckFileFitsNo": "Not built for {0}", + "ckLooksLikeMod": "Looks like a Weft mod", + "ckNothingElse": "nothing else in the zip", + "ckNotForSystem": "No library for this system", + "ckNotForSystemDetail": "It has one for: {0}", + "ckNotAMod": "Not a Weft mod", + "ckRevoked": "Pulled from the index: {0}", + "warnFile": "Only install files from people you trust. A mod can read, change or delete your files and send them anywhere.", + "typeToConfirmBefore": "Type ", + "typeToConfirmAfter": " to install it anyway", + "installAnyway": "Install anyway", + "sourcesTitle": "Mod sources", + "sourcesIntro": "The Weft mod index is always on. You can also add GitHub repos that publish mods; their releases show up as Unreviewed.", + "sourcesIndex": "Weft mod index", + "sourcesIndexFresh": "verified, from {0}", + "sourcesIndexCached": "last verified copy, from {0}", + "sourcesIndexNone": "not reached yet", + "sourcesMods": "mods: {0}", + "sourcesAddLabel": "GitHub repo", + "sourcesAddPlaceholder": "owner/repo or a github.com link", + "sourcesAdd": "Add", + "sourcesRemove": "Remove", + "sourcesWarning": "Anyone can publish a repo. Add only ones you trust.", + "noticeInstalled": "{0} is installed and on.", + "noticeDepsInstalled": "Also installed what it needs: {0}.", + "noticeAlsoEnabled": "Also turned on what it needs: {0}.", + "noticeAlsoDisabled": "Also turned off what needs it: {0}.", + "noticeMissing": "No build of {0} fits this version yet, so what needs it can't load.", + "noticeLeftBehind": "No build for this version yet: {0}.", + "noticeCopied": "Brought {0} mods over from {1}.", + "attentionDot": "Mods need a look", + "homeMods": "Mods: {0}", + "homeModsNone": "No mods", + "homeChange": "Change", + "homeAdd": "Add mods", + "homeBlocked": "Weft {0} won't start with {1} on.", + "homeFixInMods": "Fix in Mods", + "failureWrongVersion": "{0} stopped Weft from starting: it was built for another version.", + "failureMissingDep": "{0} stopped Weft from starting: it needs {1}.", + "failureLoad": "{0} stopped Weft from starting: its library didn't load.", + "failureNotAMod": "{0} stopped Weft from starting: it isn't a Weft mod.", + "failureWrongName": "{0} stopped Weft from starting: its file has the wrong name.", + "failureInit": "A mod crashed while Weft was starting.", + "failureOutside": "It's in your own Weft mods folder, which the launcher doesn't manage.", + "turnOff": "Turn off {0}", + "openModsFolder": "Open mods folder" }, "sizeUnits": { "B": "B", @@ -86,4 +257,4 @@ "MB": "MB", "GB": "GB" } -} \ No newline at end of file +} diff --git a/src/i18n/es.json b/src/i18n/es.json index 45d13b8..ed4af5a 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -2,10 +2,10 @@ "appTitle": "Weft Launcher", "latestBadge": "Última", "updateAvailable": "ACTUALIZACIÓN DISPONIBLE", -"updateDownloading": "ACTUALIZANDO {0}%", -"updateFailed": "ERROR AL ACTUALIZAR · REINTENTAR", -"updateTooltip": "Actualizar a la versión {0} y reiniciar", -"updateCloseGame": "Cierra el juego antes de actualizar", + "updateDownloading": "ACTUALIZANDO {0}%", + "updateFailed": "ERROR AL ACTUALIZAR · REINTENTAR", + "updateTooltip": "Actualizar a la versión {0} y reiniciar", + "updateCloseGame": "Cierra el juego antes de actualizar", "refreshButton": "Actualizar lista", "loading": "Cargando...", "cacheBanner": "Mostrando caché (sin conexión o sin actualizaciones)", @@ -36,6 +36,8 @@ "openLogButton": "Abrir registro", "tabHome": "Inicio", "tabVersions": "Versiones", + "tabMods": "Mods", + "newVersionsDot": "Hay versiones nuevas", "tabNotes": "Notas de versión", "tabsLabel": "Pestañas principales", "selectVersionLabel": "Seleccionar versión", @@ -78,7 +80,181 @@ "logDirCreateFailed": "Error al crear directorio de logs: {0}", "gameFolderNotFound": "Carpeta del juego no encontrada: {0}", "unsupportedPlatform": "Ubicación de la carpeta del juego no confirmada en esta plataforma", - "openerError": "Error al abrir: {0}" + "openerError": "Error al abrir: {0}", + "indexSignatureInvalid": "El índice de mods no pasó la comprobación de firma: {0}", + "indexRollback": "El índice de mods es más antiguo que el ya verificado", + "indexInvalid": "No se puede leer el índice de mods: {0}", + "abiUnavailable": "No se pudo averiguar qué mods sirven para {0}. Revisa tu conexión.", + "modNotInCatalog": "Ese mod ya no está en el catálogo. Actualiza e inténtalo de nuevo.", + "modIncompatible": "{0} no está compilado para esta versión del juego", + "modNotForPlatform": "{0} no tiene biblioteca para este sistema", + "modRevoked": "Retirado del índice: {0}", + "modArchiveInvalid": "No es un mod de Weft: {0}", + "modFileChanged": "El archivo cambió después de comprobarlo. Vuelve a elegirlo.", + "modNotInstalled": "Mod no instalado: {0}", + "modsBlocked": "Weft no arrancará con estos mods activados: {0}. Arréglalos en la pestaña Mods.", + "modSourceInvalid": "No es un repositorio de GitHub: {0}", + "modSourceNotFound": "No existe ese repositorio de GitHub: {0}" }, - "sizeUnits": { "B": "B", "KB": "KB", "MB": "MB", "GB": "GB" } -} \ No newline at end of file + "mods": { + "title": "Mods", + "forVersion": "para", + "versionLabel": "Versión del juego de estos mods", + "fromFile": "Desde archivo", + "fileFilter": "Mod de Weft", + "sources": "Fuentes", + "refresh": "Buscar mods nuevos", + "tabInstalled": "Instalados", + "tabBrowse": "Explorar", + "installedListLabel": "Mods instalados para {0}", + "noVersions": "Primero instala una versión del juego. Cada versión guarda sus propios mods.", + "emptyInstalled": "Todavía no hay mods para {0}.", + "emptyInstalledHint": "Cada versión del juego guarda sus propios mods, porque un mod solo carga en la versión para la que se compiló.", + "browseMods": "Explorar mods", + "copyButton": "Traer mods de {0}", + "runningBanner": "{0} está en ejecución. Ciérralo para cambiar sus mods.", + "blockingBanner": "Weft {0} no arrancará con estos mods activados: {1}.", + "unmanagedBanner": "Tu propia carpeta de mods de Weft contiene {0}. Esos mods cargan en todas las versiones y el launcher no puede comprobarlos.", + "unmanagedShow": "Mostrar", + "abiUnknown": "No se pudo comprobar qué mods sirven para {0}. El juego los comprueba al arrancar.", + "dismiss": "Cerrar", + "metaBuiltFor": "compilado para {0}", + "metaBuiltForThis": "sirve para {0}", + "metaFromSource": "de {0}", + "metaNeeds": "necesita {0}", + "statusOff": "Desactivado. El juego no lo ve.", + "statusWrongVersion": "Compilado para otra versión del juego. Weft no arrancará con él activado.", + "statusWrongVersionTag": "Compilado para {0}. Weft no arrancará con él activado.", + "statusMissingDeps": "Necesita {0}, que no está activado.", + "statusRevoked": "Retirado del índice: {0}. Desactivado.", + "statusUnchecked": "No se puede comprobar antes de que arranque el juego.", + "statusUncheckedFile": "De tu equipo ({0}). Se comprueba al arrancar el juego.", + "on": "Sí", + "off": "No", + "toggleLabel": "{0} activado", + "remove": "Quitar", + "removeConfirm": "Confirmar", + "removeLabel": "Quitar {0}", + "update": "Actualizar a {0}", + "updateTooltip": "Instalar {0} en su lugar", + "updateButton": "Actualizar", + "installFitting": "Obtener {0}", + "badgeReviewed": "Revisado", + "badgeUnreviewed": "Sin revisar", + "badgeUnverified": "Sin verificar", + "badgeReviewedHint": "En el índice firmado de mods de Weft: alguien revisó exactamente este archivo.", + "badgeUnreviewedHint": "De un repositorio de GitHub que añadiste. Lo publica ese repositorio; nadie lo revisó.", + "badgeUnverifiedHint": "Un archivo de tu equipo. No se puede comprobar de dónde viene.", + "badgePrerelease": "Preversión", + "badgeExperimental": "Experimental", + "searchPlaceholder": "Buscar mods", + "filterFits": "Sirve para {0}", + "filterPrereleases": "Preversiones", + "install": "Instalar", + "installed": "Instalado", + "notForVersion": "Sin compilación para {0}", + "onlyPrerelease": "Solo preversiones", + "noSummary": "Sin descripción.", + "browseEmpty": "Ningún mod coincide con la búsqueda.", + "browseEmptyFits": "Ningún mod tiene todavía una compilación para {0}.", + "browseEmptyAll": "No se encontraron mods.", + "showAll": "Mostrar todos los mods", + "indexCached": "Se muestra el último índice de mods que el launcher verificó. No pudo buscar uno más reciente.", + "indexUnavailable": "No se puede acceder al índice de mods de Weft. Los mods revisados aparecerán cuando se pueda.", + "indexInvalid": "El índice de mods recibido no pasó la comprobación de firma y se ignoró.", + "indexRollback": "El índice de mods recibido es más antiguo que el ya verificado, así que se ignoró.", + "sourceError": "{0}: {1}", + "sourceGone": "este repositorio ya no existe.", + "sourceUnreachable": "no se pudo acceder. Se muestra lo encontrado la última vez.", + "by": "de {0}", + "dlgInstallTitle": "¿Instalar {0} {1}?", + "dlgUpdateTitle": "¿Actualizar a {0} {1}?", + "dlgFor": "para Weft {0} · {1}", + "dlgVersion": "Versión", + "checksLabel": "Lo que comprobó el launcher", + "ckIndex": "Está en el índice de mods de Weft", + "ckIndexDetail": "Revisado por {0} el {1} · firma del índice verificada", + "ckNotIndex": "No está en el índice de mods de Weft", + "ckNotIndexDetail": "Publicado por {0}. Nadie lo revisó.", + "ckPinned": "Descarga fijada por SHA-256", + "ckBuilt": "Compilado por GitHub Actions a partir de código público", + "ckNoBuild": "Sin registro de compilación", + "ckNoBuildDetail": "Nada indica a partir de qué código se compiló este archivo", + "ckFits": "Compilado para esta versión del juego", + "ckFitsDetail": "interfaz de mods", + "ckDeps": "Necesita {0}", + "ckDepsDetail": "Se instala junto con él: {0}", + "ckDepsHave": "Ya instalado", + "ckReplaces": "Sustituye a la versión instalada {0}.", + "warnNative": "Los mods son programas. {0} puede hacer todo lo que tú puedes hacer en este equipo. Estas comprobaciones muestran de dónde viene, no que sea inofensivo.", + "warnUnreviewed": "Nadie revisó {0}. Puede hacer todo lo que tú puedes hacer en este equipo. Instálalo solo si confías en {1}.", + "viewSource": "Ver código", + "cancel": "Cancelar", + "close": "Cerrar", + "progressDownloading": "Descargando… {0}%", + "progressVerifying": "Comprobando el archivo…", + "progressInstalling": "Instalando…", + "fileChecking": "Comprobando {0}…", + "fileUnverifiedTitle": "{0} no está verificado", + "fileKnownTitle": "{0} es {1} {2}", + "fileNotAModTitle": "{0} no se puede instalar", + "fileFrom": "de tu equipo · {0}", + "fileSaysItIs": "Dice llamarse {0}.", + "ckFileNotKnown": "No está en el índice de mods de Weft ni en tus fuentes", + "ckFileKnown": "Es el mismo archivo que {0} {1} del catálogo", + "ckFileFitUnknown": "No se sabe si sirve", + "ckFileFitUnknownDetail": "Weft lo comprueba al arrancar. Si no sirve, el juego no arrancará.", + "ckFileFitsNo": "No está compilado para {0}", + "ckLooksLikeMod": "Parece un mod de Weft", + "ckNothingElse": "nada más en el zip", + "ckNotForSystem": "No hay biblioteca para este sistema", + "ckNotForSystemDetail": "Tiene una para: {0}", + "ckNotAMod": "No es un mod de Weft", + "ckRevoked": "Retirado del índice: {0}", + "warnFile": "Instala solo archivos de personas en las que confíes. Un mod puede leer, cambiar o borrar tus archivos y enviarlos a cualquier sitio.", + "typeToConfirmBefore": "Escribe ", + "typeToConfirmAfter": " para instalarlo de todos modos", + "installAnyway": "Instalar de todos modos", + "sourcesTitle": "Fuentes de mods", + "sourcesIntro": "El índice de mods de Weft siempre está activo. También puedes añadir repositorios de GitHub que publiquen mods; sus versiones aparecen como Sin revisar.", + "sourcesIndex": "Índice de mods de Weft", + "sourcesIndexFresh": "verificado, del {0}", + "sourcesIndexCached": "última copia verificada, del {0}", + "sourcesIndexNone": "aún no se ha alcanzado", + "sourcesMods": "mods: {0}", + "sourcesAddLabel": "Repositorio de GitHub", + "sourcesAddPlaceholder": "usuario/repositorio o un enlace de github.com", + "sourcesAdd": "Añadir", + "sourcesRemove": "Quitar", + "sourcesWarning": "Cualquiera puede publicar un repositorio. Añade solo los que sean de confianza.", + "noticeInstalled": "{0} está instalado y activado.", + "noticeDepsInstalled": "También se instaló lo que necesita: {0}.", + "noticeAlsoEnabled": "También se activó lo que necesita: {0}.", + "noticeAlsoDisabled": "También se desactivó lo que lo necesita: {0}.", + "noticeMissing": "Ninguna compilación de {0} sirve todavía para esta versión, así que lo que lo necesita no puede cargar.", + "noticeLeftBehind": "Todavía sin compilación para esta versión: {0}.", + "noticeCopied": "Se trajeron mods de {1}: {0}.", + "attentionDot": "Hay mods que revisar", + "homeMods": "Mods: {0}", + "homeModsNone": "Sin mods", + "homeChange": "Cambiar", + "homeAdd": "Añadir mods", + "homeBlocked": "Weft {0} no arrancará con {1} activado.", + "homeFixInMods": "Arreglar en Mods", + "failureWrongVersion": "{0} impidió que Weft arrancara: se compiló para otra versión.", + "failureMissingDep": "{0} impidió que Weft arrancara: necesita {1}.", + "failureLoad": "{0} impidió que Weft arrancara: su biblioteca no cargó.", + "failureNotAMod": "{0} impidió que Weft arrancara: no es un mod de Weft.", + "failureWrongName": "{0} impidió que Weft arrancara: su archivo tiene un nombre incorrecto.", + "failureInit": "Un mod falló mientras Weft arrancaba.", + "failureOutside": "Está en tu propia carpeta de mods de Weft, que el launcher no gestiona.", + "turnOff": "Desactivar {0}", + "openModsFolder": "Abrir carpeta de mods" + }, + "sizeUnits": { + "B": "B", + "KB": "KB", + "MB": "MB", + "GB": "GB" + } +} diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 7564fe1..79b6870 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -38,6 +38,8 @@ updateCloseGame: string; openLogButton: string; tabHome: string; tabVersions: string; + tabMods: string; + newVersionsDot: string; tabNotes: string; tabsLabel: string; selectVersionLabel: string; @@ -81,7 +83,23 @@ updateCloseGame: string; openerError: string; invalidName: string; installInProgress: string; + indexSignatureInvalid: string; + indexRollback: string; + indexInvalid: string; + abiUnavailable: string; + modNotInCatalog: string; + modIncompatible: string; + modNotForPlatform: string; + modRevoked: string; + modArchiveInvalid: string; + modFileChanged: string; + modNotInstalled: string; + modsBlocked: string; + modSourceInvalid: string; + modSourceNotFound: string; }; + /** Every language has the same keys: `npm run check:i18n`. */ + mods: Record; sizeUnits: { B: string; KB: string; diff --git a/src/i18n/pt-BR.json b/src/i18n/pt-BR.json index ee4e00d..f4b072d 100644 --- a/src/i18n/pt-BR.json +++ b/src/i18n/pt-BR.json @@ -1,9 +1,9 @@ { "updateAvailable": "ATUALIZAÇÃO DISPONÍVEL", -"updateDownloading": "ATUALIZANDO {0}%", -"updateFailed": "FALHA NA ATUALIZAÇÃO · TENTAR DE NOVO", -"updateTooltip": "Atualizar para a versão {0} e reiniciar", -"updateCloseGame": "Feche o jogo antes de atualizar", + "updateDownloading": "ATUALIZANDO {0}%", + "updateFailed": "FALHA NA ATUALIZAÇÃO · TENTAR DE NOVO", + "updateTooltip": "Atualizar para a versão {0} e reiniciar", + "updateCloseGame": "Feche o jogo antes de atualizar", "latestBadge": "Mais recente", "appTitle": "Weft Launcher", "refreshButton": "Atualizar lista", @@ -36,6 +36,8 @@ "openLogButton": "Abrir log", "tabHome": "Início", "tabVersions": "Versões", + "tabMods": "Mods", + "newVersionsDot": "Novas versões disponíveis", "tabNotes": "Notas de versão", "tabsLabel": "Abas principais", "selectVersionLabel": "Selecionar versão", @@ -78,7 +80,176 @@ "logDirCreateFailed": "Falha ao criar diretório de logs: {0}", "gameFolderNotFound": "Pasta do jogo não encontrada: {0}", "unsupportedPlatform": "Localização da pasta do jogo não confirmada nesta plataforma", - "openerError": "Falha ao abrir: {0}" + "openerError": "Falha ao abrir: {0}", + "indexSignatureInvalid": "O índice de mods falhou na verificação de assinatura: {0}", + "indexRollback": "O índice de mods é mais antigo que o já verificado", + "indexInvalid": "Não foi possível ler o índice de mods: {0}", + "abiUnavailable": "Não foi possível descobrir quais mods servem para {0}. Verifique sua conexão.", + "modNotInCatalog": "Esse mod não está mais no catálogo. Atualize e tente de novo.", + "modIncompatible": "{0} não foi compilado para esta versão do jogo", + "modNotForPlatform": "{0} não tem biblioteca para este sistema", + "modRevoked": "Retirado do índice: {0}", + "modArchiveInvalid": "Não é um mod do Weft: {0}", + "modFileChanged": "O arquivo mudou depois da verificação. Escolha-o de novo.", + "modNotInstalled": "Mod não instalado: {0}", + "modsBlocked": "O Weft não vai iniciar com estes mods ligados: {0}. Resolva na aba Mods.", + "modSourceInvalid": "Não é um repositório do GitHub: {0}", + "modSourceNotFound": "Esse repositório do GitHub não existe: {0}" + }, + "mods": { + "title": "Mods", + "forVersion": "para", + "versionLabel": "Versão do jogo destes mods", + "fromFile": "De arquivo", + "fileFilter": "Mod do Weft", + "sources": "Fontes", + "refresh": "Procurar mods novos", + "tabInstalled": "Instalados", + "tabBrowse": "Explorar", + "installedListLabel": "Mods instalados para {0}", + "noVersions": "Instale uma versão do jogo primeiro. Cada versão guarda os próprios mods.", + "emptyInstalled": "Ainda não há mods para {0}.", + "emptyInstalledHint": "Cada versão do jogo guarda os próprios mods, porque um mod só carrega na versão para a qual foi compilado.", + "browseMods": "Explorar mods", + "copyButton": "Trazer mods de {0}", + "runningBanner": "{0} está em execução. Feche-o para mudar os mods.", + "blockingBanner": "O Weft {0} não vai iniciar com estes mods ligados: {1}.", + "unmanagedBanner": "Sua própria pasta de mods do Weft tem {0}. Esses mods carregam em todas as versões, e o launcher não consegue verificá-los.", + "unmanagedShow": "Mostrar", + "abiUnknown": "Não foi possível verificar quais mods servem para {0}. O jogo verifica ao iniciar.", + "dismiss": "Fechar", + "metaBuiltFor": "compilado para {0}", + "metaBuiltForThis": "serve para {0}", + "metaFromSource": "de {0}", + "metaNeeds": "precisa de {0}", + "statusOff": "Desligado. O jogo não o vê.", + "statusWrongVersion": "Compilado para outra versão do jogo. O Weft não vai iniciar com ele ligado.", + "statusWrongVersionTag": "Compilado para {0}. O Weft não vai iniciar com ele ligado.", + "statusMissingDeps": "Precisa de {0}, que não está ligado.", + "statusRevoked": "Retirado do índice: {0}. Desligado.", + "statusUnchecked": "Não dá para verificar antes de o jogo iniciar.", + "statusUncheckedFile": "Do seu computador ({0}). Verificado quando o jogo inicia.", + "on": "Lig.", + "off": "Desl.", + "toggleLabel": "{0} ligado", + "remove": "Remover", + "removeConfirm": "Confirmar", + "removeLabel": "Remover {0}", + "update": "Atualizar para {0}", + "updateTooltip": "Instalar {0} no lugar", + "updateButton": "Atualizar", + "installFitting": "Obter {0}", + "badgeReviewed": "Revisado", + "badgeUnreviewed": "Não revisado", + "badgeUnverified": "Não verificado", + "badgeReviewedHint": "No índice assinado de mods do Weft: alguém revisou exatamente este arquivo.", + "badgeUnreviewedHint": "De um repositório do GitHub que você adicionou. Publicado por esse repositório; ninguém revisou.", + "badgeUnverifiedHint": "Um arquivo do seu computador. Não dá para verificar de onde veio.", + "badgePrerelease": "Pré-lançamento", + "badgeExperimental": "Experimental", + "searchPlaceholder": "Buscar mods", + "filterFits": "Serve para {0}", + "filterPrereleases": "Pré-lançamentos", + "install": "Instalar", + "installed": "Instalado", + "notForVersion": "Sem compilação para {0}", + "onlyPrerelease": "Só pré-lançamentos", + "noSummary": "Sem descrição.", + "browseEmpty": "Nenhum mod corresponde à busca.", + "browseEmptyFits": "Nenhum mod tem compilação para {0} ainda.", + "browseEmptyAll": "Nenhum mod encontrado.", + "showAll": "Mostrar todos os mods", + "indexCached": "Mostrando o último índice de mods que o launcher verificou. Não foi possível procurar um mais novo.", + "indexUnavailable": "Não foi possível acessar o índice de mods do Weft. Os mods revisados aparecem quando for possível.", + "indexInvalid": "O índice de mods recebido falhou na verificação de assinatura e foi ignorado.", + "indexRollback": "O índice de mods recebido é mais antigo que o já verificado, então foi ignorado.", + "sourceError": "{0}: {1}", + "sourceGone": "este repositório não existe mais.", + "sourceUnreachable": "não foi possível acessar. Mostrando o que foi encontrado da última vez.", + "by": "por {0}", + "dlgInstallTitle": "Instalar {0} {1}?", + "dlgUpdateTitle": "Atualizar para {0} {1}?", + "dlgFor": "para o Weft {0} · {1}", + "dlgVersion": "Versão", + "checksLabel": "O que o launcher verificou", + "ckIndex": "Está no índice de mods do Weft", + "ckIndexDetail": "Revisado por {0} em {1} · assinatura do índice verificada", + "ckNotIndex": "Não está no índice de mods do Weft", + "ckNotIndexDetail": "Publicado por {0}. Ninguém revisou.", + "ckPinned": "Download fixado por SHA-256", + "ckBuilt": "Compilado pelo GitHub Actions a partir de código público", + "ckNoBuild": "Sem registro de compilação", + "ckNoBuildDetail": "Nada mostra de qual código este arquivo foi compilado", + "ckFits": "Compilado para esta versão do jogo", + "ckFitsDetail": "interface de mods", + "ckDeps": "Precisa de {0}", + "ckDepsDetail": "Instalado junto: {0}", + "ckDepsHave": "Já instalado", + "ckReplaces": "Substitui a versão instalada {0}.", + "warnNative": "Mods são programas. {0} pode fazer tudo o que você pode fazer neste computador. Estas verificações mostram de onde ele veio, não que seja inofensivo.", + "warnUnreviewed": "Ninguém revisou {0}. Ele pode fazer tudo o que você pode fazer neste computador. Instale só se confiar em {1}.", + "viewSource": "Ver código", + "cancel": "Cancelar", + "close": "Fechar", + "progressDownloading": "Baixando… {0}%", + "progressVerifying": "Verificando o arquivo…", + "progressInstalling": "Instalando…", + "fileChecking": "Verificando {0}…", + "fileUnverifiedTitle": "{0} não é verificado", + "fileKnownTitle": "{0} é {1} {2}", + "fileNotAModTitle": "{0} não pode ser instalado", + "fileFrom": "do seu computador · {0}", + "fileSaysItIs": "Ele se chama de {0}.", + "ckFileNotKnown": "Não está no índice de mods do Weft nem nas suas fontes", + "ckFileKnown": "Mesmo arquivo que {0} {1} do catálogo", + "ckFileFitUnknown": "Não se sabe se serve", + "ckFileFitUnknownDetail": "O Weft verifica ao iniciar. Se não servir, o jogo não vai iniciar.", + "ckFileFitsNo": "Não foi compilado para {0}", + "ckLooksLikeMod": "Parece um mod do Weft", + "ckNothingElse": "nada mais no zip", + "ckNotForSystem": "Sem biblioteca para este sistema", + "ckNotForSystemDetail": "Tem uma para: {0}", + "ckNotAMod": "Não é um mod do Weft", + "ckRevoked": "Retirado do índice: {0}", + "warnFile": "Só instale arquivos de pessoas em quem você confia. Um mod pode ler, mudar ou apagar seus arquivos e enviá-los para qualquer lugar.", + "typeToConfirmBefore": "Digite ", + "typeToConfirmAfter": " para instalar mesmo assim", + "installAnyway": "Instalar mesmo assim", + "sourcesTitle": "Fontes de mods", + "sourcesIntro": "O índice de mods do Weft está sempre ativo. Você também pode adicionar repositórios do GitHub que publicam mods; os lançamentos deles aparecem como Não revisado.", + "sourcesIndex": "Índice de mods do Weft", + "sourcesIndexFresh": "verificado, de {0}", + "sourcesIndexCached": "última cópia verificada, de {0}", + "sourcesIndexNone": "ainda não acessado", + "sourcesMods": "mods: {0}", + "sourcesAddLabel": "Repositório do GitHub", + "sourcesAddPlaceholder": "dono/repositório ou um link do github.com", + "sourcesAdd": "Adicionar", + "sourcesRemove": "Remover", + "sourcesWarning": "Qualquer pessoa pode publicar um repositório. Adicione só os que você confia.", + "noticeInstalled": "{0} está instalado e ligado.", + "noticeDepsInstalled": "Também foi instalado o que ele precisa: {0}.", + "noticeAlsoEnabled": "Também foi ligado o que ele precisa: {0}.", + "noticeAlsoDisabled": "Também foi desligado o que precisa dele: {0}.", + "noticeMissing": "Nenhuma compilação de {0} serve para esta versão ainda, então o que precisa dele não carrega.", + "noticeLeftBehind": "Ainda sem compilação para esta versão: {0}.", + "noticeCopied": "Mods trazidos de {1}: {0}.", + "attentionDot": "Mods precisam de atenção", + "homeMods": "Mods: {0}", + "homeModsNone": "Sem mods", + "homeChange": "Mudar", + "homeAdd": "Adicionar mods", + "homeBlocked": "O Weft {0} não vai iniciar com {1} ligado.", + "homeFixInMods": "Resolver em Mods", + "failureWrongVersion": "{0} impediu o Weft de iniciar: foi compilado para outra versão.", + "failureMissingDep": "{0} impediu o Weft de iniciar: precisa de {1}.", + "failureLoad": "{0} impediu o Weft de iniciar: a biblioteca dele não carregou.", + "failureNotAMod": "{0} impediu o Weft de iniciar: não é um mod do Weft.", + "failureWrongName": "{0} impediu o Weft de iniciar: o arquivo tem o nome errado.", + "failureInit": "Um mod travou enquanto o Weft iniciava.", + "failureOutside": "Ele está na sua própria pasta de mods do Weft, que o launcher não gerencia.", + "turnOff": "Desligar {0}", + "openModsFolder": "Abrir pasta de mods" }, "sizeUnits": { "B": "B", @@ -86,4 +257,4 @@ "MB": "MB", "GB": "GB" } -} \ No newline at end of file +} diff --git a/src/i18n/ru.json b/src/i18n/ru.json index 4260a2f..eccd6d7 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -2,10 +2,10 @@ "appTitle": "Weft Launcher", "latestBadge": "Последняя", "updateAvailable": "ДОСТУПНО ОБНОВЛЕНИЕ", -"updateDownloading": "ОБНОВЛЕНИЕ {0}%", -"updateFailed": "ОШИБКА ОБНОВЛЕНИЯ · ПОВТОРИТЬ", -"updateTooltip": "Обновить до версии {0} и перезапустить", -"updateCloseGame": "Закройте игру перед обновлением", + "updateDownloading": "ОБНОВЛЕНИЕ {0}%", + "updateFailed": "ОШИБКА ОБНОВЛЕНИЯ · ПОВТОРИТЬ", + "updateTooltip": "Обновить до версии {0} и перезапустить", + "updateCloseGame": "Закройте игру перед обновлением", "refreshButton": "Обновить список", "loading": "Загрузка...", "cacheBanner": "Показан кэш (нет сети или нет обновлений)", @@ -36,6 +36,8 @@ "openLogButton": "Открыть лог", "tabHome": "Главная", "tabVersions": "Версии", + "tabMods": "Моды", + "newVersionsDot": "Есть новые версии", "tabNotes": "Список изменений", "tabsLabel": "Основные вкладки", "selectVersionLabel": "Выбор версии", @@ -78,7 +80,181 @@ "logDirCreateFailed": "Не удалось создать директорию для логов: {0}", "gameFolderNotFound": "Папка игры не найдена: {0}", "unsupportedPlatform": "Расположение папки игры не подтверждено на этой платформе", - "openerError": "Не удалось открыть: {0}" + "openerError": "Не удалось открыть: {0}", + "indexSignatureInvalid": "Индекс модов не прошёл проверку подписи: {0}", + "indexRollback": "Индекс модов старее уже проверенного", + "indexInvalid": "Не удалось прочитать индекс модов: {0}", + "abiUnavailable": "Не удалось узнать, какие моды подходят к {0}. Проверьте подключение.", + "modNotInCatalog": "Этого мода больше нет в каталоге. Обновите список и попробуйте снова.", + "modIncompatible": "{0} собран не для этой версии игры", + "modNotForPlatform": "У {0} нет библиотеки для этой системы", + "modRevoked": "Отозван из индекса: {0}", + "modArchiveInvalid": "Это не мод Weft: {0}", + "modFileChanged": "Файл изменился после проверки. Выберите его заново.", + "modNotInstalled": "Мод не установлен: {0}", + "modsBlocked": "Weft не запустится с включёнными модами: {0}. Исправьте это на вкладке «Моды».", + "modSourceInvalid": "Это не репозиторий GitHub: {0}", + "modSourceNotFound": "Такого репозитория GitHub нет: {0}" }, - "sizeUnits": { "B": "Б", "KB": "КБ", "MB": "МБ", "GB": "ГБ" } -} \ No newline at end of file + "mods": { + "title": "Моды", + "forVersion": "для", + "versionLabel": "Версия игры для этих модов", + "fromFile": "Из файла", + "fileFilter": "Мод Weft", + "sources": "Источники", + "refresh": "Проверить новые моды", + "tabInstalled": "Установленные", + "tabBrowse": "Каталог", + "installedListLabel": "Моды, установленные для {0}", + "noVersions": "Сначала установите версию игры. У каждой версии свой набор модов.", + "emptyInstalled": "Для {0} модов пока нет.", + "emptyInstalledHint": "У каждой версии игры свой набор модов: мод загружается только в ту версию, для которой собран.", + "browseMods": "Открыть каталог", + "copyButton": "Перенести моды из {0}", + "runningBanner": "{0} запущена. Закройте игру, чтобы менять её моды.", + "blockingBanner": "Weft {0} не запустится с включёнными модами: {1}.", + "unmanagedBanner": "В вашей папке модов Weft лежит: {0}. Эти моды загружаются во всех версиях, и лаунчер не может их проверить.", + "unmanagedShow": "Показать", + "abiUnknown": "Не удалось проверить, какие моды подходят к {0}. Игра проверит их при запуске.", + "dismiss": "Скрыть", + "metaBuiltFor": "собран для {0}", + "metaBuiltForThis": "подходит к {0}", + "metaFromSource": "из {0}", + "metaNeeds": "нужен {0}", + "statusOff": "Выключен. Игра его не видит.", + "statusWrongVersion": "Собран для другой версии игры. С ним Weft не запустится.", + "statusWrongVersionTag": "Собран для {0}. С ним Weft не запустится.", + "statusMissingDeps": "Нужен {0}, а он выключен.", + "statusRevoked": "Отозван из индекса: {0}. Выключен.", + "statusUnchecked": "Проверить можно только при запуске игры.", + "statusUncheckedFile": "С вашего компьютера ({0}). Проверяется при запуске игры.", + "on": "Вкл", + "off": "Выкл", + "toggleLabel": "{0} включён", + "remove": "Удалить", + "removeConfirm": "Точно?", + "removeLabel": "Удалить {0}", + "update": "Обновить до {0}", + "updateTooltip": "Установить {0} вместо этой", + "updateButton": "Обновить", + "installFitting": "Взять {0}", + "badgeReviewed": "Проверен", + "badgeUnreviewed": "Не проверен", + "badgeUnverified": "Неизвестен", + "badgeReviewedHint": "В подписанном индексе модов Weft: кто-то проверил именно этот файл.", + "badgeUnreviewedHint": "Из добавленного вами репозитория GitHub. Опубликован этим репозиторием, никем не проверен.", + "badgeUnverifiedHint": "Файл с вашего компьютера. Откуда он, проверить нельзя.", + "badgePrerelease": "Предрелиз", + "badgeExperimental": "Экспериментальный", + "searchPlaceholder": "Поиск модов", + "filterFits": "Подходит к {0}", + "filterPrereleases": "Предрелизы", + "install": "Установить", + "installed": "Установлен", + "notForVersion": "Нет сборки для {0}", + "onlyPrerelease": "Только предрелизы", + "noSummary": "Нет описания.", + "browseEmpty": "По запросу ничего не нашлось.", + "browseEmptyFits": "Пока ни у одного мода нет сборки для {0}.", + "browseEmptyAll": "Модов не найдено.", + "showAll": "Показать все моды", + "indexCached": "Показан последний индекс модов, проверенный лаунчером. Проверить более новый не удалось.", + "indexUnavailable": "Индекс модов Weft недоступен. Проверенные моды появятся, когда он станет доступен.", + "indexInvalid": "Полученный индекс модов не прошёл проверку подписи и был проигнорирован.", + "indexRollback": "Полученный индекс модов старее уже проверенного, поэтому он проигнорирован.", + "sourceError": "{0}: {1}", + "sourceGone": "этого репозитория больше нет.", + "sourceUnreachable": "недоступен. Показано то, что было найдено в прошлый раз.", + "by": "автор: {0}", + "dlgInstallTitle": "Установить {0} {1}?", + "dlgUpdateTitle": "Обновить до {0} {1}?", + "dlgFor": "для Weft {0} · {1}", + "dlgVersion": "Версия", + "checksLabel": "Что проверил лаунчер", + "ckIndex": "Есть в индексе модов Weft", + "ckIndexDetail": "Проверил {0}, {1} · подпись индекса верна", + "ckNotIndex": "Нет в индексе модов Weft", + "ckNotIndexDetail": "Опубликован в {0}. Никто его не проверял.", + "ckPinned": "Загрузка закреплена по SHA-256", + "ckBuilt": "Собран GitHub Actions из открытого кода", + "ckNoBuild": "Нет записи о сборке", + "ckNoBuildDetail": "Нельзя узнать, из какого кода собран этот файл", + "ckFits": "Собран для этой версии игры", + "ckFitsDetail": "интерфейс модов", + "ckDeps": "Нужен {0}", + "ckDepsDetail": "Будет установлен вместе с ним: {0}", + "ckDepsHave": "Уже установлен", + "ckReplaces": "Заменит установленную версию {0}.", + "warnNative": "Моды — это программы. {0} может делать на этом компьютере всё то же, что и вы. Эти проверки показывают, откуда он, но не то, что он безвреден.", + "warnUnreviewed": "{0} никто не проверял. Он может делать на этом компьютере всё то же, что и вы. Устанавливайте, только если доверяете {1}.", + "viewSource": "Исходный код", + "cancel": "Отмена", + "close": "Закрыть", + "progressDownloading": "Загрузка… {0}%", + "progressVerifying": "Проверка файла…", + "progressInstalling": "Установка…", + "fileChecking": "Проверка {0}…", + "fileUnverifiedTitle": "{0} не проверен", + "fileKnownTitle": "{0} — это {1} {2}", + "fileNotAModTitle": "{0} нельзя установить", + "fileFrom": "с вашего компьютера · {0}", + "fileSaysItIs": "Он называет себя {0}.", + "ckFileNotKnown": "Нет ни в индексе модов Weft, ни в ваших источниках", + "ckFileKnown": "Тот же файл, что {0} {1} из каталога", + "ckFileFitUnknown": "Неизвестно, подходит ли", + "ckFileFitUnknownDetail": "Weft проверит при запуске. Если не подходит, игра не запустится.", + "ckFileFitsNo": "Собран не для {0}", + "ckLooksLikeMod": "Похоже на мод Weft", + "ckNothingElse": "больше ничего в архиве", + "ckNotForSystem": "Нет библиотеки для этой системы", + "ckNotForSystemDetail": "Есть только для: {0}", + "ckNotAMod": "Это не мод Weft", + "ckRevoked": "Отозван из индекса: {0}", + "warnFile": "Устанавливайте файлы только от тех, кому доверяете. Мод может читать, менять и удалять ваши файлы и отправлять их куда угодно.", + "typeToConfirmBefore": "Введите ", + "typeToConfirmAfter": ", чтобы всё равно установить", + "installAnyway": "Всё равно установить", + "sourcesTitle": "Источники модов", + "sourcesIntro": "Индекс модов Weft включён всегда. Можно добавить и репозитории GitHub, которые публикуют моды: их релизы будут помечены как «Не проверен».", + "sourcesIndex": "Индекс модов Weft", + "sourcesIndexFresh": "проверен, от {0}", + "sourcesIndexCached": "последняя проверенная копия, от {0}", + "sourcesIndexNone": "пока недоступен", + "sourcesMods": "модов: {0}", + "sourcesAddLabel": "Репозиторий GitHub", + "sourcesAddPlaceholder": "владелец/репозиторий или ссылка github.com", + "sourcesAdd": "Добавить", + "sourcesRemove": "Убрать", + "sourcesWarning": "Репозиторий может опубликовать кто угодно. Добавляйте только те, которым доверяете.", + "noticeInstalled": "{0} установлен и включён.", + "noticeDepsInstalled": "Заодно установлено то, что ему нужно: {0}.", + "noticeAlsoEnabled": "Заодно включено то, что ему нужно: {0}.", + "noticeAlsoDisabled": "Заодно выключено то, чему он нужен: {0}.", + "noticeMissing": "Ни одна сборка {0} пока не подходит к этой версии, поэтому зависящие от него моды не загрузятся.", + "noticeLeftBehind": "Пока нет сборки для этой версии: {0}.", + "noticeCopied": "Перенесено модов из {1}: {0}.", + "attentionDot": "Модам нужно внимание", + "homeMods": "Моды: {0}", + "homeModsNone": "Без модов", + "homeChange": "Изменить", + "homeAdd": "Добавить моды", + "homeBlocked": "Weft {0} не запустится с включённым {1}.", + "homeFixInMods": "Исправить в «Модах»", + "failureWrongVersion": "{0} не дал Weft запуститься: он собран для другой версии.", + "failureMissingDep": "{0} не дал Weft запуститься: ему нужен {1}.", + "failureLoad": "{0} не дал Weft запуститься: его библиотека не загрузилась.", + "failureNotAMod": "{0} не дал Weft запуститься: это не мод Weft.", + "failureWrongName": "{0} не дал Weft запуститься: у файла неверное имя.", + "failureInit": "Мод упал при запуске Weft.", + "failureOutside": "Он лежит в вашей собственной папке модов Weft, которой лаунчер не управляет.", + "turnOff": "Выключить {0}", + "openModsFolder": "Открыть папку модов" + }, + "sizeUnits": { + "B": "Б", + "KB": "КБ", + "MB": "МБ", + "GB": "ГБ" + } +} diff --git a/src/main.tsx b/src/main.tsx index 8ecb915..b1bc200 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,6 +3,7 @@ import ReactDOM from "react-dom/client"; import App from "./App"; import { GameStateProvider } from "./hooks/useGameState"; import { InstallProvider } from "./hooks/useInstall"; +import { ModsProvider } from "./hooks/useMods"; import { initI18n } from "./i18n"; import "./style.css"; @@ -24,14 +25,28 @@ document.addEventListener("keydown", (e) => { import { I18nProvider } from "./i18n/I18nProvider"; -ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( +// `npm run dev:mock` runs the UI in a browser against src/dev/mockBackend.ts. The check is on a +// build-time constant, so a real build leaves the mock out entirely. +async function start() { + if (import.meta.env.MODE === "mock") { + const { installMockBackend } = await import("./dev/mockBackend"); + installMockBackend(); + } + render(); +} + +const render = () => ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - + + + , -); \ No newline at end of file +); + +start(); diff --git a/src/mods/api.ts b/src/mods/api.ts new file mode 100644 index 0000000..a139358 --- /dev/null +++ b/src/mods/api.ts @@ -0,0 +1,29 @@ +// One typed function per mod command, so a renamed command or argument fails in one place. + +import { invoke } from "@tauri-apps/api/core"; +import type { + Catalog, + ChangeResult, + CopyResult, + FileInspection, + ProfileReport, +} from "./types"; + +export const modsApi = { + catalog: (forceRefresh: boolean) => invoke("list_mod_catalog", { forceRefresh }), + profile: (tag: string) => invoke("get_mod_profile", { tag }), + install: (tag: string, sha256: string) => invoke("install_mod", { tag, sha256 }), + inspectFile: (tag: string, path: string) => + invoke("inspect_mod_file", { tag, path }), + installFile: (tag: string, path: string, sha256: string) => + invoke("install_mod_file", { tag, path, sha256 }), + setEnabled: (tag: string, id: string, enabled: boolean) => + invoke("set_mod_enabled", { tag, id, enabled }), + remove: (tag: string, id: string) => invoke("remove_mod", { tag, id }), + copyProfile: (from: string, to: string) => invoke("copy_mod_profile", { from, to }), + sources: () => invoke("get_mod_sources"), + addSource: (repo: string) => invoke("add_mod_source", { repo }), + removeSource: (repo: string) => invoke("remove_mod_source", { repo }), + icon: (url: string) => invoke("get_mod_icon", { url }), + openPlayerModsFolder: () => invoke("open_player_mods_folder"), +}; diff --git a/src/mods/select.ts b/src/mods/select.ts new file mode 100644 index 0000000..88f2b04 --- /dev/null +++ b/src/mods/select.ts @@ -0,0 +1,87 @@ +// Pure choices over catalogue data, shared by the pages and kept out of components. + +import type { Catalog, CatalogMod, CatalogVersion, Channel, InstalledMod } from "./types"; + +const RANK: Record = { release: 0, prerelease: 1, experimental: 2 }; + +function parts(v: string): [number[], string | null] { + const clean = v.replace(/^v/, "").split("+")[0]; + const dash = clean.indexOf("-"); + const core = dash < 0 ? clean : clean.slice(0, dash); + const pre = dash < 0 ? null : clean.slice(dash + 1); + return [core.split(".").map((n) => Number.parseInt(n, 10) || 0), pre]; +} + +/** Semver precedence, like the backend's `mods::version::compare`. */ +export function compareVersions(a: string, b: string): number { + const [an, ap] = parts(a); + const [bn, bp] = parts(b); + for (let i = 0; i < Math.max(an.length, bn.length); i++) { + const d = (an[i] ?? 0) - (bn[i] ?? 0); + if (d !== 0) return Math.sign(d); + } + if (ap === bp) return 0; + if (ap === null) return 1; + if (bp === null) return -1; + const as = ap.split("."); + const bs = bp.split("."); + for (let i = 0; i < Math.max(as.length, bs.length); i++) { + if (as[i] === undefined) return -1; + if (bs[i] === undefined) return 1; + const x = Number(as[i]); + const y = Number(bs[i]); + const d = !Number.isNaN(x) && !Number.isNaN(y) ? x - y : as[i].localeCompare(bs[i]); + if (d !== 0) return Math.sign(d); + } + return 0; +} + +export function isRevoked(catalog: Catalog | null, sha256: string): boolean { + return !!catalog?.revoked.some((r) => r.sha256 === sha256); +} + +/** Versions that would load into a game with `abi` on this system, best first. */ +export function fittingVersions( + catalog: Catalog | null, + mod: CatalogMod, + abi: string | null, + allowPrerelease: boolean +): CatalogVersion[] { + return mod.versions + .filter((v) => abi !== null && v.abi === abi) + .filter((v) => catalog === null || v.platforms.includes(catalog.platform)) + .filter((v) => allowPrerelease || v.channel === "release") + .filter((v) => !isRevoked(catalog, v.sha256)) + .sort((a, b) => compareVersions(b.version, a.version) || RANK[a.channel] - RANK[b.channel]); +} + +export function installedFor(mod: CatalogMod, installed: InstalledMod[]): InstalledMod | undefined { + return installed.find((m) => { + if (m.id !== mod.id) return false; + if (mod.origin.kind === "index") return m.origin.kind === "index"; + if (mod.origin.kind === "source") return m.origin.kind === "source" && m.origin.repo === mod.origin.repo; + return false; + }); +} + +export function matchesSearch(mod: CatalogMod, query: string): boolean { + const q = query.trim().toLowerCase(); + if (!q) return true; + return [mod.name, mod.id, mod.summary, ...mod.authors].some((s) => s.toLowerCase().includes(q)); +} + +export function shortSha(sha: string): string { + return `${sha.slice(0, 8)}…${sha.slice(-4)}`; +} + +export function shortCommit(commit: string | null | undefined): string { + return (commit ?? "").slice(0, 7); +} + +/** A link to the exact source a version was built from. */ +export function sourceUrl(v: { sourceRepo: string | null; sourceCommit: string | null }): string | null { + if (!v.sourceRepo) return null; + return v.sourceCommit + ? `https://github.com/${v.sourceRepo}/tree/${v.sourceCommit}` + : `https://github.com/${v.sourceRepo}`; +} diff --git a/src/mods/types.ts b/src/mods/types.ts new file mode 100644 index 0000000..818c37f --- /dev/null +++ b/src/mods/types.ts @@ -0,0 +1,145 @@ +// Mirrors the Rust types in src-tauri/src/mods. Field names are the camelCase serde output. + +export type Trust = "reviewed" | "unreviewed" | "unverified"; +export type Channel = "release" | "prerelease" | "experimental"; +export type Freshness = "fresh" | "cached" | "unavailable"; + +export type Origin = + | { kind: "index" } + | { kind: "source"; repo: string } + | { kind: "file"; name: string }; + +export interface AppErrorPayload { + code: string; + detail: string | null; +} + +export interface CatalogVersion { + version: string; + channel: Channel; + tag: string; + published: string; + sourceRepo: string; + sourceCommit: string; + weftTag: string | null; + abi: string; + deps: string[]; + platforms: string[]; + sha256: string; + size: number; + url: string; + provenance: { + repo: string | null; + workflow: string | null; + gitRef: string | null; + verifiedAt: string | null; + } | null; + reviewed: { by: string; at: string } | null; + trust: Trust; +} + +export interface CatalogMod { + key: string; + id: string; + name: string; + summary: string; + authors: string[]; + license: string | null; + homepage: string | null; + icon: string | null; + origin: Origin; + versions: CatalogVersion[]; +} + +export interface Catalog { + mods: CatalogMod[]; + revoked: { sha256: string; reason: string }[]; + index: { freshness: Freshness; generated: string | null; error: AppErrorPayload | null }; + sources: { repo: string; mods: number; error: AppErrorPayload | null }[]; + weft: { tag: string; commit: string; abi: string }[]; + platform: string; +} + +export type ModStatus = "ready" | "off" | "wrongVersion" | "missingDeps" | "revoked" | "unchecked"; + +export interface InstalledMod { + id: string; + name: string; + summary: string; + version: string | null; + channel: Channel | null; + sha256: string; + size: number; + enabled: boolean; + trust: Trust; + origin: Origin; + abi: string | null; + weftTag: string | null; + deps: string[]; + tag: string | null; + sourceRepo: string | null; + sourceCommit: string | null; + icon: string | null; + installedAt: string; + status: ModStatus; + missingDeps: string[]; + revokedReason: string | null; + update: { version: string; sha256: string; channel: Channel; trust: Trust } | null; +} + +export interface ProfileReport { + tag: string; + versionAbi: string | null; + abiError: AppErrorPayload | null; + mods: InstalledMod[]; + blocking: string[]; + unmanaged: string[]; +} + +export interface ChangeResult { + report: ProfileReport; + installed: string[]; + alsoEnabled: string[]; + alsoDisabled: string[]; + missing: string[]; +} + +export interface CopyResult extends ChangeResult { + leftBehind: string[]; +} + +export interface ArchiveInfo { + id: string; + libraries: string[]; + platforms: string[]; + hasAssets: boolean; + entries: number; +} + +export interface FileInspection { + fileName: string; + size: number; + sha256: string; + archive: ArchiveInfo | null; + problem: AppErrorPayload | null; + name: string | null; + summary: string | null; + fitsSystem: boolean; + catalogMatch: { key: string; name: string; version: string; trust: Trust; fitsVersion: boolean } | null; + revokedReason: string | null; + replaces: string | null; +} + +export interface ModProgress { + sha256: string; + modId: string; + stage: "downloading" | "verifying" | "installing"; + percent: number; +} + +export interface ModFailure { + kind: "wrongVersion" | "loadFailed" | "notAMod" | "wrongName" | "missingDep" | "initPanicked"; + modId: string | null; + other: string | null; + line: string; +} diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 4abacf0..ff0f2a8 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -1,24 +1,29 @@ -import { useState, useMemo } from "react"; +import { useEffect, useState, useMemo } from "react"; import { invoke } from "@tauri-apps/api/core"; import { useI18n } from "../i18n/useI18n"; import { translateError } from "../i18n/errors"; import { useInstall } from "../hooks/useInstall"; import { useGameState } from "../hooks/useGameState"; +import { useMods } from "../hooks/useMods"; +import { modsApi } from "../mods/api"; +import type { ModFailure } from "../mods/types"; import { StableLabel } from "../components/StableLabel"; import { VersionSelect } from "../components/VersionSelect"; import { compareTags } from "../utils/version"; interface HomeProps { onGoToVersions: () => void; + onGoToMods: () => void; latestTag: string | null; selectedTag: string | null; onSelectTag: (tag: string | null) => void; } -export function Home({ onGoToVersions, latestTag, selectedTag, onSelectTag }: HomeProps) { +export function Home({ onGoToVersions, onGoToMods, latestTag, selectedTag, onSelectTag }: HomeProps) { const { t, locale } = useI18n(); const { installedVersions, loaded } = useInstall(); const { anyRunning, isRunning, launch, lastCrash, clearCrash } = useGameState(); + const { profiles, loadProfile, setEnabled } = useMods(); const [launchError, setLaunchError] = useState(null); @@ -34,6 +39,11 @@ export function Home({ onGoToVersions, latestTag, selectedTag, onSelectTag }: Ho ); const effectiveTag = selectedVersion?.tag ?? null; + const report = effectiveTag ? profiles[effectiveTag] : undefined; + + useEffect(() => { + if (effectiveTag) loadProfile(effectiveTag); + }, [effectiveTag, loadProfile]); const handleLaunch = async (tag: string) => { setLaunchError(null); @@ -117,14 +127,51 @@ export function Home({ onGoToVersions, latestTag, selectedTag, onSelectTag }: Ho {launchError}
    )} - {lastCrash && ( + {report && ( + m.enabled).map((m) => m.name)} + onChange={onGoToMods} + /> + )} + {report && report.blocking.length > 0 && !lastCrash && (
    - {t("gameClosedError", String(lastCrash.exitCode ?? "?"))} -
    )} + {lastCrash && lastCrash.modFailure ? ( + m.id === lastCrash.modFailure?.modId)} + onTurnOff={async (id) => { + clearCrash(); + const err = await setEnabled(lastCrash.tag, id, false); + if (err) setLaunchError(err); + }} + onGoToMods={() => { + clearCrash(); + onGoToMods(); + }} + onOpenLog={() => handleOpenLog(lastCrash.tag)} + /> + ) : ( + lastCrash && ( +
    + {t("gameClosedError", String(lastCrash.exitCode ?? "?"))} + +
    + ) + )}
    @@ -159,3 +206,65 @@ export function Home({ onGoToVersions, latestTag, selectedTag, onSelectTag }: Ho ); } + +function ModsLine({ names, onChange }: { names: string[]; onChange: () => void }) { + const { t } = useI18n(); + return ( +

    + {names.length ? t("mods.homeMods", names.join(", ")) : t("mods.homeModsNone")} + +

    + ); +} + +const FAILURE_KEY: Record = { + wrongVersion: "mods.failureWrongVersion", + missingDep: "mods.failureMissingDep", + loadFailed: "mods.failureLoad", + notAMod: "mods.failureNotAMod", + wrongName: "mods.failureWrongName", + initPanicked: "mods.failureInit", +}; + +interface ModFailureNoticeProps { + failure: ModFailure; + /** The installed mod the log names, when the launcher manages it. */ + managed: { id: string; name: string; enabled: boolean } | undefined; + onTurnOff: (id: string) => void; + onGoToMods: () => void; + onOpenLog: () => void; +} + +/** Says which mod stopped the game, and offers the fix right there. */ +function ModFailureNotice({ failure, managed, onTurnOff, onGoToMods, onOpenLog }: ModFailureNoticeProps) { + const { t } = useI18n(); + const name = managed?.name ?? failure.modId ?? "?"; + return ( +
    + {t(FAILURE_KEY[failure.kind], name, failure.other ?? "")} + {failure.line} + {!managed && failure.modId && {t("mods.failureOutside")}} + + {managed?.enabled && ( + + )} + {managed ? ( + + ) : ( + + )} + + +
    + ); +} diff --git a/src/pages/Mods.tsx b/src/pages/Mods.tsx new file mode 100644 index 0000000..87b43ba --- /dev/null +++ b/src/pages/Mods.tsx @@ -0,0 +1,217 @@ +import { useEffect, useMemo, useState } from "react"; +import { open as openFileDialog } from "@tauri-apps/plugin-dialog"; +import { useI18n } from "../i18n/useI18n"; +import { translateError } from "../i18n/errors"; +import { useInstall } from "../hooks/useInstall"; +import { useGameState } from "../hooks/useGameState"; +import { useMods } from "../hooks/useMods"; +import { modsApi } from "../mods/api"; +import { compareTags } from "../utils/version"; +import { PixelIcon } from "../components/PixelIcon"; +import { VersionSelect } from "../components/VersionSelect"; +import { InstalledList } from "../components/mods/InstalledList"; +import { BrowseList } from "../components/mods/BrowseList"; +import { FileInstallDialog } from "../components/mods/FileInstallDialog"; +import { SourcesDialog } from "../components/mods/SourcesDialog"; +import { REFRESH_ICON } from "../components/icons"; + +type View = "installed" | "browse"; + +interface ModsPageProps { + selectedTag: string | null; + onSelectTag: (tag: string) => void; + onGoToVersions: () => void; +} + +export function ModsPage({ selectedTag, onSelectTag, onGoToVersions }: ModsPageProps) { + const { t } = useI18n(); + const { installedVersions, loaded } = useInstall(); + const { isRunning } = useGameState(); + const mods = useMods(); + const { profiles, profileErrors, loadProfile, notice, clearNotice, catalogLoading, refreshCatalog } = mods; + const [view, setView] = useState("installed"); + const [filePath, setFilePath] = useState(null); + const [showSources, setShowSources] = useState(false); + const [rowError, setRowError] = useState(null); + + const versions = useMemo( + () => [...installedVersions].sort((a, b) => compareTags(b.tag, a.tag)), + [installedVersions] + ); + const tag = versions.find((v) => v.tag === selectedTag)?.tag ?? versions[0]?.tag ?? null; + const report = tag ? profiles[tag] : undefined; + const running = tag ? isRunning(tag) : false; + + // Every version's set is read, so another version's mods can be offered here. + useEffect(() => { + versions.forEach((v) => loadProfile(v.tag)); + }, [versions, loadProfile]); + + useEffect(() => setRowError(null), [tag, view]); + + if (!loaded) return
    ; + + if (!tag) { + return ( +
    +
    +

    {t("mods.title")}

    +
    +
    +

    {t("mods.noVersions")}

    + +
    +
    + ); + } + + const pickFile = async () => { + try { + const path = await openFileDialog({ + multiple: false, + directory: false, + filters: [{ name: t("mods.fileFilter"), extensions: ["zip"] }], + }); + if (typeof path === "string") setFilePath(path); + } catch (err) { + setRowError(translateError(err)); + } + }; + + const nameOf = (id: string) => report?.mods.find((m) => m.id === id)?.name ?? id; + const donor = versions + .filter((v) => v.tag !== tag) + .map((v) => profiles[v.tag]) + .find((p) => p && p.mods.some((m) => m.enabled)); + const empty = report && report.mods.length === 0; + + return ( +
    +
    +

    {t("mods.title")}

    +
    + {t("mods.forVersion")} + ({ value: v.tag, label: v.tag }))} + onChange={onSelectTag} + disabled={false} + label={t("mods.versionLabel")} + compact + /> + + + +
    +
    + +
    + {(["installed", "browse"] as const).map((v) => ( + + ))} +
    + +
    + {running && ( +
    + {t("mods.runningBanner", tag)} +
    + )} + {notice && notice.tag === tag && ( +
    + + {notice.lines.map((l, i) => ( + + {l} + + ))} + + +
    + )} + {report && report.blocking.length > 0 && ( +
    + {t("mods.blockingBanner", tag, report.blocking.map(nameOf).join(", "))} +
    + )} + {report && report.unmanaged.length > 0 && ( +
    + {t("mods.unmanagedBanner", report.unmanaged.join(", "))} + +
    + )} + {report?.abiError && report.mods.length > 0 && ( +
    + {t("mods.abiUnknown", tag)} +
    + )} + {(rowError || (tag && profileErrors[tag])) && ( +
    + {rowError ?? profileErrors[tag]} +
    + )} +
    + + {!report ? ( +
    {t("loading")}
    + ) : view === "installed" ? ( + empty ? ( +
    +

    {t("mods.emptyInstalled", tag)}

    +

    {t("mods.emptyInstalledHint")}

    +
    + + {donor && ( + + )} +
    +
    + ) : ( + + ) + ) : ( + + )} + + {filePath && setFilePath(null)} />} + {showSources && setShowSources(false)} />} +
    + ); +} diff --git a/src/pages/Versions.tsx b/src/pages/Versions.tsx index be6fbec..9b45ed1 100644 --- a/src/pages/Versions.tsx +++ b/src/pages/Versions.tsx @@ -3,15 +3,7 @@ import { useInstall } from "../hooks/useInstall"; import { PixelIcon } from "../components/PixelIcon"; import { VersionActions } from "../components/VersionActions"; import { getLocale } from "../i18n"; - -const REFRESH_ICON = [ - ".###.#", - "#...##", - "#..###", - "#.....", - "#...#.", - ".###..", -]; +import { REFRESH_ICON } from "../components/icons"; interface Release { tag: string; diff --git a/src/style.css b/src/style.css index 2874bf0..3e58e87 100644 --- a/src/style.css +++ b/src/style.css @@ -18,6 +18,9 @@ --r1: #7c2c22; --r2: #c2503c; --r3: #ff9a80; + --v1: #2e6b3e; + --v2: #4fa25e; + --v3: #9be3a4; --px: 4px; --sidebar: 192px; color-scheme: dark; @@ -109,7 +112,7 @@ button:disabled { cursor: not-allowed; } /* Blocks seen from the front: lit top edge, side face below, pressing pushes them in. The side belongs to the button, so it can be clicked too, and pressing moves only what's drawn: the box stays put, so nothing around it shifts and a press on the top edge doesn't slip off it. */ -:is(.btn-play, .install-btn, .play-btn, .btn-primary, .btn-refresh, .btn-secondary, .delete-btn, .version-select-btn) { +:is(.btn-play, .install-btn, .play-btn, .btn-primary, .btn-refresh, .btn-secondary, .delete-btn, .version-select-btn, .btn-danger, .toggle) { --top: 8px; --side: 16px; --face: var(--g2); @@ -125,20 +128,20 @@ button:disabled { cursor: not-allowed; } linear-gradient(var(--lit) 0 var(--px), var(--face) 0) 0 0 / 100% calc(100% - 12px) no-repeat, linear-gradient(var(--edge) 0 8px, var(--ink) 0) 0 100% / 100% 12px no-repeat; } -:is(.btn-refresh, .btn-secondary, .delete-btn, .version-select-btn):hover:not(:disabled) { --face: var(--g3); --lit: var(--g4); } +:is(.btn-refresh, .btn-secondary, .delete-btn, .version-select-btn, .toggle):hover:not(:disabled) { --face: var(--g3); --lit: var(--g4); } :is(.btn-play, .install-btn, .play-btn, .btn-primary) { --face: var(--a2); --lit: var(--a3); --edge: var(--a1); color: var(--ink); } :is(.btn-play, .install-btn, .play-btn, .btn-primary):hover:not(:disabled) { --face: var(--a3); --lit: #ffe0a0; } -.delete-btn.confirming, .delete-btn.confirming:hover:not(:disabled) { --face: var(--r2); --lit: var(--r3); --edge: var(--r1); color: var(--ink); } +:is(.btn-danger, .delete-btn.confirming), :is(.btn-danger, .delete-btn.confirming):hover:not(:disabled) { --face: var(--r2); --lit: var(--r3); --edge: var(--r1); color: var(--ink); } -:is(.btn-play, .install-btn, .play-btn, .btn-primary, .btn-refresh, .btn-secondary, .delete-btn, .version-select-btn):is(:active:not(:disabled), :disabled) { +:is(.btn-play, .install-btn, .play-btn, .btn-primary, .btn-refresh, .btn-secondary, .delete-btn, .version-select-btn, .btn-danger, .toggle):is(:active:not(:disabled), :disabled) { padding: calc(var(--top) + 8px) var(--side) calc(var(--top) + 4px); background: linear-gradient(var(--lit) 0 var(--px), var(--face) 0) 0 8px / 100% calc(100% - 12px) no-repeat, linear-gradient(var(--ink), var(--ink)) 0 100% / 100% 4px no-repeat; } -:is(.btn-play, .install-btn, .play-btn, .btn-primary, .btn-refresh, .btn-secondary, .delete-btn, .version-select-btn):disabled { +:is(.btn-play, .install-btn, .play-btn, .btn-primary, .btn-refresh, .btn-secondary, .delete-btn, .version-select-btn, .btn-danger, .toggle):disabled { --face: var(--g1); --lit: var(--g1); color: var(--g4); } @@ -192,7 +195,7 @@ button:disabled { cursor: not-allowed; } .cache-banner { line-height: 24px; color: var(--a3); text-shadow: 2px 2px 0 var(--ink); } .empty-state { color: var(--g5); text-shadow: 2px 2px 0 var(--ink); } -.error-banner, .launch-error, .install-error { +.error-banner, .launch-error, .install-error, .inline-error { padding: 8px 16px; background: var(--ink); color: var(--r3); box-shadow: inset var(--px) 0 0 var(--r2); @@ -201,7 +204,7 @@ button:disabled { cursor: not-allowed; } } /* The banner's corner, a three pixel step, on anything framed. */ -.card, .version-entry, .note-entry { +.card, .version-entry, .note-entry, .framed { background: var(--g1); clip-path: polygon( calc(var(--px) * 2) 0, calc(100% - var(--px) * 2) 0, calc(100% - var(--px) * 2) var(--px), calc(100% - var(--px)) var(--px), @@ -269,3 +272,133 @@ button:disabled { cursor: not-allowed; } .note-gap { height: 12px; } .note-item { position: relative; padding-left: 24px; margin-top: 4px; } .note-item::before { content: ""; position: absolute; left: 0; top: 8px; width: 8px; height: 8px; background: var(--a2); box-shadow: 0 2px 0 var(--a1); } + +/* Mods */ +.btn-small { --top: 2px; --side: 12px; } +.toggle.on { --face: var(--a2); --lit: var(--a3); --edge: var(--a1); color: var(--ink); } +.toggle.on:hover:not(:disabled) { --face: var(--a3); --lit: #ffe0a0; } +.toggle { --top: 2px; --side: 12px; display: inline-flex; align-items: center; gap: 8px; } +.toggle-knob { width: 12px; height: 12px; box-shadow: inset 0 0 0 var(--px) currentColor; } +.toggle.on .toggle-knob { background: currentColor; } +.link-btn { color: var(--g5); line-height: 24px; text-decoration: underline; text-decoration-thickness: 2px; text-underline-offset: 4px; white-space: nowrap; } +.link-btn:hover { color: var(--a3); } + +.header-for { color: var(--g5); text-shadow: 2px 2px 0 var(--ink); } +.version-select.compact .version-select-btn { --top: 8px; min-width: 160px; } +.version-select.compact .version-select-menu { top: 56px; } + +.switch { display: flex; margin-bottom: 16px; } +.switch button { height: 32px; padding: 2px 16px; background: var(--g2); box-shadow: inset 0 calc(-1 * var(--px)) 0 var(--g1); } +.switch button:hover:not(.on) { background: var(--g3); } +.switch button.on { background: var(--pale); color: var(--ink); box-shadow: inset 0 calc(-1 * var(--px)) 0 var(--g5); } +.switch .count { margin-left: 8px; color: var(--a2); } +.switch button.on .count { color: var(--a1); } + +.banners { display: grid; gap: 8px; margin-bottom: 12px; } +.banners:empty { display: none; } +.banner { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 8px 8px 8px 16px; min-height: 40px; background: var(--ink); line-height: 24px; } +.banner > span { padding: 0; } +.banner-line { display: block; } +.banner .btn-secondary { flex: none; } +.banner.info { color: var(--pale); box-shadow: inset var(--px) 0 0 var(--g4); } +.banner.ok { color: var(--v3); box-shadow: inset var(--px) 0 0 var(--v2); } +.banner.warn { color: var(--a3); box-shadow: inset var(--px) 0 0 var(--a2); } +.banner.bad { color: var(--r3); box-shadow: inset var(--px) 0 0 var(--r2); } + +.text-input { height: 40px; padding: 6px 12px; background: var(--ink); color: var(--pale); border: 0; border-radius: 0; font: inherit; box-shadow: inset 0 var(--px) 0 #07080c; -webkit-user-select: text; user-select: text; min-width: 0; } +.text-input::placeholder { color: var(--g4); } +.text-input:focus-visible { outline: var(--px) solid var(--a2); outline-offset: 0; } +.text-input::-webkit-search-cancel-button { display: none; } + +.filters { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 24px; margin-bottom: 12px; } +.filters .search { flex: 1 1 240px; } +.checkbox { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; white-space: nowrap; text-shadow: 2px 2px 0 var(--ink); } +.checkbox input { position: absolute; opacity: 0; width: 1px; height: 1px; } +.checkbox .box { width: 16px; height: 16px; flex: none; background: var(--ink); box-shadow: inset 0 0 0 var(--px) var(--g3); } +.checkbox input:checked + .box { background: var(--a2); box-shadow: inset 0 0 0 var(--px) var(--a1); } +.checkbox input:focus-visible + .box { outline: var(--px) solid var(--pale); outline-offset: 2px; } + +.mod-list, .mod-grid, .checks, .source-list { margin: 0; padding: 0; list-style: none; } +.mod-list { display: grid; gap: 8px; } +.mod-row { display: grid; grid-template-columns: 48px minmax(0, 1fr) auto; align-items: center; gap: 16px; padding: 12px 16px 12px 16px; } +.mod-row.is-off { background: #20252f; } +.mod-row.is-off :is(.mod-name, .mod-icon) { opacity: .55; } +.mod-row.has-problem { box-shadow: inset var(--px) 0 0 var(--r2); } +.mod-icon { width: 48px; height: 48px; display: grid; place-items: center; background: var(--ink); flex: none; } +.mod-icon img { image-rendering: pixelated; } +.mod-main { min-width: 0; } +.mod-title { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; } +.mod-name { font-size: 24px; line-height: 32px; color: var(--a3); } +.mod-version { color: var(--g5); } +.mod-meta { margin-top: 2px; color: var(--g5); line-height: 24px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.mod-meta.bad { color: var(--r3); white-space: normal; } +.mod-actions { display: flex; align-items: center; gap: 8px; } +.mod-update { margin-top: 8px; } +.mod-actions .progress-container, .mod-card-foot .progress-container { width: 200px; } + +.badge { display: inline-flex; align-items: center; gap: 6px; padding: 0 8px; line-height: 24px; white-space: nowrap; cursor: default; } +.trust-reviewed { background: var(--v2); color: var(--ink); box-shadow: inset 0 calc(-1 * var(--px)) 0 var(--v1); } +.trust-unreviewed { background: var(--g3); color: var(--pale); box-shadow: inset 0 calc(-1 * var(--px)) 0 var(--g2); } +.trust-unverified { background: var(--r2); color: var(--ink); box-shadow: inset 0 calc(-1 * var(--px)) 0 var(--r1); } +.badge.channel { background: var(--g2); color: var(--g5); } + +.mod-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 8px; } +.mod-card { display: flex; flex-direction: column; gap: 8px; padding: 12px 16px 16px; } +.mod-card-head { display: flex; gap: 16px; align-items: center; } +.mod-card-title { display: grid; gap: 4px; min-width: 0; } +.mod-card-title .mod-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.mod-badges { display: flex; flex-wrap: wrap; gap: 8px; } +.mod-summary { color: #b9c1d3; line-height: 24px; flex: 1; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; min-height: 48px; } +.mod-card-foot { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.mod-source { color: var(--g5); line-height: 24px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; } +.installed-mark { color: var(--v3); line-height: 24px; white-space: nowrap; } + +.empty-state p + p { margin-top: 8px; } +.framed-empty { padding: 24px; background: #1a1e28cc; max-width: 640px; } +.empty-state .hint { color: var(--g5); } +.empty-actions { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 16px; } +.framed-empty > .btn-primary { margin-top: 16px; } + +/* Dialog */ +.scrim { position: fixed; inset: 0; z-index: 20; display: grid; place-items: center; padding: 16px; background: #07080ccc; } +.dialog { width: min(720px, 100%); max-height: 100%; overflow-y: auto; padding: 16px 24px 20px; box-shadow: 0 0 0 var(--px) var(--ink); } +.dialog-title { font-size: 24px; line-height: 32px; color: var(--a3); } +.dialog-sub { margin-top: 4px; color: var(--g5); line-height: 24px; display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; } +.dialog-sub code, .check code { color: var(--a3); } +.dialog-summary { margin-top: 12px; color: #b9c1d3; line-height: 24px; } +.dialog-field { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; margin-top: 12px; } +.dialog-field .dialog-note { margin: 0; } +.dialog-field .text-input { flex: 1; } +.dialog-field-label { color: var(--g5); white-space: nowrap; } +.checks { display: grid; gap: 6px; margin-top: 16px; } +.check { display: grid; grid-template-columns: 24px minmax(0, 1fr); gap: 12px; align-items: start; line-height: 24px; } +.check-mark { width: 24px; height: 24px; display: grid; place-items: center; } +.check-yes .check-mark { background: var(--v2); color: var(--ink); } +.check-no .check-mark { background: var(--r2); color: var(--ink); } +.check-unknown .check-mark { background: var(--g3); color: var(--pale); } +.check small { display: block; font-size: 16px; color: var(--g5); overflow-wrap: anywhere; } +.dialog-warning { margin-top: 16px; padding: 10px 16px; background: var(--ink); line-height: 24px; } +.dialog-warning.amber { color: var(--a3); box-shadow: inset var(--px) 0 0 var(--a2); } +.dialog-warning.red { color: var(--r3); box-shadow: inset var(--px) 0 0 var(--r2); } +.dialog-note { margin-top: 12px; color: var(--g5); line-height: 24px; } +.dialog .inline-error { margin-top: 12px; } +.dialog-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin-top: 20px; } +.dialog-actions .spacer { flex: 1; } +.typed-confirm { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; margin-top: 16px; line-height: 24px; } +.typed-confirm strong { color: var(--a3); } +.typed-confirm .text-input { flex: 1 1 200px; } +.source-list { display: grid; gap: 4px; margin-top: 16px; } +.source-row { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; grid-template-areas: "badge name action" "badge meta action"; gap: 0 12px; align-items: center; padding: 8px 12px; background: var(--ink); } +.source-row .badge { grid-area: badge; } +.source-name { grid-area: name; overflow-wrap: anywhere; } +.source-meta { grid-area: meta; color: var(--g5); line-height: 24px; } +.source-meta.bad { color: var(--r3); } +.source-row > button { grid-area: action; } + +/* Home: what will load */ +.mods-line { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 16px; margin-top: 16px; color: var(--g5); text-shadow: 2px 2px 0 var(--ink); line-height: 24px; max-width: 640px; } +.mod-failure { display: grid; gap: 4px; justify-content: stretch; } +.mod-failure-title { color: var(--r3); } +.mod-failure-line { color: var(--g5); font-size: 16px; overflow-wrap: anywhere; } +.mod-failure-why { color: var(--pale); } +.mod-failure-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; } From c65516ed362aaa00eddde1df421e463ac8f7b2a9 Mon Sep 17 00:00:00 2001 From: Zorro909 Date: Wed, 4 Nov 2026 19:15:45 +0000 Subject: [PATCH 003/156] Trust the published mod index key Embed keys/index.pub of Zorro909/weft-mods-index and keep a signed copy of the live index as a test fixture, so a wrong key or a contract check that drops real entries fails the tests. --- src-tauri/src/mods/index.rs | 16 +- .../src/mods/testdata/published-index.json | 922 ++++++++++++++++++ .../testdata/published-index.json.minisig | 4 + 3 files changed, 941 insertions(+), 1 deletion(-) create mode 100644 src-tauri/src/mods/testdata/published-index.json create mode 100644 src-tauri/src/mods/testdata/published-index.json.minisig diff --git a/src-tauri/src/mods/index.rs b/src-tauri/src/mods/index.rs index f0f00aa..51de9e3 100644 --- a/src-tauri/src/mods/index.rs +++ b/src-tauri/src/mods/index.rs @@ -16,7 +16,7 @@ use super::paths::{cache_dir, write_atomic}; pub const INDEX_URL: &str = "https://zorro909.github.io/weft-mods-index/v1/index.json"; /// `keys/index.pub` of the index repository. -pub const INDEX_PUBLIC_KEY: &str = "RWQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; +pub const INDEX_PUBLIC_KEY: &str = "RWRelY1XUps7n+bc3RVDFwMAWzPYz4orZvxzRTo0M+hK2A0w02cQmfvh"; const MAX_INDEX_BYTES: usize = 8 * 1024 * 1024; @@ -470,6 +470,20 @@ pub(crate) mod tests { )); } + /// A copy of the live index, signed by the real key: the embedded key is the right + /// one, and nothing the index publishes is dropped by the contract checks here. + #[test] + fn the_published_index_verifies_and_keeps_every_entry() { + let bytes = include_bytes!("testdata/published-index.json"); + let sig = include_str!("testdata/published-index.json.minisig"); + let index = verify(bytes, sig, INDEX_PUBLIC_KEY).unwrap(); + let raw: Index = serde_json::from_slice(bytes).unwrap(); + let count = |i: &Index| i.mods.iter().map(|m| m.versions.len()).sum::(); + assert!(count(&raw) > 0); + assert_eq!(count(&index), count(&raw)); + assert_eq!(index.weft.len(), raw.weft.len()); + } + #[test] fn another_key_is_refused() { assert!(matches!( diff --git a/src-tauri/src/mods/testdata/published-index.json b/src-tauri/src/mods/testdata/published-index.json new file mode 100644 index 0000000..6501578 --- /dev/null +++ b/src-tauri/src/mods/testdata/published-index.json @@ -0,0 +1,922 @@ +{ + "schema": 1, + "generated": "2026-11-04T19:04:11Z", + "weft": [ + { + "tag": "v26.11.1", + "commit": "ff9c7128511fc0dce7719b67c9a59274d70bdd5a", + "abi": "f470b1cbb1f9ad91" + }, + { + "tag": "v26.10.4", + "commit": "34fa22b1b6546aa8a8d3d12252bd7a5b01dbb0ec", + "abi": "5f0569a6bea71b81" + }, + { + "tag": "v26.10.3", + "commit": "7e32b4217b6ada9ae6567845256caf42d203fdeb", + "abi": "0c4348eac3c90c5c" + }, + { + "tag": "v26.10.2", + "commit": "23d2a3b5fa95a674c993ae9f115851e8318c3c8c", + "abi": "7f70a596eee22b6f" + }, + { + "tag": "v26.10.1", + "commit": "d444d448770c5648d452fe76ebe4e1166322d67e", + "abi": "7f70a596eee22b6f" + }, + { + "tag": "v0.1.0", + "commit": "54a5829f3c25fc6975379f6421cd64c9d0e378b0", + "abi": "27b2fc40e2fd67eb" + } + ], + "mods": [ + { + "id": "cogwork", + "name": "Cogwork", + "summary": "Gears, item tubes and engines, an excavator that digs out a whole area layer by layer…", + "authors": [ + "Zorro909" + ], + "license": null, + "homepage": "https://github.com/Zorro909/weft-mods/tree/main/cogwork", + "icon": "https://raw.githubusercontent.com/Zorro909/weft-mods/514b2feb25af29f7dabbadfec36a3f24f4c8e6b2/cogwork/assets/textures/gui/icon.png", + "versions": [ + { + "version": "0.2.1-rc.1", + "channel": "prerelease", + "tag": "cogwork-v0.2.1-rc.1", + "published": "2026-11-04T12:08:06Z", + "source": { + "repo": "Zorro909/weft-mods", + "commit": "514b2feb25af29f7dabbadfec36a3f24f4c8e6b2" + }, + "weft": { + "tag": "v26.11.1", + "commit": "ff9c7128511fc0dce7719b67c9a59274d70bdd5a", + "abi": "f470b1cbb1f9ad91" + }, + "deps": [ + { + "id": "base" + } + ], + "platforms": [ + "windows-x64", + "linux-x64", + "macos-arm64" + ], + "asset": { + "name": "cogwork.zip", + "url": "https://github.com/Zorro909/weft-mods/releases/download/cogwork-v0.2.1-rc.1/cogwork.zip", + "size": 3114153, + "sha256": "f4b03a760afa163167307b57dd8ff10e4443aef9a5fc5f26fe907982aa7b8d07" + }, + "provenance": null, + "reviewed": { + "by": "Zorro909", + "at": "2026-11-04" + } + }, + { + "version": "0.2.0", + "channel": "release", + "tag": "cogwork-v0.2.0", + "published": "2026-11-04T02:39:03Z", + "source": { + "repo": "Zorro909/weft-mods", + "commit": "cceea7805df5902d62017d2008d1bb29ed84e152" + }, + "weft": { + "tag": "v26.11.1", + "commit": "ff9c7128511fc0dce7719b67c9a59274d70bdd5a", + "abi": "f470b1cbb1f9ad91" + }, + "deps": [ + { + "id": "base" + } + ], + "platforms": [ + "windows-x64", + "linux-x64", + "macos-arm64" + ], + "asset": { + "name": "cogwork.zip", + "url": "https://github.com/Zorro909/weft-mods/releases/download/cogwork-v0.2.0/cogwork.zip", + "size": 2642469, + "sha256": "989b21912db1c53d06460cf7bbf9a979ce461c3fe197215124851a427bf7d20c" + }, + "provenance": null, + "reviewed": { + "by": "Zorro909", + "at": "2026-11-04" + } + }, + { + "version": "0.1.2-experimental.4563878.917596d", + "channel": "experimental", + "tag": "cogwork-v0.1.2-experimental.4563878.917596d", + "published": "2026-11-03T12:00:56Z", + "source": { + "repo": "Zorro909/weft-mods", + "commit": "8c2543e950d06004c66c49fab96b7ecce4e6343b" + }, + "weft": { + "tag": "v26.11.1-46-g4563878", + "commit": "4563878b067425085701268f074f9483b2625e4c", + "abi": "5d449b795eda0099" + }, + "deps": [ + { + "id": "base" + } + ], + "platforms": [ + "windows-x64", + "linux-x64", + "macos-arm64" + ], + "asset": { + "name": "cogwork.zip", + "url": "https://github.com/Zorro909/weft-mods/releases/download/cogwork-v0.1.2-experimental.4563878.917596d/cogwork.zip", + "size": 2591246, + "sha256": "322afb5a502472fe5bf05b41416f73d14c40e22794f71eea99063312406609ef" + }, + "provenance": null, + "reviewed": { + "by": "Zorro909", + "at": "2026-11-04" + } + }, + { + "version": "0.1.2-experimental.b5958a1.917596d", + "channel": "experimental", + "tag": "cogwork-v0.1.2-experimental.b5958a1.917596d", + "published": "2026-11-02T18:03:48Z", + "source": { + "repo": "Zorro909/weft-mods", + "commit": "917596da05afdf1fc87a4e20a20a6e6e427a5a86" + }, + "weft": { + "tag": "v26.11.1-5-gb5958a1", + "commit": "b5958a15ba4f528cb349d7b8788e3892a18be091", + "abi": "d3dae6ede60ad004" + }, + "deps": [ + { + "id": "base" + } + ], + "platforms": [ + "windows-x64", + "linux-x64", + "macos-arm64" + ], + "asset": { + "name": "cogwork.zip", + "url": "https://github.com/Zorro909/weft-mods/releases/download/cogwork-v0.1.2-experimental.b5958a1.917596d/cogwork.zip", + "size": 2589353, + "sha256": "fb1cff0624ad2b7f20b61186e27afab48b7f7cfef602f629646e986b1f1792ce" + }, + "provenance": null, + "reviewed": { + "by": "Zorro909", + "at": "2026-11-04" + } + }, + { + "version": "0.1.2", + "channel": "release", + "tag": "cogwork-v0.1.2", + "published": "2026-11-02T18:03:45Z", + "source": { + "repo": "Zorro909/weft-mods", + "commit": "917596da05afdf1fc87a4e20a20a6e6e427a5a86" + }, + "weft": { + "tag": "v26.11.1", + "commit": "ff9c7128511fc0dce7719b67c9a59274d70bdd5a", + "abi": "f470b1cbb1f9ad91" + }, + "deps": [ + { + "id": "base" + } + ], + "platforms": [ + "windows-x64", + "linux-x64", + "macos-arm64" + ], + "asset": { + "name": "cogwork.zip", + "url": "https://github.com/Zorro909/weft-mods/releases/download/cogwork-v0.1.2/cogwork.zip", + "size": 2586387, + "sha256": "cb39cecb64e22ab2a6e6cad761e131b42b7a57e055924776561e72ea87ba0db5" + }, + "provenance": null, + "reviewed": { + "by": "Zorro909", + "at": "2026-11-04" + } + }, + { + "version": "0.1.1", + "channel": "release", + "tag": "cogwork-v0.1.1", + "published": "2026-11-02T14:13:07Z", + "source": { + "repo": "Zorro909/weft-mods", + "commit": "5855730cc0a423eee7fd7e0a0eebb67bb8f355ac" + }, + "weft": { + "tag": "v26.11.1", + "commit": "ff9c7128511fc0dce7719b67c9a59274d70bdd5a", + "abi": "f470b1cbb1f9ad91" + }, + "deps": [ + { + "id": "base" + } + ], + "platforms": [ + "windows-x64", + "linux-x64", + "macos-arm64" + ], + "asset": { + "name": "cogwork.zip", + "url": "https://github.com/Zorro909/weft-mods/releases/download/cogwork-v0.1.1/cogwork.zip", + "size": 2585995, + "sha256": "a35711d9e0188c9e086ac55088283f754d0617f62b4c2a31bcddfe5deed0e65f" + }, + "provenance": null, + "reviewed": { + "by": "Zorro909", + "at": "2026-11-04" + } + }, + { + "version": "0.1.1-experimental.090722d.5855730", + "channel": "experimental", + "tag": "cogwork-v0.1.1-experimental.090722d.5855730", + "published": "2026-11-02T14:12:55Z", + "source": { + "repo": "Zorro909/weft-mods", + "commit": "5855730cc0a423eee7fd7e0a0eebb67bb8f355ac" + }, + "weft": { + "tag": "v26.11.1-2-g090722d", + "commit": "090722d8954973e0badcd130446cbebbe0016181", + "abi": "f470b1cbb1f9ad91" + }, + "deps": [ + { + "id": "base" + } + ], + "platforms": [ + "windows-x64", + "linux-x64", + "macos-arm64" + ], + "asset": { + "name": "cogwork.zip", + "url": "https://github.com/Zorro909/weft-mods/releases/download/cogwork-v0.1.1-experimental.090722d.5855730/cogwork.zip", + "size": 2585993, + "sha256": "98ae1eb863b47855576523529d3c34b004e9eecbc9fb8ec010ce62e2c53b597c" + }, + "provenance": null, + "reviewed": { + "by": "Zorro909", + "at": "2026-11-04" + } + }, + { + "version": "0.1.0-experimental.090722d.8da6d93", + "channel": "experimental", + "tag": "cogwork-v0.1.0-experimental.090722d.8da6d93", + "published": "2026-11-02T12:34:24Z", + "source": { + "repo": "Zorro909/weft-mods", + "commit": "66de7e3a57c8edb1c6da476948f7092527bdc5c2" + }, + "weft": { + "tag": "v26.11.1-2-g090722d", + "commit": "090722d8954973e0badcd130446cbebbe0016181", + "abi": "f470b1cbb1f9ad91" + }, + "deps": [ + { + "id": "base" + } + ], + "platforms": [ + "windows-x64", + "linux-x64", + "macos-arm64" + ], + "asset": { + "name": "cogwork.zip", + "url": "https://github.com/Zorro909/weft-mods/releases/download/cogwork-v0.1.0-experimental.090722d.8da6d93/cogwork.zip", + "size": 2586504, + "sha256": "c4577a2a2abd1ff6d42419a3bfc798f02c5e8392a8311c516ad07c6d2475919e" + }, + "provenance": null, + "reviewed": { + "by": "Zorro909", + "at": "2026-11-04" + } + }, + { + "version": "0.1.0", + "channel": "release", + "tag": "cogwork-v0.1.0", + "published": "2026-11-02T00:54:53Z", + "source": { + "repo": "Zorro909/weft-mods", + "commit": "f90064856310bcbfd5f00c9f83d5813a8e3fbb21" + }, + "weft": { + "tag": "v26.10.4-56-g5faf2e3", + "commit": "5faf2e3e57697dd3476b03a300f1231b84f6419e", + "abi": "f470b1cbb1f9ad91" + }, + "deps": [ + { + "id": "base" + } + ], + "platforms": [ + "windows-x64", + "linux-x64", + "macos-arm64" + ], + "asset": { + "name": "cogwork.zip", + "url": "https://github.com/Zorro909/weft-mods/releases/download/cogwork-v0.1.0/cogwork.zip", + "size": 2586992, + "sha256": "07ca439eb9d20aa1563220097d27b097b4a6c5ac250ce15ee049fc47d86b6774" + }, + "provenance": null, + "reviewed": { + "by": "Zorro909", + "at": "2026-11-04" + } + }, + { + "version": "0.1.0-experimental.ff9c712.8da6d93", + "channel": "experimental", + "tag": "cogwork-v0.1.0-experimental.ff9c712.8da6d93", + "published": "2026-11-02T00:54:35Z", + "source": { + "repo": "Zorro909/weft-mods", + "commit": "f90064856310bcbfd5f00c9f83d5813a8e3fbb21" + }, + "weft": { + "tag": "v26.11.1", + "commit": "ff9c7128511fc0dce7719b67c9a59274d70bdd5a", + "abi": "f470b1cbb1f9ad91" + }, + "deps": [ + { + "id": "base" + } + ], + "platforms": [ + "windows-x64", + "linux-x64", + "macos-arm64" + ], + "asset": { + "name": "cogwork.zip", + "url": "https://github.com/Zorro909/weft-mods/releases/download/cogwork-v0.1.0-experimental.ff9c712.8da6d93/cogwork.zip", + "size": 2586505, + "sha256": "cbac69292b213670369957c94e0eafc4511e6bd1fd5cf6133d2d88328478565a" + }, + "provenance": null, + "reviewed": { + "by": "Zorro909", + "at": "2026-11-04" + } + } + ] + }, + { + "id": "showcase", + "name": "Showcase", + "summary": "Plays the films other mods make of their content: a set built in the world and a camera flying around it in orbits, flybys and cranes.", + "authors": [ + "Zorro909" + ], + "license": null, + "homepage": "https://github.com/Zorro909/weft-mods/tree/main/showcase", + "icon": null, + "versions": [ + { + "version": "0.1.1-rc.2", + "channel": "prerelease", + "tag": "showcase-v0.1.1-rc.2", + "published": "2026-11-04T12:08:09Z", + "source": { + "repo": "Zorro909/weft-mods", + "commit": "514b2feb25af29f7dabbadfec36a3f24f4c8e6b2" + }, + "weft": { + "tag": "v26.11.1", + "commit": "ff9c7128511fc0dce7719b67c9a59274d70bdd5a", + "abi": "f470b1cbb1f9ad91" + }, + "deps": [], + "platforms": [ + "windows-x64", + "linux-x64", + "macos-arm64" + ], + "asset": { + "name": "showcase.zip", + "url": "https://github.com/Zorro909/weft-mods/releases/download/showcase-v0.1.1-rc.2/showcase.zip", + "size": 2115903, + "sha256": "707745d8fc940031256060434d0a64ef0e7f5bdb016c716c24458ca4e0a13bf0" + }, + "provenance": null, + "reviewed": { + "by": "Zorro909", + "at": "2026-11-04" + } + }, + { + "version": "0.1.1-rc.1", + "channel": "prerelease", + "tag": "showcase-v0.1.1-rc.1", + "published": "2026-11-04T02:39:06Z", + "source": { + "repo": "Zorro909/weft-mods", + "commit": "cceea7805df5902d62017d2008d1bb29ed84e152" + }, + "weft": { + "tag": "v26.11.1", + "commit": "ff9c7128511fc0dce7719b67c9a59274d70bdd5a", + "abi": "f470b1cbb1f9ad91" + }, + "deps": [], + "platforms": [ + "windows-x64", + "linux-x64", + "macos-arm64" + ], + "asset": { + "name": "showcase.zip", + "url": "https://github.com/Zorro909/weft-mods/releases/download/showcase-v0.1.1-rc.1/showcase.zip", + "size": 2116303, + "sha256": "97ab1bbbaae5b3664bfa6164810ebe3af8b4590f1a89b860f0800203b1766d30" + }, + "provenance": null, + "reviewed": { + "by": "Zorro909", + "at": "2026-11-04" + } + }, + { + "version": "0.1.0-experimental.4563878.7ce8734", + "channel": "experimental", + "tag": "showcase-v0.1.0-experimental.4563878.7ce8734", + "published": "2026-11-03T12:00:58Z", + "source": { + "repo": "Zorro909/weft-mods", + "commit": "8c2543e950d06004c66c49fab96b7ecce4e6343b" + }, + "weft": { + "tag": "v26.11.1-46-g4563878", + "commit": "4563878b067425085701268f074f9483b2625e4c", + "abi": "5d449b795eda0099" + }, + "deps": [], + "platforms": [ + "windows-x64", + "linux-x64", + "macos-arm64" + ], + "asset": { + "name": "showcase.zip", + "url": "https://github.com/Zorro909/weft-mods/releases/download/showcase-v0.1.0-experimental.4563878.7ce8734/showcase.zip", + "size": 2107517, + "sha256": "d592062bb4d8a77635f46125de20f7f7ac8bd682bc52a6e7f31b938c55dd7cf6" + }, + "provenance": null, + "reviewed": { + "by": "Zorro909", + "at": "2026-11-04" + } + }, + { + "version": "0.1.0-experimental.b5958a1.7ce8734", + "channel": "experimental", + "tag": "showcase-v0.1.0-experimental.b5958a1.7ce8734", + "published": "2026-11-02T18:03:50Z", + "source": { + "repo": "Zorro909/weft-mods", + "commit": "917596da05afdf1fc87a4e20a20a6e6e427a5a86" + }, + "weft": { + "tag": "v26.11.1-5-gb5958a1", + "commit": "b5958a15ba4f528cb349d7b8788e3892a18be091", + "abi": "d3dae6ede60ad004" + }, + "deps": [], + "platforms": [ + "windows-x64", + "linux-x64", + "macos-arm64" + ], + "asset": { + "name": "showcase.zip", + "url": "https://github.com/Zorro909/weft-mods/releases/download/showcase-v0.1.0-experimental.b5958a1.7ce8734/showcase.zip", + "size": 2105349, + "sha256": "6c1549b381f3902f88673df293c235366407a878a5e6e00c1bab8436ffe800ae" + }, + "provenance": null, + "reviewed": { + "by": "Zorro909", + "at": "2026-11-04" + } + }, + { + "version": "0.1.0", + "channel": "release", + "tag": "showcase-v0.1.0", + "published": "2026-11-02T18:03:47Z", + "source": { + "repo": "Zorro909/weft-mods", + "commit": "917596da05afdf1fc87a4e20a20a6e6e427a5a86" + }, + "weft": { + "tag": "v26.11.1", + "commit": "ff9c7128511fc0dce7719b67c9a59274d70bdd5a", + "abi": "f470b1cbb1f9ad91" + }, + "deps": [], + "platforms": [ + "windows-x64", + "linux-x64", + "macos-arm64" + ], + "asset": { + "name": "showcase.zip", + "url": "https://github.com/Zorro909/weft-mods/releases/download/showcase-v0.1.0/showcase.zip", + "size": 2102725, + "sha256": "45ab76b2768694a68918800260b4058d6b16c3471498fe44c5792cfb905a0331" + }, + "provenance": null, + "reviewed": { + "by": "Zorro909", + "at": "2026-11-04" + } + } + ] + }, + { + "id": "structures", + "name": "Structures", + "summary": "Generates structures from .wst files in the overworld, alone and as hamlets.", + "authors": [ + "Zorro909" + ], + "license": null, + "homepage": "https://github.com/Zorro909/weft-mods/tree/main/structures", + "icon": "https://raw.githubusercontent.com/Zorro909/weft-mods/cceea7805df5902d62017d2008d1bb29ed84e152/structures/assets/textures/gui/icon.png", + "versions": [ + { + "version": "0.1.3-rc.1", + "channel": "prerelease", + "tag": "structures-v0.1.3-rc.1", + "published": "2026-11-04T02:39:09Z", + "source": { + "repo": "Zorro909/weft-mods", + "commit": "cceea7805df5902d62017d2008d1bb29ed84e152" + }, + "weft": { + "tag": "v26.11.1", + "commit": "ff9c7128511fc0dce7719b67c9a59274d70bdd5a", + "abi": "f470b1cbb1f9ad91" + }, + "deps": [ + { + "id": "base" + } + ], + "platforms": [ + "windows-x64", + "linux-x64", + "macos-arm64" + ], + "asset": { + "name": "structures.zip", + "url": "https://github.com/Zorro909/weft-mods/releases/download/structures-v0.1.3-rc.1/structures.zip", + "size": 2268217, + "sha256": "ca444b61dc22c04fe053b8acd9f809b1e659e874df644731ad10918abeaca239" + }, + "provenance": null, + "reviewed": { + "by": "Zorro909", + "at": "2026-11-04" + } + }, + { + "version": "0.1.2-experimental.4563878.917596d", + "channel": "experimental", + "tag": "structures-v0.1.2-experimental.4563878.917596d", + "published": "2026-11-03T12:01:02Z", + "source": { + "repo": "Zorro909/weft-mods", + "commit": "8c2543e950d06004c66c49fab96b7ecce4e6343b" + }, + "weft": { + "tag": "v26.11.1-46-g4563878", + "commit": "4563878b067425085701268f074f9483b2625e4c", + "abi": "5d449b795eda0099" + }, + "deps": [ + { + "id": "base" + } + ], + "platforms": [ + "windows-x64", + "linux-x64", + "macos-arm64" + ], + "asset": { + "name": "structures.zip", + "url": "https://github.com/Zorro909/weft-mods/releases/download/structures-v0.1.2-experimental.4563878.917596d/structures.zip", + "size": 2274880, + "sha256": "abf92f44774cb9fef02e8d7cee365aea1993cb39536b4fb713c5f1b11e48eae1" + }, + "provenance": null, + "reviewed": { + "by": "Zorro909", + "at": "2026-11-04" + } + }, + { + "version": "0.1.2-experimental.b5958a1.917596d", + "channel": "experimental", + "tag": "structures-v0.1.2-experimental.b5958a1.917596d", + "published": "2026-11-02T18:03:53Z", + "source": { + "repo": "Zorro909/weft-mods", + "commit": "917596da05afdf1fc87a4e20a20a6e6e427a5a86" + }, + "weft": { + "tag": "v26.11.1-5-gb5958a1", + "commit": "b5958a15ba4f528cb349d7b8788e3892a18be091", + "abi": "d3dae6ede60ad004" + }, + "deps": [ + { + "id": "base" + } + ], + "platforms": [ + "windows-x64", + "linux-x64", + "macos-arm64" + ], + "asset": { + "name": "structures.zip", + "url": "https://github.com/Zorro909/weft-mods/releases/download/structures-v0.1.2-experimental.b5958a1.917596d/structures.zip", + "size": 2272769, + "sha256": "5d2efc634fa4d091437de643864f79cf2e70ebbd315523b8bb26c1f4573436e0" + }, + "provenance": null, + "reviewed": { + "by": "Zorro909", + "at": "2026-11-04" + } + }, + { + "version": "0.1.2", + "channel": "release", + "tag": "structures-v0.1.2", + "published": "2026-11-02T18:03:50Z", + "source": { + "repo": "Zorro909/weft-mods", + "commit": "917596da05afdf1fc87a4e20a20a6e6e427a5a86" + }, + "weft": { + "tag": "v26.11.1", + "commit": "ff9c7128511fc0dce7719b67c9a59274d70bdd5a", + "abi": "f470b1cbb1f9ad91" + }, + "deps": [ + { + "id": "base" + } + ], + "platforms": [ + "windows-x64", + "linux-x64", + "macos-arm64" + ], + "asset": { + "name": "structures.zip", + "url": "https://github.com/Zorro909/weft-mods/releases/download/structures-v0.1.2/structures.zip", + "size": 2270608, + "sha256": "4579c714fd0768e2f71c41a1e08064199797e1d0378a19dd1d563e8348d415cc" + }, + "provenance": null, + "reviewed": { + "by": "Zorro909", + "at": "2026-11-04" + } + }, + { + "version": "0.1.1", + "channel": "release", + "tag": "structures-v0.1.1", + "published": "2026-11-02T14:13:10Z", + "source": { + "repo": "Zorro909/weft-mods", + "commit": "5855730cc0a423eee7fd7e0a0eebb67bb8f355ac" + }, + "weft": { + "tag": "v26.11.1", + "commit": "ff9c7128511fc0dce7719b67c9a59274d70bdd5a", + "abi": "f470b1cbb1f9ad91" + }, + "deps": [ + { + "id": "base" + } + ], + "platforms": [ + "windows-x64", + "linux-x64", + "macos-arm64" + ], + "asset": { + "name": "structures.zip", + "url": "https://github.com/Zorro909/weft-mods/releases/download/structures-v0.1.1/structures.zip", + "size": 2268411, + "sha256": "dc5c4390c0bc2fd0f458ea6f02208e502fff5eb730ecba4dbed409f9e75f4434" + }, + "provenance": null, + "reviewed": { + "by": "Zorro909", + "at": "2026-11-04" + } + }, + { + "version": "0.1.1-experimental.090722d.5855730", + "channel": "experimental", + "tag": "structures-v0.1.1-experimental.090722d.5855730", + "published": "2026-11-02T14:12:58Z", + "source": { + "repo": "Zorro909/weft-mods", + "commit": "5855730cc0a423eee7fd7e0a0eebb67bb8f355ac" + }, + "weft": { + "tag": "v26.11.1-2-g090722d", + "commit": "090722d8954973e0badcd130446cbebbe0016181", + "abi": "f470b1cbb1f9ad91" + }, + "deps": [ + { + "id": "base" + } + ], + "platforms": [ + "windows-x64", + "linux-x64", + "macos-arm64" + ], + "asset": { + "name": "structures.zip", + "url": "https://github.com/Zorro909/weft-mods/releases/download/structures-v0.1.1-experimental.090722d.5855730/structures.zip", + "size": 2268414, + "sha256": "729a55916c94584c409284eedf6b79bbef53a82e4012554ab194e51b044cc10d" + }, + "provenance": null, + "reviewed": { + "by": "Zorro909", + "at": "2026-11-04" + } + }, + { + "version": "0.1.0-experimental.090722d.935cf10", + "channel": "experimental", + "tag": "structures-v0.1.0-experimental.090722d.935cf10", + "published": "2026-11-02T12:34:28Z", + "source": { + "repo": "Zorro909/weft-mods", + "commit": "66de7e3a57c8edb1c6da476948f7092527bdc5c2" + }, + "weft": { + "tag": "v26.11.1-2-g090722d", + "commit": "090722d8954973e0badcd130446cbebbe0016181", + "abi": "f470b1cbb1f9ad91" + }, + "deps": [ + { + "id": "base" + } + ], + "platforms": [ + "windows-x64", + "linux-x64", + "macos-arm64" + ], + "asset": { + "name": "structures.zip", + "url": "https://github.com/Zorro909/weft-mods/releases/download/structures-v0.1.0-experimental.090722d.935cf10/structures.zip", + "size": 2268456, + "sha256": "079db519167c2cd09eeef42d7234ebc8b1424985dfc95a16798d01f614dd4a7e" + }, + "provenance": null, + "reviewed": { + "by": "Zorro909", + "at": "2026-11-04" + } + }, + { + "version": "0.1.0", + "channel": "release", + "tag": "structures-v0.1.0", + "published": "2026-11-02T00:54:55Z", + "source": { + "repo": "Zorro909/weft-mods", + "commit": "f90064856310bcbfd5f00c9f83d5813a8e3fbb21" + }, + "weft": { + "tag": "v26.10.4-56-g5faf2e3", + "commit": "5faf2e3e57697dd3476b03a300f1231b84f6419e", + "abi": "f470b1cbb1f9ad91" + }, + "deps": [ + { + "id": "base" + } + ], + "platforms": [ + "windows-x64", + "linux-x64", + "macos-arm64" + ], + "asset": { + "name": "structures.zip", + "url": "https://github.com/Zorro909/weft-mods/releases/download/structures-v0.1.0/structures.zip", + "size": 2268416, + "sha256": "417656fde1e77a2eee43def2cba34dc3a87f3f571c5db6ab34435b9de98381de" + }, + "provenance": null, + "reviewed": { + "by": "Zorro909", + "at": "2026-11-04" + } + }, + { + "version": "0.1.0-experimental.ff9c712.935cf10", + "channel": "experimental", + "tag": "structures-v0.1.0-experimental.ff9c712.935cf10", + "published": "2026-11-02T00:54:37Z", + "source": { + "repo": "Zorro909/weft-mods", + "commit": "f90064856310bcbfd5f00c9f83d5813a8e3fbb21" + }, + "weft": { + "tag": "v26.11.1", + "commit": "ff9c7128511fc0dce7719b67c9a59274d70bdd5a", + "abi": "f470b1cbb1f9ad91" + }, + "deps": [ + { + "id": "base" + } + ], + "platforms": [ + "windows-x64", + "linux-x64", + "macos-arm64" + ], + "asset": { + "name": "structures.zip", + "url": "https://github.com/Zorro909/weft-mods/releases/download/structures-v0.1.0-experimental.ff9c712.935cf10/structures.zip", + "size": 2268460, + "sha256": "c9c1bf6797cd91d332397426ab666f3cf2caf41665330d3e5332ac5a8e83e6db" + }, + "provenance": null, + "reviewed": { + "by": "Zorro909", + "at": "2026-11-04" + } + } + ] + } + ], + "revoked": [] +} diff --git a/src-tauri/src/mods/testdata/published-index.json.minisig b/src-tauri/src/mods/testdata/published-index.json.minisig new file mode 100644 index 0000000..5c6a1e0 --- /dev/null +++ b/src-tauri/src/mods/testdata/published-index.json.minisig @@ -0,0 +1,4 @@ +untrusted comment: signature from minisign secret key +RURelY1XUps7n2m5kpQu4sUNqi28gQDq9PjETjFDSI10jlYOClgrpOh5ydKKE2IFQ8GkITs0GKyNJO4d0TMlzJ4Q8/JUhi7I8wM= +trusted comment: timestamp:1793819114 file:index.json commit:5e4de0c8b8b4354646fbd8d56e415089e2b3c300 +d7FsnPifi8cXHXHOnG4UPJKLJtVZMvJjOb054RmcqUX5C7chQEEV+bxuZsTqADMDT8YFWyvGZW27L5xdhxo8Cg== From e8b5599cede65861c4e76f0c23e22978045f6276 Mon Sep 17 00:00:00 2001 From: Zorro909 Date: Wed, 4 Nov 2026 19:20:24 +0000 Subject: [PATCH 004/156] Describe the mod manager as built The proposal names the modules, commands and tier as they are in the code and lists what shipped; the README covers the Mods tab, where mod data lives, and the browser mock. --- README.md | 16 +++++++++++++--- docs/mod-manager/PROPOSAL.md | 34 +++++++++++++++++++++++++--------- 2 files changed, 38 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 15dfae2..9e802b3 100644 --- a/README.md +++ b/README.md @@ -23,10 +23,12 @@ The builds aren't code-signed yet: **Versions** -> **Install** -> back to **Home** -> **Play**. Delete a version with a double click on **Delete** (blocked while the game is running). +**Mods** -> pick the game version -> **Browse** -> **Install**. Each game version keeps its own mods. Mods are native programs: Reviewed ones come from the signed [Weft mod index](https://github.com/Zorro909/weft-mods-index), and anything else (a GitHub repo you add under **Sources**, or a zip **From file**) is labelled as unreviewed or unverified. See [docs/mod-manager](docs/mod-manager/PROPOSAL.md). + ## Data -- **Game data** (worlds, settings): `%APPDATA%\Weft` - the launcher never reads or writes it. -- **Launcher data**: `%APPDATA%\dev.weft.launcher` - `versions/`, `logs/.log`, `cache/`, `downloads/`. +- **Game data** (worlds, settings): `%APPDATA%\Weft` - the launcher never writes it, and only lists its `mods` folder to warn about mods it can't manage. +- **Launcher data**: `%APPDATA%\dev.weft.launcher` - `versions/`, `logs/.log`, `cache/`, `downloads/`, and for mods `mods/store/` (downloads by SHA-256) and `profiles//` (each version's mod set). Newer versions may write saves older ones can't open - back up `saves` before downgrading. @@ -40,7 +42,15 @@ npm run tauri dev # dev mode (plain `cargo run` won't work - UI is served by npm run tauri build # installer ``` -Before a PR: `cargo fmt --check && cargo clippy` in `src-tauri`, and `npm run build`. UI strings go through `src/i18n` - add keys to all four languages (en, pt-BR, es, ru). +Before a PR: `cargo fmt --check && cargo clippy && cargo test` in `src-tauri`, and `npm run build`. UI strings go through `src/i18n` - add keys to all four languages (en, pt-BR, es, ru); `npm run check:i18n` (also part of `npm run build`) lists missing keys and placeholder mismatches. + +### UI without Tauri + +```bash +npm run dev:mock # http://localhost:1420/?scenario=mods +``` + +Runs the UI in a browser against a fake backend (`src/dev/mockBackend.ts`). Pick a state with `?scenario=`: `mods` (default: problems, updates, a source, a local file), `empty`, `offline`, `crash` (Play fails on a mod), `fresh` (no versions) or `knownfile` (the file picker returns a catalogued file). The mock is never part of a production build. ## Releasing diff --git a/docs/mod-manager/PROPOSAL.md b/docs/mod-manager/PROPOSAL.md index 5573987..43a63b0 100644 --- a/docs/mod-manager/PROPOSAL.md +++ b/docs/mod-manager/PROPOSAL.md @@ -1,6 +1,6 @@ # Mod manager for Weft Launcher: proposal -Status: draft, 2026-11-04. Nothing is implemented yet. Mockup: [`mockup.html`](mockup.html). Open it in a browser; it's drawn at 1:1 in the launcher's own pixel style. +Status: Phases 1 and 2 are implemented on `feat/mod-manager` (see [What shipped](#what-shipped)); Phase 3 needs engine changes and is not started. Written 2026-11-04. Mockup (the first design; the app has moved on since): [`mockup.html`](mockup.html). Open it in a browser; it's drawn at 1:1 in the launcher's own pixel style. Sources: - Read-only research of the engine (`Weft` @ `7c69c9c`, `master`). @@ -88,13 +88,16 @@ Launch becomes `weft --mods /mods>`. Turning a mod on or off just | Module | Job | |---|---| -| `abi.rs` | ABI hash of a Weft tag. **Now:** fetch `raw.githubusercontent.com/Weft-Rust/Weft//crates/abi/src/lib.rs` and compute FNV-1a-64 (needs no engine change; verified against v26.10.2 and v26.10.4). **Later:** read a `weft-abi.txt` release asset. | -| `catalog.rs` | Read the signed index, plus `*.mod.json` assets from the GitHub repos the user added, using the same ETag cache as `releases.rs`. Filter by platform, ABI and channel. | -| `verify.rs` | Index signature (minisign), SHA-256, and provenance (§4). | -| `store.rs` | Stream the download (reusing `install.rs`'s download and progress code), verify, check the zip structure, then move it into `store/`. | -| `profile.rs` | Lock file, turning mods on and off, resolving dependencies, the pre-Play check. | +| `abi.rs` | ABI hash of a Weft tag: the index's `weft[]` table, else a cache, else FNV-1a-64 of `raw.githubusercontent.com/Weft-Rust/Weft//crates/abi/src/lib.rs` (verified against v26.10.2 and v26.10.4). **Later:** read a `weft-abi.txt` release asset. | +| `index.rs` | Fetch the signed index, verify it (minisign), refuse an older one than the last verified copy, keep that copy for offline use, and drop entries that break [INDEX_FORMAT.md](INDEX_FORMAT.md). | +| `sources.rs` | GitHub repos the player added: read their releases (ETag-cached) and accept a `.mod.json` only where it agrees with GitHub on tag, asset name, URL, size and digest. | +| `catalog.rs` | Merge the index and the sources into one list with a trust tier per version; a source build with a reviewed hash counts as Reviewed. | +| `archive.rs` | Check the zip structure (below) before anything is stored. | +| `profile.rs` | Lock file, the store and the per-version folders, the status of each mod, updates, revocations. | +| `diagnose.rs` | Read the game log after a crash and name the mod that stopped it. | +| `commands.rs` | The Tauri commands; installs go through `fetch_to_store`, which streams via the shared `download.rs`, checks the SHA-256 and the zip, then moves it into `store/`. | -Tauri commands: `list_mod_catalog`, `get_profile_mods`, `install_mod`, `install_mod_from_file`, `set_mod_enabled`, `remove_mod`, `check_profile`, `copy_profile`. +Tauri commands: `list_mod_catalog`, `get_mod_sources`, `add_mod_source`, `remove_mod_source`, `get_mod_profile`, `install_mod`, `inspect_mod_file`, `install_mod_file`, `set_mod_enabled`, `remove_mod`, `copy_mod_profile`, `get_mod_icon`, `open_player_mods_folder`. The pre-Play check runs inside `launch_version`. ### Checking the zip structure (before it is stored) - Only `.dll`, `lib.so`, `lib.dylib` and `assets/**` are allowed. @@ -135,7 +138,7 @@ What we can and can't promise: | Badge | Meaning | Checks that must pass | |---|---|---| | **Reviewed** (green) | In the curated Weft mod index | Index signature ✔, SHA-256 equals the index pin ✔, provenance ✔ (Phase 2) | -| **CI-built** (grey) | From a GitHub repo the user added as a source; built in public CI, not reviewed | SHA-256 equals GitHub `digest` ✔. Phase 2 adds a provenance attestation: built by `release.yml` of repo R at commit C ✔ | +| **Unreviewed** (grey) | From a GitHub repo the user added as a source; not reviewed by anyone | SHA-256 equals GitHub `digest` ✔. Phase 2 adds a provenance attestation: built by `release.yml` of repo R at commit C ✔ | | **Unverified** (red) | Local file, or a source without provenance | Zip structure only. Typed confirmation needed (mockup 3) | | **Revoked** | The index lists the sha256 as revoked | The launcher turns the mod off on the next refresh and says why | @@ -204,5 +207,18 @@ The UI deliberately has **no permission list**. Native code has no permissions t 1. **Isolation scope.** Per-version mod sets with **shared** saves (proposed), or full "instances" with `--run-dir` (separate saves and config per profile, like MultiMC)? Shared saves are friendlier. The engine already asks when a world's mods are missing. 2. **Index ownership.** Who hosts the curated index (the `Weft-Rust` org?), who reviews, and where does the signing key live: offline, or a CI secret with required reviews? 3. **Reading the player's mods folder.** Is a read-only listing for the warning acceptable, given the README says the launcher never reads game data? -4. **Third-party sources.** Should users be able to add any GitHub repo (CI-built tier), or only use the index plus local files? +4. **Third-party sources.** Should users be able to add any GitHub repo (Unreviewed tier), or only use the index plus local files? 5. **Upstream engine changes.** The launcher targets `Weft-Rust/Weft`. Engine changes 1–3 would make Phase 1 much more robust. Are they something you can push upstream? + +--- + +## What shipped + +Implemented on `feat/mod-manager`; names below are the ones in the code. + +- **Phase 1 and 2 together.** The signed index lives at `Zorro909/weft-mods-index` and is served from GitHub Pages; the launcher embeds its public key (`INDEX_PUBLIC_KEY` in `index.rs`). Sources, local files, revocation and "Bring mods from vX" (`copy_mod_profile`) all work. +- **Renamed tier.** "CI-built" became **Unreviewed**: a source's release proves nothing about CI until it carries an attestation, so the name only claims what is known. +- **Provenance** is shown when the index records it (`provenance` in INDEX_FORMAT) and is otherwise stated as "No build record". The launcher doesn't verify attestations itself; the index does that when a version is added. +- **Matching is by ABI, not tag.** Builds made from untagged Weft commits fit every release with the same ABI. +- **Deferred:** everything in Phase 3, and the open questions above. The player's own `mods` folder is listed read-only for the warning (question 3) and never written to. +- **Working on the UI without Tauri:** `npm run dev:mock` (see the README). From 8e65ca495fef6130fc4e9b8d36e59425e7fe3e12 Mon Sep 17 00:00:00 2001 From: Zorro909 Date: Wed, 4 Nov 2026 20:01:45 +0000 Subject: [PATCH 005/156] Name the mod behind every engine load failure FailureKind is now an enum. An init panic takes its mod from the mod's own "[mod:X] panicked" line before it, or the nearest "loading mod X". A library path with spaces in it still gives the mod id. Every failure kind has a fixture in the launcher's log format. Adds ts-rs and url, and tempfile and http for tests. --- src-tauri/Cargo.lock | 35 ++++++ src-tauri/Cargo.toml | 6 + src-tauri/src/mods/diagnose.rs | 201 +++++++++++++++++++++++++++------ 3 files changed, 205 insertions(+), 37 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 668576e..3bf6121 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4400,6 +4400,15 @@ dependencies = [ "new_debug_unreachable", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -4731,6 +4740,28 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ts-rs" +version = "12.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8" +dependencies = [ + "thiserror 2.0.21", + "ts-rs-macros", +] + +[[package]] +name = "ts-rs-macros" +version = "12.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d90eea51bc7988ef9e674bf80a85ba6804739e535e9cab48e4bb34a8b652aa" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "termcolor", +] + [[package]] name = "typed-path" version = "0.13.0" @@ -5112,6 +5143,7 @@ dependencies = [ "base64 0.22.1", "flate2", "futures-util", + "http", "minisign-verify", "reqwest 0.12.28", "serde", @@ -5125,9 +5157,12 @@ dependencies = [ "tauri-plugin-process", "tauri-plugin-single-instance", "tauri-plugin-updater", + "tempfile", "thiserror 2.0.21", "time", "tokio", + "ts-rs", + "url", "zip 0.6.6", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e2cff0b..0cbfaa7 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -36,6 +36,8 @@ flate2 = "1.0" tar = "0.4" minisign-verify = "0.3" base64 = "0.22" +ts-rs = { version = "12", features = ["no-serde-warnings"] } +url = "2" [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-updater = "2" @@ -49,3 +51,7 @@ lto = true opt-level = 3 panic = "abort" strip = true + +[dev-dependencies] +tempfile = "3" +http = "1" diff --git a/src-tauri/src/mods/diagnose.rs b/src-tauri/src/mods/diagnose.rs index 036b8f2..52704bc 100644 --- a/src-tauri/src/mods/diagnose.rs +++ b/src-tauri/src/mods/diagnose.rs @@ -2,14 +2,31 @@ //! reason in its log; these are the engine's own messages (`crates/engine/src/modloader.rs`). use serde::Serialize; +use ts_rs::TS; -#[derive(Debug, Clone, Serialize, PartialEq)] +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq, TS)] +#[serde(rename_all = "camelCase")] +pub enum FailureKind { + /// `mod X was built against another mod ABI (…)` + WrongVersion, + /// `loading mod X from : ` + LoadFailed, + /// ` is not a Weft mod` + NotAMod, + /// `mod X is the crate Y: name its folder or zip Y` + WrongName, + /// `mod X requires missing mod Y` + MissingDep, + /// `init panicked`, after the mod's own `[mod:X] panicked at …` + InitPanicked, +} + +#[derive(Debug, Clone, Serialize, PartialEq, TS)] #[serde(rename_all = "camelCase")] pub struct ModFailure { - /// `wrongVersion`, `loadFailed`, `notAMod`, `wrongName`, `missingDep` or `initPanicked`. - pub kind: &'static str, + pub kind: FailureKind, pub mod_id: Option, - /// For `missingDep`: the mod that is missing. + /// For `missingDep`: the mod that is missing. For `wrongName`: the crate the library is. pub other: Option, /// The log line, so the player can search for it. pub line: String, @@ -42,7 +59,13 @@ fn id_from_library(path: &str) -> Option { (!id.is_empty()).then(|| id.to_string()) } +/// The message part of a log line: after the last `[target] `, which may hold spaces in a path. +fn message(line: &str) -> &str { + line.rfind("] ").map_or(line, |i| &line[i + 2..]) +} + fn classify(line: &str) -> Option { + use FailureKind as K; let failure = |kind, mod_id: Option<&str>, other: Option<&str>| ModFailure { kind, mod_id: mod_id.map(str::to_string), @@ -50,72 +73,176 @@ fn classify(line: &str) -> Option { line: line.trim().to_string(), }; if line.contains("was built against another mod ABI") { - return Some(failure("wrongVersion", word_after(line, "mod "), None)); + return Some(failure(K::WrongVersion, word_after(line, "mod "), None)); } if line.contains(" requires missing mod ") { return Some(failure( - "missingDep", + K::MissingDep, word_after(line, "mod "), word_after(line, " requires missing mod "), )); } if line.contains(" is the crate ") { - return Some(failure("wrongName", word_after(line, "mod "), None)); - } - if line.contains("loading mod ") { return Some(failure( - "loadFailed", - word_after(line, "loading mod "), - None, + K::WrongName, + word_after(line, "mod "), + word_after(line, " is the crate "), )); } + if let Some(id) = word_after(line, "loading mod ") + && line.contains(&format!("loading mod {id} from ")) + { + return Some(failure(K::LoadFailed, Some(id), None)); + } if let Some(at) = line.find(" is not a Weft mod") { - let path = line[..at].rsplit(' ').next().unwrap_or(""); + let path = message(&line[..at]).trim(); return Some(ModFailure { mod_id: id_from_library(path), - ..failure("notAMod", None, None) + ..failure(K::NotAMod, None, None) }); } if line.contains("init panicked") { - return Some(failure("initPanicked", None, None)); + return Some(failure(K::InitPanicked, None, None)); } None } +/// For an init panic, the mod named by the lines before it: the mod's own `[mod:X] panicked` +/// line, else the nearest `loading mod X`. +fn panicked_mod(before: &[String]) -> Option { + let from_panic = before.iter().rev().find_map(|l| { + if !l.contains("panicked") { + return None; + } + word_after(l, "[mod:").map(str::to_string) + }); + from_panic.or_else(|| { + before + .iter() + .rev() + .find_map(|l| word_after(l, "loading mod ").map(str::to_string)) + }) +} + /// The first mod failure in the log of one run. pub fn find(log: &str) -> Option { - log.lines() - .map(crate::launch::strip_ansi_codes) - .find_map(|l| classify(&l)) + let lines: Vec = log.lines().map(crate::launch::strip_ansi_codes).collect(); + lines.iter().enumerate().find_map(|(i, l)| { + let mut failure = classify(l)?; + if failure.kind == FailureKind::InitPanicked { + failure.mod_id = panicked_mod(&lines[..i]); + } + Some(failure) + }) } #[cfg(test)] mod tests { use super::*; + use FailureKind as K; + /// One fixture per kind, in the shape the launcher's log has: `[stderr]` from the launcher, + /// then the engine's own `
    + {update.dialog} ); } - -function ModsLine({ names, onChange }: { names: string[]; onChange: () => void }) { - const { t } = useI18n(); - return ( -

    - {names.length ? t("mods.homeMods", names.join(", ")) : t("mods.homeModsNone")} - -

    - ); -} - -const FAILURE_KEY: Record = { - wrongVersion: "mods.failureWrongVersion", - missingDep: "mods.failureMissingDep", - loadFailed: "mods.failureLoad", - notAMod: "mods.failureNotAMod", - wrongName: "mods.failureWrongName", - initPanicked: "mods.failureInit", -}; - -interface ModFailureNoticeProps { - failure: ModFailure; - /** The installed mod the log names, when the launcher manages it. */ - managed: { id: string; name: string; enabled: boolean } | undefined; - onTurnOff: (id: string) => void; - onGoToMods: () => void; - onOpenLog: () => void; -} - -/** Says which mod stopped the game, and offers the fix right there. */ -function ModFailureNotice({ failure, managed, onTurnOff, onGoToMods, onOpenLog }: ModFailureNoticeProps) { - const { t } = useI18n(); - const name = managed?.name ?? failure.modId ?? "?"; - return ( -
    - {t(FAILURE_KEY[failure.kind], name, failure.other ?? "")} - {failure.line} - {!managed && failure.modId && {t("mods.failureOutside")}} - - {managed?.enabled && ( - - )} - {managed ? ( - - ) : ( - - )} - - -
    - ); -} diff --git a/src/pages/Mods.tsx b/src/pages/Mods.tsx index 87b43ba..632ef2c 100644 --- a/src/pages/Mods.tsx +++ b/src/pages/Mods.tsx @@ -1,11 +1,13 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useId, useMemo, useState } from "react"; import { open as openFileDialog } from "@tauri-apps/plugin-dialog"; import { useI18n } from "../i18n/useI18n"; import { translateError } from "../i18n/errors"; import { useInstall } from "../hooks/useInstall"; import { useGameState } from "../hooks/useGameState"; import { useMods } from "../hooks/useMods"; +import { useRovingTabs } from "../hooks/useRovingTabs"; import { modsApi } from "../mods/api"; +import { copyDonors } from "../mods/select"; import { compareTags } from "../utils/version"; import { PixelIcon } from "../components/PixelIcon"; import { VersionSelect } from "../components/VersionSelect"; @@ -13,9 +15,14 @@ import { InstalledList } from "../components/mods/InstalledList"; import { BrowseList } from "../components/mods/BrowseList"; import { FileInstallDialog } from "../components/mods/FileInstallDialog"; import { SourcesDialog } from "../components/mods/SourcesDialog"; +import { CopyDialog } from "../components/mods/CopyDialog"; +import { BannerStack, type BannerItem } from "../components/mods/BannerStack"; +import { useCatalogBanners } from "../components/mods/catalogBanners"; +import { ModFailureNotice, useUpdateDialog } from "../components/mods/ModFixes"; import { REFRESH_ICON } from "../components/icons"; -type View = "installed" | "browse"; +const VIEWS = ["installed", "browse"] as const; +type View = (typeof VIEWS)[number]; interface ModsPageProps { selectedTag: string | null; @@ -24,28 +31,32 @@ interface ModsPageProps { } export function ModsPage({ selectedTag, onSelectTag, onGoToVersions }: ModsPageProps) { - const { t } = useI18n(); + const { t, tn, tm } = useI18n(); const { installedVersions, loaded } = useInstall(); - const { isRunning } = useGameState(); + const { isRunning, lastCrash } = useGameState(); const mods = useMods(); - const { profiles, profileErrors, loadProfile, notice, clearNotice, catalogLoading, refreshCatalog } = mods; + const { profile, profileError, notices, dismissNotice, catalogLoading, refreshCatalog, nameIn } = mods; const [view, setView] = useState("installed"); const [filePath, setFilePath] = useState(null); const [showSources, setShowSources] = useState(false); + const [showCopy, setShowCopy] = useState(false); const [rowError, setRowError] = useState(null); + const versionLabelId = useId(); + const tabs = useRovingTabs("mods", VIEWS, view, setView); - const versions = useMemo( - () => [...installedVersions].sort((a, b) => compareTags(b.tag, a.tag)), - [installedVersions] - ); + const versions = useMemo(() => [...installedVersions].sort((a, b) => compareTags(b.tag, a.tag)), [installedVersions]); const tag = versions.find((v) => v.tag === selectedTag)?.tag ?? versions[0]?.tag ?? null; - const report = tag ? profiles[tag] : undefined; + const report = profile(tag); const running = tag ? isRunning(tag) : false; - - // Every version's set is read, so another version's mods can be offered here. - useEffect(() => { - versions.forEach((v) => loadProfile(v.tag)); - }, [versions, loadProfile]); + const update = useUpdateDialog(tag); + const catalogBanners = useCatalogBanners(() => setShowSources(true)); + const donors = tag + ? copyDonors( + versions.map((v) => v.tag), + tag, + Object.fromEntries(versions.map((v) => [v.tag, profile(v.tag)])) + ) + : []; useEffect(() => setRowError(null), [tag, view]); @@ -80,33 +91,74 @@ export function ModsPage({ selectedTag, onSelectTag, onGoToVersions }: ModsPageP } }; - const nameOf = (id: string) => report?.mods.find((m) => m.id === id)?.name ?? id; - const donor = versions - .filter((v) => v.tag !== tag) - .map((v) => profiles[v.tag]) - .find((p) => p && p.mods.some((m) => m.enabled)); + const name = nameIn(tag); const empty = report && report.mods.length === 0; + const error = rowError ?? profileError(tag); + + // What blocks the player or answers their last action shows in full. The rest folds away. + const full: BannerItem[] = []; + if (running) full.push({ id: "running", tone: "info", text: t("mods.runningBanner", tag) }); + for (const n of notices(tag)) { + full.push({ + id: `notice:${n.id}`, + tone: n.tone, + text: n.lines.map((l, i) => ( + + {tm(l)} + + )), + actions: ( + + ), + }); + } + if (report && report.blocking.length > 0) { + const names = report.blocking.map(name).join(", "); + full.push({ id: "blocking", tone: "bad", text: tn("mods.blockingBanner", report.blocking.length, tag, names) }); + } + if (error) full.push({ id: "error", tone: "bad", text: error }); + + const quiet: BannerItem[] = []; + if (report && report.unmanaged.length > 0) { + const text = t("mods.unmanagedBanner", report.unmanaged.join(", ")); + quiet.push({ + id: "unmanaged", + tone: "warn", + text, + summary: text, + actions: ( + + ), + }); + } + if (report?.abiError) { + const text = t(view === "browse" ? "mods.abiUnknownBrowse" : "mods.abiUnknown", tag); + quiet.push({ id: "abi", tone: "info", text, summary: text }); + } + if (view === "browse") quiet.push(...catalogBanners); + + const count = report?.mods.length ?? 0; return (

    {t("mods.title")}

    - {t("mods.forVersion")} + + {t("mods.versionPicker")} + ({ value: v.tag, label: v.tag }))} onChange={onSelectTag} disabled={false} - label={t("mods.versionLabel")} + labelledBy={versionLabelId} compact /> - -
    -
    - {(["installed", "browse"] as const).map((v) => ( - - ))} -
    - -
    - {running && ( -
    - {t("mods.runningBanner", tag)} -
    - )} - {notice && notice.tag === tag && ( -
    - - {notice.lines.map((l, i) => ( - - {l} - - ))} - - -
    - )} - {report && report.blocking.length > 0 && ( -
    - {t("mods.blockingBanner", tag, report.blocking.map(nameOf).join(", "))} -
    - )} - {report && report.unmanaged.length > 0 && ( -
    - {t("mods.unmanagedBanner", report.unmanaged.join(", "))} -
    +
    + + {donors.length > 0 && ( + -
    - )} - {report?.abiError && report.mods.length > 0 && ( -
    - {t("mods.abiUnknown", tag)} -
    - )} - {(rowError || (tag && profileErrors[tag])) && ( -
    - {rowError ?? profileErrors[tag]} -
    - )} + )} + +
    - {!report ? ( -
    {t("loading")}
    - ) : view === "installed" ? ( - empty ? ( -
    -

    {t("mods.emptyInstalled", tag)}

    -

    {t("mods.emptyInstalledHint")}

    -
    - - {donor && ( - - )} + {donors.length > 0 && ( + + )} +
    -
    + ) : ( + + ) ) : ( - - ) - ) : ( - - )} + + )} + {filePath && setFilePath(null)} />} {showSources && setShowSources(false)} />} + {showCopy && setShowCopy(false)} />} + {update.dialog} ); } diff --git a/src/pages/Versions.tsx b/src/pages/Versions.tsx index 9b45ed1..58118d1 100644 --- a/src/pages/Versions.tsx +++ b/src/pages/Versions.tsx @@ -1,5 +1,6 @@ import { useI18n } from "../i18n/useI18n"; import { useInstall } from "../hooks/useInstall"; +import { useMods } from "../hooks/useMods"; import { PixelIcon } from "../components/PixelIcon"; import { VersionActions } from "../components/VersionActions"; import { getLocale } from "../i18n"; @@ -37,8 +38,9 @@ export function VersionsPage({ onRefresh, newTag, }: VersionsPageProps) { - const { t, formatSize: fmtSize } = useI18n(); + const { t, tn, formatSize: fmtSize } = useI18n(); const { installedVersions } = useInstall(); + const { profile } = useMods(); const installedMap = new Map(installedVersions.map((v) => [v.tag, v])); @@ -57,7 +59,7 @@ export function VersionsPage({ )} {error && (
    - {t("errorPrefix")}{error} + {t("errorPrefix", error)}
    )} {open && ( -
      - {OPTIONS.map((o) => ( -
    • +
        + {OPTIONS.map((o, i) => ( +
      • ))} @@ -65,4 +97,4 @@ export function LanguageMenu() { )} ); -} \ No newline at end of file +} diff --git a/src/main.tsx b/src/main.tsx index b1bc200..c31695e 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,10 +4,11 @@ import App from "./App"; import { GameStateProvider } from "./hooks/useGameState"; import { InstallProvider } from "./hooks/useInstall"; import { ModsProvider } from "./hooks/useMods"; -import { initI18n } from "./i18n"; +import { getLocale, initI18n } from "./i18n"; import "./style.css"; initI18n(); +document.documentElement.lang = getLocale(); document.addEventListener("contextmenu", (e) => e.preventDefault()); diff --git a/src/style.css b/src/style.css index 2a22c95..1c7c5d5 100644 --- a/src/style.css +++ b/src/style.css @@ -169,7 +169,7 @@ button:disabled { cursor: not-allowed; } .lang-menu, .version-select-menu { margin: 0; padding: var(--px); list-style: none; background: var(--ink); } .lang-menu { position: absolute; right: 0; top: 40px; min-width: 176px; } .lang-item { display: flex; gap: 16px; width: 100%; padding: 2px 8px; text-align: left; } -.lang-item:hover { background: var(--g2); } +.lang-item:hover, .lang-item:focus-visible { background: var(--g2); outline: none; } .lang-item strong { color: var(--g5); } .lang-item.active strong { color: var(--a3); } From 37648a59154abe25e404a42b88559e06b94ece71 Mon Sep 17 00:00:00 2001 From: Zorro909 Date: Wed, 4 Nov 2026 20:06:26 +0000 Subject: [PATCH 018/156] Drop an unused check helper --- src/mods/checks.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/mods/checks.ts b/src/mods/checks.ts index 1e79fd9..32545eb 100644 --- a/src/mods/checks.ts +++ b/src/mods/checks.ts @@ -153,11 +153,6 @@ export function replaceNotes(plan: InstallPlan): RichMsg[] { return notes; } -/** Whether a check list is safe to act on: nothing failed except what the dialog warns about. */ -export function hasFailure(rows: CheckRow[]): boolean { - return rows.some((r) => r.id === "problem"); -} - /** A zip from the player's computer. */ export function fileChecks(inspection: FileInspection, tag: string): CheckRow[] { const rows: CheckRow[] = []; From 317d7691e146be38ed0a04e143658bc4055ba422 Mon Sep 17 00:00:00 2001 From: Zorro909 Date: Wed, 4 Nov 2026 20:16:39 +0000 Subject: [PATCH 019/156] Keep Browse summaries to two lines --- src/style.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/style.css b/src/style.css index 1c7c5d5..0612f58 100644 --- a/src/style.css +++ b/src/style.css @@ -365,7 +365,8 @@ button:disabled { cursor: not-allowed; } .mod-card-title { display: grid; gap: 4px; min-width: 0; } .mod-card-title .mod-name { display: block; } .mod-badges { display: flex; flex-wrap: wrap; gap: 8px; } -.mod-summary { color: #b9c1d3; line-height: 24px; flex: 1; min-height: 48px; align-self: stretch; } +.mod-summary { color: #b9c1d3; line-height: 24px; height: 48px; align-self: stretch; } +.mod-card-foot { margin-top: auto; } .mod-card-foot { display: flex; align-items: center; justify-content: space-between; gap: 12px; } .mod-source { color: var(--g5); line-height: 24px; min-width: 0; } .installed-mark { color: var(--v3); line-height: 24px; white-space: nowrap; } From a53053d4c6d5375034a6532287d32e371954f63e Mon Sep 17 00:00:00 2001 From: Zorro909 Date: Wed, 4 Nov 2026 20:49:22 +0000 Subject: [PATCH 020/156] Use the generated IPC types and test the UI in CI types.ts and ipc.ts re-export the bindings the backend generates, and the i18n check reads error codes from the generated ErrorCode union. A Playwright suite renders every mock scenario in all four languages at 960x565 and walks the install, launch-safety and keyboard flows. A CI workflow runs fmt, clippy, cargo test, the build, unit tests and the UI tests on pull requests. --- .github/workflows/ci.yml | 95 ++++++++++++++ .gitignore | 4 +- README.md | 2 +- e2e/flows.spec.ts | 159 +++++++++++++++++++++++ e2e/helpers.ts | 39 ++++++ e2e/scenarios.spec.ts | 40 ++++++ package-lock.json | 64 ++++++++++ package.json | 4 +- playwright.config.ts | 25 ++++ scripts/check-i18n.mjs | 41 ++---- src/mods/ipc.ts | 44 +------ src/mods/types.ts | 268 +-------------------------------------- 12 files changed, 450 insertions(+), 335 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 e2e/flows.spec.ts create mode 100644 e2e/helpers.ts create mode 100644 e2e/scenarios.spec.ts create mode 100644 playwright.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..36814eb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,95 @@ +name: CI + +on: + pull_request: + push: + branches: [master] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + rust: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - name: Install Tauri system packages + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + with: + toolchain: stable + components: rustfmt, clippy + + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + with: + workspaces: src-tauri + + # tauri::generate_context! wants the built frontend to exist. + - name: Placeholder frontend + run: mkdir -p dist && touch dist/index.html + + - name: Format + working-directory: src-tauri + run: cargo fmt --check + + - name: Clippy + working-directory: src-tauri + run: cargo clippy --all-targets -- -D warnings + + # Includes bindings_are_current: fails if src/mods/bindings is out of date. + - name: Test + working-directory: src-tauri + run: cargo test + + frontend: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: lts/* + cache: npm + + - run: npm ci + + # check:i18n, tsc and vite build + - name: Build + run: npm run build + + - name: Unit tests + run: npm test + + ui: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: lts/* + cache: npm + + - run: npm ci + + - name: Install Chromium + run: npx playwright install --with-deps chromium + + # Every mock scenario in all four languages at 960×565, plus the safety and keyboard flows. + - name: UI tests + run: npm run test:ui + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + if: failure() + with: + name: playwright-traces + path: test-results/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index 85d23d2..1507d23 100644 --- a/.gitignore +++ b/.gitignore @@ -34,4 +34,6 @@ desktop.ini # Logs logs *.log -*.bak \ No newline at end of file +*.bak +test-results/ +playwright-report/ diff --git a/README.md b/README.md index e6fced1..b0777c7 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ npm run tauri dev # dev mode (plain `cargo run` won't work - UI is served by npm run tauri build # installer ``` -Before a PR: `cargo fmt --check && cargo clippy && cargo test` in `src-tauri`, and `npm run build`. UI strings go through `src/i18n` - add keys to all four languages (en, pt-BR, es, ru); `npm run check:i18n` (also part of `npm run build`) lists missing keys, placeholder mismatches, missing plural forms (counted messages have `_one`, `_other` and, per language, `_few`/`_many` keys), message keys used in `src` that don't exist, and backend error codes without an `errors.*` message. `npm test` runs the unit tests (vitest) for the pure logic in `src/mods` and `src/i18n`. +Before a PR: `cargo fmt --check && cargo clippy && cargo test` in `src-tauri`, and `npm run build`. UI strings go through `src/i18n` - add keys to all four languages (en, pt-BR, es, ru); `npm run check:i18n` (also part of `npm run build`) lists missing keys, placeholder mismatches, missing plural forms (counted messages have `_one`, `_other` and, per language, `_few`/`_many` keys), message keys used in `src` that don't exist, and backend error codes without an `errors.*` message. `npm test` runs the unit tests (vitest) for the pure logic in `src/mods` and `src/i18n`. `npm run test:ui` runs the UI tests (Playwright, `e2e/`): every mock scenario in all four languages at the minimum 960×565 window, plus the install, launch-safety and keyboard flows. Run `npx playwright install chromium` once first. CI (`.github/workflows/ci.yml`) runs all of these on every pull request. ### UI without Tauri diff --git a/e2e/flows.spec.ts b/e2e/flows.spec.ts new file mode 100644 index 0000000..a36a1d0 --- /dev/null +++ b/e2e/flows.spec.ts @@ -0,0 +1,159 @@ +// The flows the mod manager's safety depends on, and keyboard use, against the mock backend. +import { expect, test } from "@playwright/test"; +import { MODS_TAB, focused, goToTab, open } from "./helpers"; + +test.describe("launch safety", () => { + test("blocked mods disable Play and one click fixes it", async ({ page }) => { + await open(page, "scenario=mods"); + await expect(page.locator(".btn-play")).toBeDisabled(); + await expect(page.locator(".hero-version-badge")).toContainText("MODS NEED A FIX"); + await page.locator(".blocker button", { hasText: "Turn off" }).first().click(); + await expect(page.locator(".btn-play")).toBeEnabled(); + }); + + test("a crash notice names the mod and stays until it is fixed", async ({ page }) => { + await open(page, "scenario=crash"); + await page.locator(".btn-play").click(); + const notice = page.locator(".mod-failure"); + await expect(notice).toBeVisible({ timeout: 10_000 }); + await expect(notice.locator("button", { hasText: "Get update" })).toHaveCount(1); + await goToTab(page, MODS_TAB); + await expect(page.locator(".mod-failure")).toHaveCount(1); + await page.locator(".mod-failure button", { hasText: "Turn off" }).click(); + await expect(page.locator(".mod-failure")).toHaveCount(0); + }); +}); + +test.describe("every install shows what was checked", () => { + test("an update opens the review dialog, it isn't installed silently", async ({ page }) => { + await open(page, "scenario=mods"); + await goToTab(page, MODS_TAB); + await page.locator(".mod-row .mod-update button").first().click(); + const dialog = page.locator("[role=dialog]"); + await expect(dialog).toBeVisible(); + await expect(dialog.locator(".checks li").first()).toBeVisible(); + await expect(dialog).toContainText(/can do anything/i); + }); + + test("provenance a repo only claims is never a green check", async ({ page }) => { + await open(page, "scenario=mods"); + await goToTab(page, MODS_TAB); + await page.locator(".switch [role=tab]").nth(1).click(); + await page.locator(".mod-card", { hasText: "Glowmoss" }).locator("button.btn-primary").click(); + const claimed = page.locator(".checks li", { hasText: "the repo says" }); + await expect(claimed).toHaveClass(/check-unknown/); + const spoken = await page + .locator(".checks li") + .evaluateAll((rows) => rows.map((r) => r.querySelector(".sr-only")?.textContent ?? "")); + expect(spoken.length).toBeGreaterThan(0); + for (const s of spoken) expect(s).toMatch(/Passed|Failed|Unknown/); + }); + + test("copying a mod set previews it before installing", async ({ page }) => { + await open(page, "scenario=empty"); + await goToTab(page, MODS_TAB); + await page.locator(".empty-actions .btn-secondary").first().click(); + const dialog = page.locator("[role=dialog]"); + await expect(dialog).toBeVisible(); + await expect(dialog.locator(".trust-badge, .badge").first()).toBeVisible(); + }); + + test("an update keeps a mod that is off turned off", async ({ page }) => { + await open(page, "scenario=mods"); + await goToTab(page, MODS_TAB); + const row = page.locator(".mod-row", { hasText: "Structures" }); + const toggle = row.locator(".toggle"); + if ((await toggle.getAttribute("aria-checked")) === "true") await toggle.click(); + await expect(toggle).toHaveAttribute("aria-checked", "false"); + await row.locator(".mod-update button").click(); + await page.locator("[role=dialog] .dialog-actions .btn-primary").click(); + await expect(page.locator("[role=dialog]")).toHaveCount(0, { timeout: 15_000 }); + await expect(row.locator(".toggle")).toHaveAttribute("aria-checked", "false"); + }); +}); + +test.describe("keyboard", () => { + test("top tabs: one tab stop, arrows, Home and End", async ({ page }) => { + await open(page, "scenario=mods"); + const stops = await page.locator(".tab-btn").evaluateAll((els) => els.filter((e) => (e as HTMLElement).tabIndex === 0).length); + expect(stops).toBe(1); + await page.locator(".tab-btn").first().focus(); + await page.keyboard.press("ArrowRight"); + expect((await focused(page)).text).toMatch(/^Versions/); + await expect(page.locator("#main-panel-versions[role=tabpanel]")).toHaveCount(1); + await page.keyboard.press("End"); + expect((await focused(page)).text).toMatch(/^Release Notes/); + await page.keyboard.press("Home"); + await page.keyboard.press("ArrowLeft"); + expect((await focused(page)).text).toMatch(/^Release Notes/); + }); + + test("Installed/Browse switch follows the arrows", async ({ page }) => { + await open(page, "scenario=mods"); + await goToTab(page, MODS_TAB); + await page.locator(".switch [role=tab]").first().focus(); + await page.keyboard.press("ArrowRight"); + expect((await focused(page)).text).toMatch(/^Browse/); + await expect(page.locator("#mods-panel-browse")).toHaveCount(1); + }); + + test("dialogs start on Cancel, keep focus inside and close on Escape", async ({ page }) => { + await open(page, "scenario=mods"); + await goToTab(page, MODS_TAB); + await page.locator(".mod-row .mod-update button").first().click(); + await expect(page.locator("[role=dialog]")).toBeVisible(); + const first = await focused(page); + expect(first.inDialog).toBe(true); + expect(first.text).toBe("Cancel"); + await page.evaluate(() => (document.querySelector(".tab-btn") as HTMLElement | null)?.focus()); + await expect.poll(async () => (await focused(page)).inDialog).toBe(true); + for (let i = 0; i < 8; i++) await page.keyboard.press("Tab"); + expect((await focused(page)).inDialog).toBe(true); + await page.keyboard.press("Escape"); + await expect(page.locator("[role=dialog]")).toHaveCount(0); + }); + + test("an unverified file can be confirmed by keyboard alone", async ({ page }) => { + await open(page, "scenario=mods"); + await goToTab(page, MODS_TAB); + await page.locator(".toolbar-actions button").first().click(); + const input = page.locator("[role=dialog] input"); + await expect(input).toBeVisible(); + expect((await focused(page)).inDialog).toBe(true); + await input.focus(); + await page.keyboard.type("mytweak"); + await page.keyboard.press("Enter"); + await expect(page.locator("[role=dialog]")).toHaveCount(0, { timeout: 10_000 }); + }); + + test("toggling a mod doesn't move its row", async ({ page }) => { + await open(page, "scenario=mods"); + await goToTab(page, MODS_TAB); + const names = () => page.locator(".mod-list .mod-name").allInnerTexts(); + const before = await names(); + await page.locator(".mod-row", { hasText: "Cogwork" }).locator(".toggle").click(); + await expect.poll(names).toEqual(before); + }); +}); + +test.describe("degraded states", () => { + test("a catalogue that fails to load offers Retry, not 'no mods'", async ({ page }) => { + await open(page, "scenario=mods&fail=catalog"); + await goToTab(page, MODS_TAB); + await page.locator(".switch [role=tab]").nth(1).click(); + await expect(page.locator(".mods-panel button", { hasText: "Retry" })).toHaveCount(1); + }); + + test("removing a source asks first and a bad repo fails inline", async ({ page }) => { + await open(page, "scenario=mods"); + await goToTab(page, MODS_TAB); + await page.locator(".toolbar-actions button", { hasText: "Sources" }).click(); + await page.locator(".source-row button", { hasText: "Remove" }).click(); + await expect(page.locator(".source-confirm")).toHaveCount(1); + await page.locator(".source-confirm .btn-danger").click(); + await expect(page.locator(".source-row")).toHaveCount(1); + await page.locator("[role=dialog] input").fill("not a repo"); + await page.keyboard.press("Enter"); + await expect(page.locator("[role=dialog] .inline-error")).toHaveCount(1); + }); +}); diff --git a/e2e/helpers.ts b/e2e/helpers.ts new file mode 100644 index 0000000..c27293b --- /dev/null +++ b/e2e/helpers.ts @@ -0,0 +1,39 @@ +import { expect, type Page } from "@playwright/test"; + +export const LOCALES = ["en", "pt-BR", "es", "ru"] as const; + +/** Opens the launcher on a mock scenario, in a language, and fails the test on any page error. */ +export async function open(page: Page, query: string, locale = "en") { + const errors: string[] = []; + page.on("pageerror", (e) => errors.push(e.message)); + await page.addInitScript((l) => localStorage.setItem("locale", l), locale); + await page.goto(`/?${query}`); + await expect(page.locator(".tab-btn").first()).toBeVisible(); + return errors; +} + +export async function goToTab(page: Page, index: number) { + await page.locator(".tab-btn").nth(index).click(); +} + +export const MODS_TAB = 2; + +/** What has keyboard focus, and whether it is inside the open dialog. */ +export const focused = (page: Page) => + page.evaluate(() => { + const a = document.activeElement; + return { tag: a?.tagName ?? "", text: a?.textContent?.trim() ?? "", inDialog: !!a?.closest("[role=dialog]") }; + }); + +/** Nothing sticks out sideways, and the dialog's buttons (if one is open) are inside the window. */ +export async function expectFits(page: Page) { + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth); + expect(overflow, "page scrolls sideways").toBeLessThanOrEqual(0); + const actions = page.locator("[role=dialog] .dialog-actions"); + if (await actions.count()) { + const box = await actions.boundingBox(); + const viewport = page.viewportSize()!; + expect(box, "dialog actions are rendered").not.toBeNull(); + expect(box!.y + box!.height, "dialog actions are below the window").toBeLessThanOrEqual(viewport.height); + } +} diff --git a/e2e/scenarios.spec.ts b/e2e/scenarios.spec.ts new file mode 100644 index 0000000..a6f4acf --- /dev/null +++ b/e2e/scenarios.spec.ts @@ -0,0 +1,40 @@ +// Every mock scenario, in every language: it renders without errors, nothing overflows the +// 960×565 window, and the dialogs keep their buttons on screen. +import { expect, test } from "@playwright/test"; +import { SCENARIOS } from "../src/dev/scenarios"; +import { LOCALES, MODS_TAB, expectFits, goToTab, open } from "./helpers"; + +for (const locale of LOCALES) { + for (const scenario of SCENARIOS) { + test(`${scenario.name} renders in ${locale}`, async ({ page }) => { + const errors = await open(page, `scenario=${scenario.name}`, locale); + await expectFits(page); + await goToTab(page, MODS_TAB); + await expect(page.locator(".mods-page")).toBeVisible(); + await expectFits(page); + const browse = page.locator(".switch [role=tab]").nth(1); + if (await browse.count()) { + await browse.click(); + await expectFits(page); + } + expect(errors).toEqual([]); + }); + } + + test(`dialogs fit in ${locale}`, async ({ page }) => { + await open(page, "scenario=mods&long=1", locale); + await goToTab(page, MODS_TAB); + + // Install dialog for an update. + await page.locator(".mod-row .mod-update button").first().click(); + await expect(page.locator("[role=dialog]")).toBeVisible(); + await expectFits(page); + await page.keyboard.press("Escape"); + + // The unverified file dialog is the tallest one. + await page.locator(".toolbar-actions button").first().click(); + await expect(page.locator("[role=dialog] input")).toBeVisible(); + await expectFits(page); + await page.keyboard.press("Escape"); + }); +} diff --git a/package-lock.json b/package-lock.json index 0cdf943..19c8467 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "react-dom": "^19.1.0" }, "devDependencies": { + "@playwright/test": "1.54.2", "@tauri-apps/cli": "^2", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", @@ -63,6 +64,22 @@ "url": "https://github.com/sponsors/oxc-project" } }, + "node_modules/@playwright/test": { + "version": "1.54.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.54.2.tgz", + "integrity": "sha512-A+znathYxPf+72riFd1r1ovOLqsIIB0jKIoPjyK2kqEIe30/6jF6BC7QNluHuwUmsD2tv1XZVugN8GqfTMOxsA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.54.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rolldown/binding-android-arm-eabi": { "version": "1.2.16", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.16.tgz", @@ -1088,6 +1105,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.54.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.54.2.tgz", + "integrity": "sha512-Hu/BMoA1NAdRUuulyvQC0pEqZ4vQbGfn8f7wPXcnqQmM+zct9UliKxsIkLNmz/ku7LElUNqmaiv1TG/aL5ACsw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.54.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.54.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.54.2.tgz", + "integrity": "sha512-n5r4HFbMmWsB4twG7tJLDN9gmBUeSPcsBZiWSE4DnYz9mJMAFqr2ID7+eGC9kpEnxExJ1epttwR59LEWCk8mtA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.32", "dev": true, diff --git a/package.json b/package.json index ccee719..05a4752 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "test": "vitest run", "test:watch": "vitest", "preview": "vite preview", - "tauri": "tauri" + "tauri": "tauri", + "test:ui": "playwright test" }, "dependencies": { "@tauri-apps/api": "^2", @@ -25,6 +26,7 @@ "react-dom": "^19.1.0" }, "devDependencies": { + "@playwright/test": "1.54.2", "@tauri-apps/cli": "^2", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..0745a69 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,25 @@ +// `npm run test:ui`: the UI against the mock backend (src/dev/mockBackend.ts) at the launcher's +// minimum window size. Set PW_CHROMIUM to use an already installed Chromium instead of +// `npx playwright install chromium`. +import { defineConfig } from "@playwright/test"; + +const PORT = 1450; + +export default defineConfig({ + testDir: "e2e", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI ? [["github"], ["list"]] : "list", + use: { + baseURL: `http://127.0.0.1:${PORT}`, + viewport: { width: 960, height: 565 }, + launchOptions: process.env.PW_CHROMIUM ? { executablePath: process.env.PW_CHROMIUM } : {}, + trace: "retain-on-failure", + }, + webServer: { + command: `npx vite --mode mock --host 127.0.0.1 --port ${PORT} --strictPort`, + url: `http://127.0.0.1:${PORT}`, + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/scripts/check-i18n.mjs b/scripts/check-i18n.mjs index c8c16bd..210a52a 100644 --- a/scripts/check-i18n.mjs +++ b/scripts/check-i18n.mjs @@ -5,10 +5,9 @@ // - a counted message (`key_one`, `key_other`...) lacks a plural form the language needs, as // Intl.PluralRules says (Russian: one, few, many, other), or has one it doesn't use; // - code under src/ uses a message key that en.json doesn't have; -// - an error code the backend can send has no `errors.*` message. The codes come from -// src-tauri/src/error.rs and src-tauri/src/mods/error.rs, and from the ErrorCode union in -// src/mods/types.ts. -import { readFileSync, readdirSync, statSync, existsSync } from "node:fs"; +// - an error code the backend can send has no `errors.*` message, going by the generated +// ErrorCode union in src/mods/bindings/ErrorCode.ts. +import { readFileSync, readdirSync, statSync } from "node:fs"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -108,36 +107,24 @@ for (const file of sources(join(root, "src"))) { } } -// Every error code the backend can send has a message. -const codes = new Map(); -const note = (code, where) => codes.set(code, [...(codes.get(code) ?? []), where]); -for (const rs of ["src-tauri/src/error.rs", "src-tauri/src/mods/error.rs"]) { - const path = join(root, rs); - if (!existsSync(path)) continue; - const text = readFileSync(path, "utf8"); - // `AppError::new("Code", ...)`, `=> ("Code", ...)` and, once codes are an enum, `ErrorCode::Code`. - const found = [ - ...text.matchAll(/AppError::new\(\s*"([A-Z][A-Za-z0-9]*)"/g), - ...text.matchAll(/=>\s*\(\s*"([A-Z][A-Za-z0-9]*)"/g), - ...text.matchAll(/\(\s*\n\s*"([A-Z][A-Za-z0-9]*)",/g), - ...text.matchAll(/ErrorCode::([A-Z][A-Za-z0-9]*)/g), - ]; - for (const m of found) note(m[1], rs); -} -const typesText = readFileSync(join(root, "src/mods/types.ts"), "utf8"); -const union = typesText.match(/export type ErrorCode =([\s\S]*?);/); -const tsCodes = new Set(union ? [...union[1].matchAll(/"([A-Za-z0-9]+)"/g)].map((m) => m[1]) : []); -if (!union) problems.push("src/mods/types.ts: no ErrorCode union found"); -for (const code of tsCodes) note(code, "src/mods/types.ts"); +// Every error code the backend can send has a message. The codes are the ErrorCode union that +// the backend generates (src/mods/bindings/ErrorCode.ts); a Rust test keeps that file current. +const codes = new Set(); +const unionPath = "src/mods/bindings/ErrorCode.ts"; +const union = readFileSync(join(root, unionPath), "utf8").match(/export type ErrorCode =([\s\S]*?);/); +if (!union) problems.push(`${unionPath}: no ErrorCode union found`); +else for (const m of union[1].matchAll(/"([A-Za-z0-9]+)"/g)) codes.add(m[1]); const errorsTs = readFileSync(join(dir, "errors.ts"), "utf8"); const mapping = Object.fromEntries([...errorsTs.matchAll(/^\s*([A-Z][A-Za-z0-9]*):\s*'([^']+)'/gm)].map((m) => [m[1], m[2]])); -for (const [code, where] of codes) { - if (!tsCodes.has(code)) problems.push(`${where[0]}: error code ${code} is missing from ErrorCode in src/mods/types.ts`); +for (const code of codes) { const key = mapping[code]; if (!key) problems.push(`src/i18n/errors.ts: error code ${code} has no message in ERROR_KEY`); else if (!(key in en.plain)) problems.push(`src/i18n/errors.ts: ${code} maps to ${key}, which en.json doesn't have`); } +for (const code of Object.keys(mapping)) { + if (!codes.has(code)) problems.push(`src/i18n/errors.ts: ${code} is not an ErrorCode the backend sends`); +} if (problems.length) { console.error(problems.join("\n")); diff --git a/src/mods/ipc.ts b/src/mods/ipc.ts index ebd9299..4b079f1 100644 --- a/src/mods/ipc.ts +++ b/src/mods/ipc.ts @@ -1,40 +1,4 @@ -// Command and event names, in one place for src/mods/api.ts, the hooks and the mock backend. The -// backend exports the same names to src/mods/bindings/; the integrator points this file at them. - -export const COMMANDS = { - // Game versions - listVersions: "list_versions", - installVersion: "install_version", - getInstalledVersions: "get_installed_versions", - removeVersion: "remove_version", - launchVersion: "launch_version", - getRunningVersions: "get_running_versions", - openGameFolder: "open_game_folder", - openLog: "open_log", - // Mods - listModCatalog: "list_mod_catalog", - getModSources: "get_mod_sources", - addModSource: "add_mod_source", - removeModSource: "remove_mod_source", - getModProfile: "get_mod_profile", - planModInstall: "plan_mod_install", - installMod: "install_mod", - inspectModFile: "inspect_mod_file", - installModFile: "install_mod_file", - setModEnabled: "set_mod_enabled", - removeMod: "remove_mod", - previewCopyModProfile: "preview_copy_mod_profile", - copyModProfile: "copy_mod_profile", - getModIcon: "get_mod_icon", - openPlayerModsFolder: "open_player_mods_folder", -} as const; - -export type Command = (typeof COMMANDS)[keyof typeof COMMANDS]; - -export const EVENTS = { - modProgress: "mod-progress", - gameState: "game-state", - installProgress: "install-progress", -} as const; - -export type EventName = (typeof EVENTS)[keyof typeof EVENTS]; +// Command and event names, generated from the backend. Used by src/mods/api.ts, the hooks and the +// mock backend, so a renamed command fails to compile instead of failing at runtime. +export { COMMANDS, EVENTS } from "./bindings/commands"; +export type { CommandName as Command, EventName } from "./bindings/commands"; diff --git a/src/mods/types.ts b/src/mods/types.ts index 81503c3..1ce1f7c 100644 --- a/src/mods/types.ts +++ b/src/mods/types.ts @@ -1,265 +1,3 @@ -// Mirrors the Rust types in src-tauri/src/mods, as in the round 2 IPC contract. Field names are -// the camelCase serde output. The integrator replaces this file with re-exports of the generated -// bindings in src/mods/bindings/, so keep the names and shapes exactly as the contract has them. - -export type Trust = "reviewed" | "unreviewed" | "unverified"; -export type Channel = "release" | "prerelease" | "experimental"; -export type Freshness = "fresh" | "cached" | "unavailable"; -export type Platform = "windows-x64" | "linux-x64" | "macos-arm64"; -export type ProgressStage = "downloading" | "verifying" | "installing"; -export type FailureKind = "wrongVersion" | "loadFailed" | "notAMod" | "wrongName" | "missingDep" | "initPanicked"; - -/** Every code an `AppError` can carry, across the whole app. */ -export type ErrorCode = - // Network, files and data - | "Network" - | "Io" - | "Serde" - | "InvalidHeader" - | "CacheCorrupted" - | "RateLimited" - | "Forbidden" - | "OfflineNoCache" - | "DiskFull" - // Game versions - | "AssetNotFound" - | "MissingDigest" - | "Zip" - | "CreateDir" - | "Sha256Mismatch" - | "HashMissing" - | "AlreadyInstalled" - | "NotInstalled" - | "DownloadFailed" - | "ExtractFailed" - | "GameRunning" - | "VersionInUse" - | "InvalidName" - | "InstallInProgress" - // Launching - | "AlreadyRunning" - | "AnotherGameRunning" - | "ExecutableNotFound" - | "ProcessSpawnFailed" - | "LogDirCreateFailed" - | "GameFolderNotFound" - | "OpenerError" - // Mods - | "IndexSignatureInvalid" - | "IndexRollback" - | "IndexInvalid" - | "AbiUnavailable" - | "ModNotInCatalog" - | "ModIncompatible" - | "ModNotForPlatform" - | "ModRevoked" - | "ModArchiveInvalid" - | "ModFileChanged" - | "ModNotInstalled" - | "ModsBlocked" - | "ModSourceInvalid" - | "ModSourceNotFound" - | "ModSourceRateLimited" - | "ModDownloadNotFound" - | "ModTooLarge" - | "ModIdReserved"; - -export type Origin = - | { kind: "index" } - | { kind: "source"; repo: string } - | { kind: "file"; name: string }; - -export interface AppErrorPayload { - code: ErrorCode; - detail: string | null; -} - -export interface CatalogVersion { - version: string; - channel: Channel; - tag: string; - published: string; - sourceRepo: string; - sourceCommit: string; - weftTag: string | null; - abi: string; - deps: string[]; - platforms: Platform[]; - sha256: string; - size: number; - url: string; - provenance: { - repo: string | null; - workflow: string | null; - gitRef: string | null; - verifiedAt: string | null; - } | null; - /** True only when provenance came from the signed index; a source's own claim is false. */ - provenanceVerified: boolean; - reviewed: { by: string; at: string } | null; - trust: Trust; -} - -export interface CatalogMod { - key: string; - id: string; - name: string; - summary: string; - authors: string[]; - license: string | null; - homepage: string | null; - icon: string | null; - origin: Origin; - versions: CatalogVersion[]; -} - -export interface SourceStatus { - /** Canonical casing, as GitHub reports it. */ - repo: string; - mods: number; - /** "cached": showing the last good copy because the refresh failed. */ - freshness: Freshness; - /** RFC 3339 time of the data shown. */ - checked: string | null; - /** Set whenever the last refresh failed, even when cached data is shown. */ - error: AppErrorPayload | null; -} - -export interface Catalog { - mods: CatalogMod[]; - revoked: { sha256: string; reason: string }[]; - index: { freshness: Freshness; generated: string | null; error: AppErrorPayload | null }; - sources: SourceStatus[]; - weft: { tag: string; commit: string; abi: string }[]; - platform: string; -} - -export type ModStatus = "ready" | "off" | "wrongVersion" | "missingDeps" | "revoked" | "unchecked"; - -export interface InstalledMod { - id: string; - name: string; - summary: string; - version: string | null; - channel: Channel | null; - sha256: string; - size: number; - enabled: boolean; - trust: Trust; - origin: Origin; - abi: string | null; - weftTag: string | null; - deps: string[]; - tag: string | null; - sourceRepo: string | null; - sourceCommit: string | null; - icon: string | null; - installedAt: string; - status: ModStatus; - missingDeps: string[]; - revokedReason: string | null; - update: { version: string; sha256: string; channel: Channel; trust: Trust } | null; -} - -export interface ProfileReport { - tag: string; - versionAbi: string | null; - abiError: AppErrorPayload | null; - mods: InstalledMod[]; - blocking: string[]; - unmanaged: string[]; -} - -/** `installed`, `alsoEnabled`, `alsoDisabled` and `missing` are all mod ids. */ -export interface ChangeResult { - report: ProfileReport; - installed: string[]; - alsoEnabled: string[]; - alsoDisabled: string[]; - missing: string[]; -} - -export interface CopyResult extends ChangeResult { - leftBehind: CopyItem[]; -} - -export interface ArchiveInfo { - id: string; - libraries: string[]; - platforms: string[]; - hasAssets: boolean; - entries: number; -} - -export interface FileInspection { - fileName: string; - size: number; - sha256: string; - archive: ArchiveInfo | null; - problem: AppErrorPayload | null; - name: string | null; - summary: string | null; - fitsSystem: boolean; - catalogMatch: { - key: string; - name: string; - fitsVersion: boolean; - build: CatalogVersion; - } | null; - revokedReason: string | null; - replaces: string | null; -} - -export interface ModProgress { - sha256: string; - modId: string; - stage: ProgressStage; - percent: number; -} - -export interface ModFailure { - kind: FailureKind; - modId: string | null; - other: string | null; - line: string; -} - -/** What `install_mod(tag, sha256)` would do, without doing it. */ -export interface InstallPlan { - target: PlanItem; - replaces: { version: string | null; sha256: string; trust: Trust; enabled: boolean } | null; - /** Dependencies that will be downloaded. */ - install: PlanItem[]; - /** Ids of installed dependencies that will be turned on. */ - enable: string[]; - /** Dependency ids with no fitting, non-revoked build. */ - missing: string[]; - /** Set when the install would fail: revoked, incompatible, not for this platform, game running... */ - problem: AppErrorPayload | null; -} - -export interface PlanItem { - id: string; - name: string; - version: string; - sha256: string; - trust: Trust; - channel: Channel; - size: number; -} - -/** What `copy_mod_profile(from, to)` would bring over. */ -export interface CopyPreview { - from: string; - to: string; - items: CopyItem[]; -} - -export interface CopyItem { - id: string; - name: string; - outcome: "install" | "alreadyThere" | "noBuild" | "localFile" | "revoked"; - /** The build that will be installed (outcome "install"). */ - version: string | null; - trust: Trust | null; -} +// The IPC types, generated from the Rust structs into ./bindings (`cargo test export_bindings +// -- --ignored` in src-tauri regenerates them; a test fails while they're out of date). +export type * from "./bindings"; From a073ed3307207ed35444602ce9c9bf1331cc6be8 Mon Sep 17 00:00:00 2001 From: Zorro909 Date: Wed, 4 Nov 2026 21:24:46 +0000 Subject: [PATCH 021/156] Split the index module by job and give the index an age The index module becomes format, signature, cache and fetch. The signing time comes from the minisign trusted comment and is reported as signedAt. Rollback protection compares (generated, signedAt) against the newest pair ever accepted, kept in cache/mod-index.state.json so that deleting the cached index doesn't reset it. An index signed more than 30 days ago is stale but still used, and one dated more than a day ahead is refused. Index versions must now have a semver version, full commit hashes and an RFC 3339 published time, as INDEX_FORMAT already said; long summaries are cut to 140 characters. The test fixtures are re-signed with a new throw-away key so they carry timestamps. --- src-tauri/src/download.rs | 47 ++ src-tauri/src/mods/catalog.rs | 3 + src-tauri/src/mods/doc_tests.rs | 1 + src-tauri/src/mods/index/cache.rs | 463 +++++++++++++++ src-tauri/src/mods/index/fetch.rs | 204 +++++++ .../src/mods/{index.rs => index/format.rs} | 541 ++++-------------- src-tauri/src/mods/index/mod.rs | 24 + src-tauri/src/mods/index/signature.rs | 148 +++++ src-tauri/src/mods/profile.rs | 1 + src-tauri/src/mods/service.rs | 7 +- src-tauri/src/mods/sources.rs | 25 +- src-tauri/src/mods/testdata/index-older.json | 1 + .../mods/testdata/index-older.json.minisig | 4 + .../src/mods/testdata/index.json.minisig | 6 +- .../mods/testdata/index.json.resigned.minisig | 4 + src-tauri/src/mods/testdata/test.pub | 4 +- src/mods/bindings/Freshness.ts | 2 +- src/mods/bindings/IndexStatus.ts | 6 +- 18 files changed, 1038 insertions(+), 453 deletions(-) create mode 100644 src-tauri/src/mods/index/cache.rs create mode 100644 src-tauri/src/mods/index/fetch.rs rename src-tauri/src/mods/{index.rs => index/format.rs} (55%) create mode 100644 src-tauri/src/mods/index/mod.rs create mode 100644 src-tauri/src/mods/index/signature.rs create mode 100644 src-tauri/src/mods/testdata/index-older.json create mode 100644 src-tauri/src/mods/testdata/index-older.json.minisig create mode 100644 src-tauri/src/mods/testdata/index.json.resigned.minisig diff --git a/src-tauri/src/download.rs b/src-tauri/src/download.rs index 4891151..bf13a81 100644 --- a/src-tauri/src/download.rs +++ b/src-tauri/src/download.rs @@ -36,14 +36,61 @@ pub struct Downloaded { pub sha256: String, } +/// Where a redirect may lead: https, on GitHub's own hosts. Release downloads redirect from +/// `github.com` to `objects.githubusercontent.com` or `release-assets.githubusercontent.com`, +/// and GitHub Pages may redirect within `github.io`. +pub fn redirect_allowed(url: &url::Url) -> bool { + let host = url.host_str().unwrap_or(""); + let on = |domain: &str| host == domain || host.ends_with(&format!(".{domain}")); + url.scheme() == "https" + && url.port().is_none() + && url.username().is_empty() + && url.password().is_none() + && (on("github.com") || on("githubusercontent.com") || on("github.io")) +} + +/// Follows at most 10 redirects, each to an allowed place (see [`redirect_allowed`]). +pub fn redirect_policy() -> reqwest::redirect::Policy { + reqwest::redirect::Policy::custom(|attempt| { + if attempt.previous().len() >= 10 { + attempt.error("too many redirects") + } else if redirect_allowed(attempt.url()) { + attempt.follow() + } else { + let to = attempt.url().to_string(); + attempt.error(format!("refused a redirect to {to}")) + } + }) +} + /// No total timeout: a slow connection may take long, as long as data keeps coming. pub fn client() -> Result { reqwest::Client::builder() .connect_timeout(std::time::Duration::from_secs(30)) .read_timeout(std::time::Duration::from_secs(60)) + .redirect(redirect_policy()) .build() } +/// Reads at most `max` bytes of a response, chunk by chunk; `None` if it is longer. Nothing past +/// the limit is read, whatever the response claims its length is. +pub async fn read_capped( + mut response: reqwest::Response, + max: usize, +) -> Result>, reqwest::Error> { + if response.content_length().is_some_and(|n| n > max as u64) { + return Ok(None); + } + let mut bytes = Vec::new(); + while let Some(chunk) = response.chunk().await? { + if bytes.len() + chunk.len() > max { + return Ok(None); + } + bytes.extend_from_slice(&chunk); + } + Ok(Some(bytes)) +} + /// Reports about every 1% (or 100 KB when the size is unknown). `max_bytes` stops a download that /// grows past what was announced. pub async fn to_file( diff --git a/src-tauri/src/mods/catalog.rs b/src-tauri/src/mods/catalog.rs index 1b711db..63b5006 100644 --- a/src-tauri/src/mods/catalog.rs +++ b/src-tauri/src/mods/catalog.rs @@ -103,6 +103,8 @@ pub struct CatalogMod { pub struct IndexStatus { pub freshness: Freshness, pub generated: Option, + /// RFC 3339, from the signature's trusted comment (`timestamp:`). + pub signed_at: Option, pub error: Option, } @@ -325,6 +327,7 @@ pub(crate) mod tests { IndexStatus { freshness: Freshness::Fresh, generated: None, + signed_at: None, error: None, } } diff --git a/src-tauri/src/mods/doc_tests.rs b/src-tauri/src/mods/doc_tests.rs index d573e37..ca52e7e 100644 --- a/src-tauri/src/mods/doc_tests.rs +++ b/src-tauri/src/mods/doc_tests.rs @@ -51,6 +51,7 @@ fn the_index_example_parses_and_keeps_everything() { let status = IndexStatus { freshness: Freshness::Fresh, generated: None, + signed_at: None, error: None, }; let catalog = catalog::build(Some(&index), status, vec![]); diff --git a/src-tauri/src/mods/index/cache.rs b/src-tauri/src/mods/index/cache.rs new file mode 100644 index 0000000..cde7add --- /dev/null +++ b/src-tauri/src/mods/index/cache.rs @@ -0,0 +1,463 @@ +//! The verified index on disk, and how far it can be trusted: how old it is, and whether a +//! fetched one may replace it. +//! +//! - `cache/mod-index//` holds verified generations (index plus signature), checked again on +//! every read, so the catalogue works offline. +//! - `cache/mod-index.state.json` holds the newest (`generated`, `signedAt`) pair ever accepted. +//! It is a separate file so that deleting the cached index doesn't reset rollback protection. + +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use time::format_description::well_known::Rfc3339; +use time::{Duration, OffsetDateTime}; +use ts_rs::TS; + +use super::super::error::ModsError; +use super::super::paths::{unique_suffix, write_atomic}; +use super::format::{Index, generated_at}; +use super::signature::{Signed, verify}; + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq, TS)] +#[serde(rename_all = "lowercase")] +pub enum Freshness { + /// Fetched and verified just now. + Fresh, + /// Verified, but signed more than [`STALE_AFTER`] ago: revocations since then may be missing. + /// The entries are still used. + Stale, + /// The last good copy: not refreshed, or the refresh failed (then an error says why). + Cached, + /// Nothing usable yet. + Unavailable, +} + +/// An index signed longer ago than this is `stale`. The index repository re-signs weekly. +pub const STALE_AFTER: Duration = Duration::days(30); + +/// How far ahead of this computer's clock an index may be dated. Further than that, it would +/// lock out every honest index until that date, so it is refused. +pub const FUTURE_SLACK: Duration = Duration::days(1); + +pub struct IndexState { + pub index: Option, + pub signed_at: Option, + pub freshness: Freshness, + pub error: Option, +} + +/// How new an index is: `generated`, then the signing time. A re-signed index with the same +/// `generated` is newer; an index without a signing time is older than the same one with it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct Age { + pub generated: OffsetDateTime, + pub signed_at: Option, +} + +impl Age { + pub fn of(signed: &Signed) -> Option { + Some(Age { + generated: generated_at(&signed.index)?, + signed_at: signed.signed_at, + }) + } + + /// The time the age is judged by: when it was signed, or else when it was generated. + fn reference(&self) -> OffsetDateTime { + self.signed_at.unwrap_or(self.generated) + } +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct StateFile { + generated: String, + #[serde(default)] + signed_at: Option, +} + +fn rfc3339(t: OffsetDateTime) -> String { + t.format(&Rfc3339).unwrap_or_default() +} + +fn parse_time(s: &str) -> Option { + OffsetDateTime::parse(s, &Rfc3339).ok() +} + +/// Verified index generations: `//index.json` and `index.json.minisig` are written as +/// one directory and renamed into place together, then `/current` names the new one. A crash +/// at any point leaves a complete pair behind: reading tries `current`, then every other +/// generation from the newest, then the single pair older launchers wrote. +pub struct IndexCache { + dir: PathBuf, + legacy: PathBuf, + state: PathBuf, + pub(super) key: String, +} + +const KEEP_GENERATIONS: usize = 2; + +impl IndexCache { + /// `cache_dir` is the launcher's `cache/` folder. + pub fn new(cache_dir: &Path, key: String) -> Self { + IndexCache { + dir: cache_dir.join("mod-index"), + legacy: cache_dir.join("mod-index.json"), + state: cache_dir.join("mod-index.state.json"), + key, + } + } + + pub(super) fn generations(&self) -> Vec { + let mut out: Vec = std::fs::read_dir(&self.dir) + .into_iter() + .flatten() + .flatten() + .filter_map(|e| e.file_name().to_str()?.parse().ok()) + .collect(); + out.sort_unstable_by(|a, b| b.cmp(a)); + out + } + + fn current(&self) -> Option { + std::fs::read_to_string(self.dir.join("current")) + .ok()? + .trim() + .parse() + .ok() + } + + fn read_pair(&self, json: &Path) -> Option { + let bytes = std::fs::read(json).ok()?; + let sig = std::fs::read_to_string(json.with_extension("json.minisig")).ok()?; + // Checked again: the cache is only a file on disk. + verify(&bytes, &sig, &self.key).ok() + } + + /// The newest generation that still verifies, and its number (0 for the old single pair). + pub fn read(&self) -> Option<(u64, Signed)> { + let mut order: Vec = self.current().into_iter().collect(); + order.extend(self.generations()); + order.dedup(); + for generation in order { + let json = self.dir.join(generation.to_string()).join("index.json"); + if let Some(signed) = self.read_pair(&json) { + return Some((generation, signed)); + } + } + self.read_pair(&self.legacy).map(|s| (0, s)) + } + + /// Stores a verified pair as a new generation and drops all but the newest few. + pub fn write(&self, bytes: &[u8], signature: &str) -> Result { + std::fs::create_dir_all(&self.dir)?; + let next = self.generations().first().map_or(1, |n| n + 1); + let staging = self.dir.join(format!(".{next}.{}.tmp", unique_suffix())); + let result = (|| { + std::fs::create_dir_all(&staging)?; + write_atomic(&staging.join("index.json"), bytes)?; + write_atomic(&staging.join("index.json.minisig"), signature.as_bytes())?; + std::fs::rename(&staging, self.dir.join(next.to_string()))?; + write_atomic(&self.dir.join("current"), next.to_string().as_bytes()) + })(); + if result.is_err() { + let _ = std::fs::remove_dir_all(&staging); + } + result?; + for old in self.generations().into_iter().skip(KEEP_GENERATIONS) { + let _ = std::fs::remove_dir_all(self.dir.join(old.to_string())); + } + // Staging folders a crash left behind. + for entry in std::fs::read_dir(&self.dir).into_iter().flatten().flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if name.starts_with('.') && name.ends_with(".tmp") && entry.path().is_dir() { + let _ = std::fs::remove_dir_all(entry.path()); + } + } + Ok(next) + } + + /// The newest age ever accepted, from the state file. + pub fn highest(&self) -> Option { + let bytes = std::fs::read(&self.state).ok()?; + let file: StateFile = serde_json::from_slice(&bytes).ok()?; + Some(Age { + generated: parse_time(&file.generated)?, + signed_at: file.signed_at.as_deref().and_then(parse_time), + }) + } + + /// Records `age` as accepted, unless a newer one already is. + pub fn raise(&self, age: Age) -> Result<(), ModsError> { + if self.highest().is_some_and(|h| h >= age) { + return Ok(()); + } + let file = StateFile { + generated: rfc3339(age.generated), + signed_at: age.signed_at.map(rfc3339), + }; + write_atomic(&self.state, &serde_json::to_vec_pretty(&file)?) + } +} + +/// The state of an index the launcher may use: `stale` when it is old and nothing went wrong. +fn usable( + signed: Signed, + freshness: Freshness, + error: Option, + now: OffsetDateTime, +) -> IndexState { + let old = Age::of(&signed).is_some_and(|a| now - a.reference() > STALE_AFTER); + let freshness = match freshness { + Freshness::Fresh | Freshness::Cached if old && error.is_none() => Freshness::Stale, + f => f, + }; + IndexState { + signed_at: signed.signed_at, + index: Some(signed.index), + freshness, + error, + } +} + +/// The cached copy, or nothing, with the reason the newer one wasn't used. +fn fallback(cached: Option, error: Option, now: OffsetDateTime) -> IndexState { + match cached { + Some(signed) => usable(signed, Freshness::Cached, error, now), + None => IndexState { + index: None, + signed_at: None, + freshness: Freshness::Unavailable, + error, + }, + } +} + +/// The cached copy as it is, without asking for a newer one. +pub fn from_cache(cached: Option, now: OffsetDateTime) -> IndexState { + fallback(cached, None, now) +} + +/// What to do with a freshly fetched index, given the verified copy held and the newest age +/// ever accepted. An index older than either is refused: someone serving an old index must not +/// bring back a revoked mod, even after the cache was deleted. So is one dated in the future. +pub fn decide( + cached: Option, + fetched: Result, + highest: Option, + now: OffsetDateTime, +) -> (IndexState, bool /* store the fetched one */) { + let floor = highest.max(cached.as_ref().and_then(Age::of)); + let new = match fetched { + Err(error) => return (fallback(cached, Some(error), now), false), + Ok(new) => new, + }; + let Some(age) = Age::of(&new) else { + let error = ModsError::IndexInvalid("no generated time".into()); + return (fallback(cached, Some(error), now), false); + }; + if age.generated > now + FUTURE_SLACK || age.signed_at.is_some_and(|s| s > now + FUTURE_SLACK) { + let error = ModsError::IndexInvalid("dated in the future".into()); + return (fallback(cached, Some(error), now), false); + } + if floor.is_some_and(|f| age < f) { + return (fallback(cached, Some(ModsError::IndexRollback), now), false); + } + (usable(new, Freshness::Fresh, None, now), true) +} + +#[cfg(test)] +mod tests { + use super::super::signature::tests::{ + FIXTURE, FIXTURE_KEY, FIXTURE_RESIGNED_SIG, FIXTURE_SIG, OLDER, OLDER_SIG, at, + }; + use super::*; + + fn signed(bytes: &[u8], sig: &str) -> Signed { + verify(bytes, sig, FIXTURE_KEY).unwrap() + } + + fn index_at(generated: &str, signed_at: Option<&str>) -> Signed { + Signed { + index: Index { + schema: 1, + generated: generated.into(), + weft: vec![], + mods: vec![], + revoked: vec![], + }, + signed_at: signed_at.map(at), + } + } + + const NOW: &str = "2026-11-05T00:00:00Z"; + + #[test] + fn an_older_index_is_refused() { + let now = at(NOW); + let held = || Some(index_at("2026-11-04T18:00:00Z", None)); + let (state, store) = decide( + held(), + Ok(index_at("2026-11-01T00:00:00Z", None)), + None, + now, + ); + assert!(!store); + assert_eq!(state.freshness, Freshness::Cached); + assert!(matches!(state.error, Some(ModsError::IndexRollback))); + assert_eq!(state.index.unwrap().generated, "2026-11-04T18:00:00Z"); + + let (state, store) = decide( + held(), + Ok(index_at("2026-11-04T20:00:00Z", None)), + None, + now, + ); + assert!(store); + assert_eq!(state.freshness, Freshness::Fresh); + assert!(state.error.is_none()); + + let (state, store) = decide(held(), Err(ModsError::IndexInvalid("x".into())), None, now); + assert!(!store); + assert_eq!(state.freshness, Freshness::Cached); + assert!(state.index.is_some() && state.error.is_some()); + + let (state, _) = decide(None, Err(ModsError::IndexInvalid("x".into())), None, now); + assert_eq!(state.freshness, Freshness::Unavailable); + } + + /// The pair decides: the same `generated` signed later is newer, signed earlier is older. + #[test] + fn the_signing_time_breaks_ties() { + let now = at(NOW); + let highest = Age::of(&index_at( + "2026-11-04T18:00:00Z", + Some("2026-11-04T18:05:00Z"), + )); + let same_later = index_at("2026-11-04T18:00:00Z", Some("2026-11-04T19:00:00Z")); + let same_earlier = index_at("2026-11-04T18:00:00Z", Some("2026-11-04T18:01:00Z")); + let same_unsigned = index_at("2026-11-04T18:00:00Z", None); + let same_exact = index_at("2026-11-04T18:00:00Z", Some("2026-11-04T18:05:00Z")); + assert!(decide(None, Ok(same_later), highest, now).1); + assert!( + decide(None, Ok(same_exact), highest, now).1, + "fetching the same index again" + ); + for older in [same_earlier, same_unsigned] { + let (state, store) = decide(None, Ok(older), highest, now); + assert!(!store); + assert!(matches!(state.error, Some(ModsError::IndexRollback))); + assert_eq!(state.freshness, Freshness::Unavailable); + } + } + + #[test] + fn an_index_from_the_future_is_refused() { + let now = at(NOW); + for (generated, signed_at) in [ + ("2027-01-01T00:00:00Z", None), + ("2026-11-04T00:00:00Z", Some("2027-01-01T00:00:00Z")), + ] { + let (state, store) = decide(None, Ok(index_at(generated, signed_at)), None, now); + assert!(!store, "{generated}"); + assert!(matches!(state.error, Some(ModsError::IndexInvalid(_)))); + } + // A few hours ahead is only a clock that is off. + let (_, store) = decide(None, Ok(index_at("2026-11-05T06:00:00Z", None)), None, now); + assert!(store); + } + + #[test] + fn an_old_signature_makes_it_stale() { + let fetched = || { + Ok(index_at( + "2026-09-01T00:00:00Z", + Some("2026-09-01T00:00:00Z"), + )) + }; + let (state, store) = decide(None, fetched(), None, at(NOW)); + assert!(store, "a stale index is still used"); + assert_eq!(state.freshness, Freshness::Stale); + assert!(state.index.is_some() && state.error.is_none()); + assert_eq!(state.signed_at, Some(at("2026-09-01T00:00:00Z"))); + + let (state, _) = decide(None, fetched(), None, at("2026-09-20T00:00:00Z")); + assert_eq!(state.freshness, Freshness::Fresh); + + // Without a signing time, `generated` is what counts. + let (state, _) = decide( + None, + Ok(index_at("2026-09-01T00:00:00Z", None)), + None, + at(NOW), + ); + assert_eq!(state.freshness, Freshness::Stale); + + // A cached copy read without refreshing is stale too; one shown because the refresh + // failed stays `cached`, with the error. + let old = || Some(index_at("2026-09-01T00:00:00Z", None)); + assert_eq!(from_cache(old(), at(NOW)).freshness, Freshness::Stale); + let (state, _) = decide(old(), Err(ModsError::IndexRollback), None, at(NOW)); + assert_eq!(state.freshness, Freshness::Cached); + } + + fn cache(dir: &Path) -> IndexCache { + IndexCache::new(dir, FIXTURE_KEY.to_string()) + } + + #[test] + fn the_cache_falls_back_to_the_previous_generation() { + let tmp = tempfile::tempdir().unwrap(); + let c = cache(tmp.path()); + assert!(c.read().is_none()); + assert_eq!(c.write(FIXTURE, FIXTURE_SIG).unwrap(), 1); + assert_eq!(c.write(FIXTURE, FIXTURE_SIG).unwrap(), 2); + assert_eq!(c.read().unwrap().0, 2); + + // The newest pair no longer verifies: the one before it is used. + let newest = tmp.path().join("mod-index").join("2").join("index.json"); + let mut bytes = std::fs::read(&newest).unwrap(); + bytes[10] ^= 1; + std::fs::write(&newest, bytes).unwrap(); + assert_eq!(c.read().unwrap().0, 1); + + // A crash half way through a write: a third generation without its signature, and + // `current` pointing at it. + let third = tmp.path().join("mod-index").join("3"); + std::fs::create_dir_all(&third).unwrap(); + std::fs::write(third.join("index.json"), FIXTURE).unwrap(); + std::fs::write(tmp.path().join("mod-index").join("current"), "3").unwrap(); + assert_eq!(c.read().unwrap().0, 1); + + // Only the newest few are kept. + c.write(FIXTURE, FIXTURE_SIG).unwrap(); + assert_eq!(c.generations(), [4, 3]); + assert_eq!(c.read().unwrap().0, 4); + } + + #[test] + fn the_cache_reads_the_pair_older_launchers_wrote() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("mod-index.json"), FIXTURE).unwrap(); + std::fs::write(tmp.path().join("mod-index.json.minisig"), FIXTURE_SIG).unwrap(); + assert_eq!(cache(tmp.path()).read().unwrap().0, 0); + std::fs::write(tmp.path().join("mod-index.json.minisig"), "junk").unwrap(); + assert!(cache(tmp.path()).read().is_none()); + } + + #[test] + fn the_state_file_only_moves_forward() { + let tmp = tempfile::tempdir().unwrap(); + let c = cache(tmp.path()); + assert!(c.highest().is_none()); + let newer = Age::of(&signed(FIXTURE, FIXTURE_SIG)).unwrap(); + let older = Age::of(&signed(OLDER, OLDER_SIG)).unwrap(); + let resigned = Age::of(&signed(FIXTURE, FIXTURE_RESIGNED_SIG)).unwrap(); + assert!(older < newer && newer < resigned); + c.raise(newer).unwrap(); + c.raise(older).unwrap(); + assert_eq!(c.highest(), Some(newer)); + c.raise(resigned).unwrap(); + assert_eq!(c.highest(), Some(resigned)); + assert!(tmp.path().join("mod-index.state.json").exists()); + } +} diff --git a/src-tauri/src/mods/index/fetch.rs b/src-tauri/src/mods/index/fetch.rs new file mode 100644 index 0000000..47fbba4 --- /dev/null +++ b/src-tauri/src/mods/index/fetch.rs @@ -0,0 +1,204 @@ +//! Getting the index: from GitHub Pages when asked to refresh, else from the cache. + +use std::time::Duration; +use time::OffsetDateTime; + +use super::super::error::ModsError; +use super::cache::{Age, IndexCache, IndexState, decide, from_cache}; +use super::signature::verify; + +pub const INDEX_URL: &str = "https://zorro909.github.io/weft-mods-index/v1/index.json"; + +pub const MAX_INDEX_BYTES: usize = 8 * 1024 * 1024; + +pub fn index_url() -> String { + #[cfg(debug_assertions)] + if let Ok(url) = std::env::var("WEFT_MOD_INDEX_URL") { + return url; + } + INDEX_URL.to_string() +} + +pub async fn fetch_bytes(client: &reqwest::Client, url: &str) -> Result, ModsError> { + let response = client + .get(url) + .timeout(Duration::from_secs(15)) + .send() + .await?; + let status = response.status().as_u16(); + if status != 200 { + return Err(ModsError::Http { + status, + url: url.to_string(), + }); + } + crate::download::read_capped(response, MAX_INDEX_BYTES) + .await? + .ok_or_else(|| ModsError::IndexInvalid("too large".into())) +} + +fn remember(cache: &IndexCache, age: Option) { + if let Some(age) = age + && let Err(e) = cache.raise(age) + { + eprintln!("mod index: could not record its age: {e}"); + } +} + +/// The newest index this launcher can trust. With `refresh` false only the cache is read, and +/// nothing touches the network. `fetch` returns the index bytes and their signature. +pub async fn load(cache: &IndexCache, refresh: bool, now: OffsetDateTime, fetch: F) -> IndexState +where + F: std::future::Future, String), ModsError>>, +{ + let cached = cache.read().map(|(_, s)| s); + // A cache written before the state file existed still counts as accepted. + remember(cache, cached.as_ref().and_then(Age::of)); + if !refresh { + return from_cache(cached, now); + } + let fetched = fetch.await.and_then(|(bytes, sig)| { + let signed = verify(&bytes, &sig, &cache.key)?; + Ok((bytes, sig, signed)) + }); + let (raw, fetched) = match fetched { + Ok((bytes, sig, signed)) => (Some((bytes, sig)), Ok(signed)), + Err(e) => (None, Err(e)), + }; + let new_age = fetched.as_ref().ok().and_then(Age::of); + let (state, store) = decide(cached, fetched, cache.highest(), now); + if store && let Some((bytes, sig)) = raw { + if let Err(e) = cache.write(&bytes, &sig) { + eprintln!("mod index: could not cache: {e}"); + } + remember(cache, new_age); + } + state +} + +#[cfg(test)] +mod tests { + use super::super::cache::Freshness; + use super::super::signature::tests::{ + FIXTURE, FIXTURE_KEY, FIXTURE_RESIGNED_SIG, FIXTURE_SIG, OLDER, OLDER_SIG, at, + }; + use super::*; + use std::path::Path; + + fn run(fut: F) -> F::Output { + tokio::runtime::Builder::new_current_thread() + .build() + .unwrap() + .block_on(fut) + } + + fn cache(dir: &Path) -> IndexCache { + IndexCache::new(dir, FIXTURE_KEY.to_string()) + } + + const NOW: &str = "2026-11-05T00:00:00Z"; + + fn serve( + bytes: &[u8], + sig: &str, + ) -> impl Future, String), ModsError>> { + let answer = (bytes.to_vec(), sig.to_string()); + async move { Ok(answer) } + } + + #[test] + fn load_keeps_the_cache_when_refresh_fails() { + let tmp = tempfile::tempdir().unwrap(); + let c = cache(tmp.path()); + c.write(FIXTURE, FIXTURE_SIG).unwrap(); + let state = run(load(&c, true, at(NOW), serve(FIXTURE, "bad signature"))); + assert_eq!(state.freshness, Freshness::Cached); + assert!(matches!( + state.error, + Some(ModsError::IndexSignatureInvalid(_)) + )); + assert!(state.index.is_some()); + assert_eq!(c.generations(), [1], "nothing new was stored"); + + let state = run(load(&c, true, at(NOW), serve(FIXTURE, FIXTURE_SIG))); + assert_eq!(state.freshness, Freshness::Fresh); + assert_eq!(state.signed_at, Some(at("2026-11-04T18:05:00Z"))); + assert_eq!(c.generations(), [2, 1]); + } + + #[test] + fn without_refresh_nothing_is_fetched() { + let tmp = tempfile::tempdir().unwrap(); + let c = cache(tmp.path()); + type Fetch = std::pin::Pin, String), ModsError>>>>; + let never: Fetch = Box::pin(async { panic!("fetched without being asked to") }); + let state = run(load(&c, false, at(NOW), never)); + assert_eq!(state.freshness, Freshness::Unavailable); + assert!(state.error.is_none()); + } + + /// Deleting the cached index doesn't reset rollback protection: the state file remembers. + #[test] + fn rollback_protection_survives_a_deleted_cache() { + let tmp = tempfile::tempdir().unwrap(); + let c = cache(tmp.path()); + let state = run(load(&c, true, at(NOW), serve(FIXTURE, FIXTURE_SIG))); + assert_eq!(state.freshness, Freshness::Fresh); + + std::fs::remove_dir_all(tmp.path().join("mod-index")).unwrap(); + let state = run(load(&c, true, at(NOW), serve(OLDER, OLDER_SIG))); + assert_eq!(state.freshness, Freshness::Unavailable); + assert!(matches!(state.error, Some(ModsError::IndexRollback))); + assert!(c.read().is_none(), "the older index was not stored"); + + // The newer one, and the same one signed again, still are. + let state = run(load(&c, true, at(NOW), serve(FIXTURE, FIXTURE_SIG))); + assert_eq!(state.freshness, Freshness::Fresh); + let state = run(load( + &c, + true, + at("2026-11-12T00:00:00Z"), + serve(FIXTURE, FIXTURE_RESIGNED_SIG), + )); + assert_eq!(state.freshness, Freshness::Fresh); + assert_eq!(state.signed_at, Some(at("2026-11-11T18:00:00Z"))); + + // Now the first signature of those bytes is the older one. + std::fs::remove_dir_all(tmp.path().join("mod-index")).unwrap(); + let state = run(load(&c, true, at(NOW), serve(FIXTURE, FIXTURE_SIG))); + assert!(matches!(state.error, Some(ModsError::IndexRollback))); + } + + /// A cache from before the state file existed sets the floor the first time it is read. + #[test] + fn an_existing_cache_sets_the_floor() { + let tmp = tempfile::tempdir().unwrap(); + let c = cache(tmp.path()); + c.write(FIXTURE, FIXTURE_SIG).unwrap(); + assert!(c.highest().is_none()); + let state = run(load(&c, false, at(NOW), serve(OLDER, OLDER_SIG))); + assert_eq!(state.freshness, Freshness::Cached); + assert!(c.highest().is_some()); + std::fs::remove_dir_all(tmp.path().join("mod-index")).unwrap(); + let state = run(load(&c, true, at(NOW), serve(OLDER, OLDER_SIG))); + assert!(matches!(state.error, Some(ModsError::IndexRollback))); + } + + #[test] + fn an_old_cache_is_stale_and_still_used() { + let tmp = tempfile::tempdir().unwrap(); + let c = cache(tmp.path()); + c.write(FIXTURE, FIXTURE_SIG).unwrap(); + let later = at("2026-12-10T00:00:00Z"); + let state = run(load(&c, false, later, serve(FIXTURE, FIXTURE_SIG))); + assert_eq!(state.freshness, Freshness::Stale); + assert!(state.index.is_some()); + let state = run(load(&c, true, later, serve(FIXTURE, FIXTURE_SIG))); + assert_eq!( + state.freshness, + Freshness::Stale, + "fetched, but signed long ago" + ); + assert!(state.error.is_none()); + } +} diff --git a/src-tauri/src/mods/index.rs b/src-tauri/src/mods/index/format.rs similarity index 55% rename from src-tauri/src/mods/index.rs rename to src-tauri/src/mods/index/format.rs index df52dcb..f04a379 100644 --- a/src-tauri/src/mods/index.rs +++ b/src-tauri/src/mods/index/format.rs @@ -1,26 +1,14 @@ -//! The curated mod index: `index.json` plus its minisign signature, as described in -//! `docs/mod-manager/INDEX_FORMAT.md`. Only an index signed with the key below is used. The last -//! one that verified is kept, so the catalogue works offline and an older index can't replace it. +//! The format of `index.json` and the rules every entry must follow, as described in +//! `docs/mod-manager/INDEX_FORMAT.md`. Nothing here checks a signature or touches the disk. -use minisign_verify::{PublicKey, Signature}; use serde::de::DeserializeOwned; use serde::{Deserialize, Deserializer, Serialize}; -use std::path::{Path, PathBuf}; -use std::time::Duration; use time::OffsetDateTime; use time::format_description::well_known::Rfc3339; use ts_rs::TS; -use super::abi::{is_abi, is_safe_ref}; -use super::error::ModsError; -use super::paths::{unique_suffix, write_atomic}; - -pub const INDEX_URL: &str = "https://zorro909.github.io/weft-mods-index/v1/index.json"; - -/// `keys/index.pub` of the index repository. -pub const INDEX_PUBLIC_KEY: &str = "RWRelY1XUps7n+bc3RVDFwMAWzPYz4orZvxzRTo0M+hK2A0w02cQmfvh"; - -pub const MAX_INDEX_BYTES: usize = 8 * 1024 * 1024; +use super::super::abi::{is_abi, is_safe_ref}; +use super::super::error::ModsError; /// Reads a list one entry at a time and leaves out the entries that don't parse, so one bad /// entry (an unknown channel, a missing field) never hides the rest. @@ -166,12 +154,12 @@ pub struct IndexVersion { impl IndexVersion { /// The platforms this launcher knows, in the entry's order. pub fn known_platforms(&self) -> Vec { - let mut out: Vec = self - .platforms - .iter() - .filter_map(|p| Platform::parse(p)) - .collect(); - out.dedup(); + let mut out: Vec = Vec::new(); + for p in self.platforms.iter().filter_map(|p| Platform::parse(p)) { + if !out.contains(&p) { + out.push(p); + } + } out } } @@ -319,11 +307,69 @@ pub fn asset_url_problem(id: &str, repo: &str, tag: &str, url: &str) -> Option<& } } +/// A full git commit hash: 40 lowercase hex characters, or 64 in a SHA-256 repository. +pub fn is_commit(s: &str) -> bool { + (s.len() == 40 || s.len() == 64) && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) +} + +/// `MAJOR.MINOR.PATCH`, then optionally `-prerelease` and `+build`: dot-separated parts of +/// `0-9 A-Z a-z -`, none empty. +pub fn is_semver(s: &str) -> bool { + let (rest, build) = match s.split_once('+') { + Some((r, b)) => (r, Some(b)), + None => (s, None), + }; + let (core, pre) = match rest.split_once('-') { + Some((c, p)) => (c, Some(p)), + None => (rest, None), + }; + let ident = |part: &str| { + !part.is_empty() && part.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') + }; + let dotted = |s: &str| s.split('.').all(ident); + let numbers: Vec<&str> = core.split('.').collect(); + numbers.len() == 3 + && numbers + .iter() + .all(|n| !n.is_empty() && n.len() <= 18 && n.bytes().all(|b| b.is_ascii_digit())) + && pre.is_none_or(dotted) + && build.is_none_or(dotted) +} + +pub fn is_rfc3339(s: &str) -> bool { + OffsetDateTime::parse(s, &Rfc3339).is_ok() +} + +/// What the launcher shows of a summary; longer ones are cut, not refused. +pub const MAX_SUMMARY_CHARS: usize = 140; + +pub fn clamp_summary(summary: &mut String) { + let line = summary.lines().next().unwrap_or("").trim(); + *summary = if line.chars().count() > MAX_SUMMARY_CHARS { + let cut: String = line.chars().take(MAX_SUMMARY_CHARS - 1).collect(); + format!("{}…", cut.trim_end()) + } else { + line.to_string() + }; +} + /// The contract's rules for one version of one mod. A version that breaks them is left out. pub fn version_problem(id: &str, v: &IndexVersion) -> Option { + if !is_semver(&v.version) { + return Some(format!("version {:?}", v.version)); + } if !is_repo(&v.source.repo) { return Some(format!("source repo {:?}", v.source.repo)); } + if !is_commit(&v.source.commit) { + return Some(format!("source commit {:?}", v.source.commit)); + } + if !v.weft.commit.is_empty() && !is_commit(&v.weft.commit) { + return Some(format!("weft commit {:?}", v.weft.commit)); + } + if !v.published.is_empty() && !is_rfc3339(&v.published) { + return Some(format!("published {:?}", v.published)); + } if !is_safe_ref(&v.tag) { return Some(format!("tag {:?}", v.tag)); } @@ -373,6 +419,7 @@ pub fn sanitize(mut index: Index) -> Index { }); m.icon = m.icon.take().filter(|u| u.starts_with("https://")); m.homepage = m.homepage.take().filter(|u| u.starts_with("https://")); + clamp_summary(&mut m.summary); } index.mods.retain(|m| !m.versions.is_empty()); index.weft.retain(|w| is_safe_ref(&w.tag) && is_abi(&w.abi)); @@ -380,7 +427,8 @@ pub fn sanitize(mut index: Index) -> Index { index } -/// Reads index bytes whose signature already verified. +/// Reads index bytes whose signature already verified: `schema` 1 and an RFC 3339 `generated`, +/// or the whole index is refused; any other bad entry costs only that entry. pub fn parse(bytes: &[u8]) -> Result { let index: Index = serde_json::from_slice(bytes).map_err(|e| ModsError::IndexInvalid(e.to_string()))?; @@ -396,254 +444,10 @@ pub fn parse(bytes: &[u8]) -> Result { Ok(sanitize(index)) } -pub fn verify(bytes: &[u8], signature: &str, public_key: &str) -> Result { - let key = PublicKey::from_base64(public_key.trim()) - .or_else(|_| PublicKey::decode(public_key)) - .map_err(|e| ModsError::IndexSignatureInvalid(format!("public key: {e}")))?; - let signature = Signature::decode(signature) - .map_err(|e| ModsError::IndexSignatureInvalid(e.to_string()))?; - key.verify(bytes, &signature, false) - .map_err(|e| ModsError::IndexSignatureInvalid(e.to_string()))?; - parse(bytes) -} - -fn generated_at(index: &Index) -> Option { +pub fn generated_at(index: &Index) -> Option { OffsetDateTime::parse(&index.generated, &Rfc3339).ok() } -#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq, TS)] -#[serde(rename_all = "lowercase")] -pub enum Freshness { - /// Fetched and verified just now. - Fresh, - /// The last good copy: not refreshed, or the refresh failed (then an error says why). - Cached, - /// Nothing usable yet. - Unavailable, -} - -pub struct IndexState { - pub index: Option, - pub freshness: Freshness, - pub error: Option, -} - -pub fn public_key() -> String { - #[cfg(debug_assertions)] - if let Ok(key) = std::env::var("WEFT_MOD_INDEX_PUBKEY") { - return key; - } - INDEX_PUBLIC_KEY.to_string() -} - -pub fn index_url() -> String { - #[cfg(debug_assertions)] - if let Ok(url) = std::env::var("WEFT_MOD_INDEX_URL") { - return url; - } - INDEX_URL.to_string() -} - -// --------------------------------------------------------------------------------------------- -// The verified copy on disk - -/// Verified index generations: `//index.json` and `index.json.minisig` are written as -/// one directory and renamed into place together, then `/current` names the new one. A crash -/// at any point leaves a complete pair behind: reading tries `current`, then every other -/// generation from the newest, then the single pair older launchers wrote. -pub struct IndexCache { - dir: PathBuf, - legacy: PathBuf, - key: String, -} - -const KEEP_GENERATIONS: usize = 2; - -impl IndexCache { - /// `cache_dir` is the launcher's `cache/` folder. - pub fn new(cache_dir: &Path, key: String) -> Self { - IndexCache { - dir: cache_dir.join("mod-index"), - legacy: cache_dir.join("mod-index.json"), - key, - } - } - - fn generations(&self) -> Vec { - let mut out: Vec = std::fs::read_dir(&self.dir) - .into_iter() - .flatten() - .flatten() - .filter_map(|e| e.file_name().to_str()?.parse().ok()) - .collect(); - out.sort_unstable_by(|a, b| b.cmp(a)); - out - } - - fn current(&self) -> Option { - std::fs::read_to_string(self.dir.join("current")) - .ok()? - .trim() - .parse() - .ok() - } - - fn read_pair(&self, json: &Path) -> Option { - let bytes = std::fs::read(json).ok()?; - let sig = std::fs::read_to_string(json.with_extension("json.minisig")).ok()?; - // Checked again: the cache is only a file on disk. - verify(&bytes, &sig, &self.key).ok() - } - - /// The newest generation that still verifies, and its number (0 for the old single pair). - pub fn read(&self) -> Option<(u64, Index)> { - let mut order: Vec = self.current().into_iter().collect(); - order.extend(self.generations()); - order.dedup(); - for generation in order { - let json = self.dir.join(generation.to_string()).join("index.json"); - if let Some(index) = self.read_pair(&json) { - return Some((generation, index)); - } - } - self.read_pair(&self.legacy).map(|i| (0, i)) - } - - /// Stores a verified pair as a new generation and drops all but the newest few. - pub fn write(&self, bytes: &[u8], signature: &str) -> Result { - std::fs::create_dir_all(&self.dir)?; - let next = self.generations().first().map_or(1, |n| n + 1); - let staging = self.dir.join(format!(".{next}.{}.tmp", unique_suffix())); - let result = (|| { - std::fs::create_dir_all(&staging)?; - write_atomic(&staging.join("index.json"), bytes)?; - write_atomic(&staging.join("index.json.minisig"), signature.as_bytes())?; - std::fs::rename(&staging, self.dir.join(next.to_string()))?; - write_atomic(&self.dir.join("current"), next.to_string().as_bytes()) - })(); - if result.is_err() { - let _ = std::fs::remove_dir_all(&staging); - } - result?; - for old in self.generations().into_iter().skip(KEEP_GENERATIONS) { - let _ = std::fs::remove_dir_all(self.dir.join(old.to_string())); - } - // Staging folders a crash left behind. - for entry in std::fs::read_dir(&self.dir).into_iter().flatten().flatten() { - let name = entry.file_name().to_string_lossy().to_string(); - if name.starts_with('.') && name.ends_with(".tmp") && entry.path().is_dir() { - let _ = std::fs::remove_dir_all(entry.path()); - } - } - Ok(next) - } -} - -/// What to do with a freshly fetched index, given the verified copy already held. An index -/// older than that copy is refused: someone serving an old index must not bring back a -/// revoked mod. -pub fn decide( - cached: Option, - fetched: Result, -) -> (IndexState, bool /* store the fetched one */) { - let fallback = |cached: Option, error| { - let freshness = if cached.is_some() { - Freshness::Cached - } else { - Freshness::Unavailable - }; - IndexState { - index: cached, - freshness, - error: Some(error), - } - }; - match fetched { - Err(error) => (fallback(cached, error), false), - Ok(index) => { - let older = cached.as_ref().is_some_and(|old| { - match (generated_at(&index), generated_at(old)) { - (Some(new_at), Some(old_at)) => new_at < old_at, - (None, Some(_)) => true, - _ => false, - } - }); - if older { - return (fallback(cached, ModsError::IndexRollback), false); - } - let state = IndexState { - index: Some(index), - freshness: Freshness::Fresh, - error: None, - }; - (state, true) - } - } -} - -pub async fn fetch_bytes(client: &reqwest::Client, url: &str) -> Result, ModsError> { - let mut response = client - .get(url) - .timeout(Duration::from_secs(15)) - .send() - .await?; - let status = response.status().as_u16(); - if status != 200 { - return Err(ModsError::Http { - status, - url: url.to_string(), - }); - } - if response.content_length().unwrap_or(0) as usize > MAX_INDEX_BYTES { - return Err(ModsError::IndexInvalid("too large".into())); - } - let mut bytes = Vec::new(); - while let Some(chunk) = response.chunk().await? { - if bytes.len() + chunk.len() > MAX_INDEX_BYTES { - return Err(ModsError::IndexInvalid("too large".into())); - } - bytes.extend_from_slice(&chunk); - } - Ok(bytes) -} - -/// The newest index this launcher can trust. With `refresh` false only the cache is read. -/// `fetch` returns the index bytes and their signature. -pub async fn load(cache: &IndexCache, refresh: bool, fetch: F) -> IndexState -where - F: std::future::Future, String), ModsError>>, -{ - let cached = cache.read().map(|(_, i)| i); - if !refresh { - let freshness = if cached.is_some() { - Freshness::Cached - } else { - Freshness::Unavailable - }; - return IndexState { - index: cached, - freshness, - error: None, - }; - } - let fetched = fetch.await.and_then(|(bytes, sig)| { - let index = verify(&bytes, &sig, &cache.key)?; - Ok((bytes, sig, index)) - }); - let (raw, fetched) = match fetched { - Ok((bytes, sig, index)) => (Some((bytes, sig)), Ok(index)), - Err(e) => (None, Err(e)), - }; - let (state, store) = decide(cached, fetched); - if store - && let Some((bytes, sig)) = raw - && let Err(e) = cache.write(&bytes, &sig) - { - eprintln!("mod index: could not cache: {e}"); - } - state -} - #[cfg(test)] pub(crate) mod tests { use super::*; @@ -705,6 +509,12 @@ pub(crate) mod tests { }), ("abi shape", |v| v.weft.abi = "F00".into()), ("dep id", |v| v.deps = vec![Dep { id: "A-B".into() }]), + ("version not semver", |v| v.version = "0.2".into()), + ("version with junk", |v| v.version = "0.2.0-rc..1".into()), + ("short commit", |v| v.source.commit = "cceea78".into()), + ("commit in capitals", |v| v.source.commit = "C".repeat(40)), + ("short weft commit", |v| v.weft.commit = "ff9c712".into()), + ("published not a date", |v| v.published = "yesterday".into()), ]; for (what, change) in cases { let mut v = ok(); @@ -834,168 +644,43 @@ pub(crate) mod tests { assert!(parse(br#"{ "schema": 1, "generated": "2026-11-04T00:00:00Z" }"#).is_ok()); } - // Signed with a throw-away key made for these tests (`minisign -G -W`); the private half was - // never kept. The real key belongs to the index repository. - pub const FIXTURE: &[u8] = include_bytes!("testdata/index.json"); - pub const FIXTURE_SIG: &str = include_str!("testdata/index.json.minisig"); - pub const FIXTURE_KEY: &str = include_str!("testdata/test.pub"); - - #[test] - fn a_signed_index_verifies() { - let index = verify(FIXTURE, FIXTURE_SIG, FIXTURE_KEY).unwrap(); - assert_eq!(index.mods[0].id, "cogwork"); - assert_eq!(index.weft[0].abi, "f470b1cbb1f9ad91"); - // The bare base64 line works too, as embedded in the launcher. - let key_line = FIXTURE_KEY.lines().nth(1).unwrap(); - assert!(verify(FIXTURE, FIXTURE_SIG, key_line).is_ok()); - } - - #[test] - fn any_change_breaks_the_signature() { - let mut tampered = FIXTURE.to_vec(); - let at = tampered.windows(7).position(|w| w == b"Cogwork").unwrap(); - tampered[at] = b'K'; - assert!(matches!( - verify(&tampered, FIXTURE_SIG, FIXTURE_KEY), - Err(ModsError::IndexSignatureInvalid(_)) - )); - assert!(matches!( - verify(FIXTURE, "not a signature", FIXTURE_KEY), - Err(ModsError::IndexSignatureInvalid(_)) - )); - } - - /// A copy of the live index, signed by the real key: the embedded key is the right - /// one, and nothing the index publishes is dropped by the contract checks here. - #[test] - fn the_published_index_verifies_and_keeps_every_entry() { - let bytes = include_bytes!("testdata/published-index.json"); - let sig = include_str!("testdata/published-index.json.minisig"); - let index = verify(bytes, sig, INDEX_PUBLIC_KEY).unwrap(); - let raw: serde_json::Value = serde_json::from_slice(bytes).unwrap(); - let raw_count: usize = raw["mods"] - .as_array() - .unwrap() - .iter() - .map(|m| m["versions"].as_array().unwrap().len()) - .sum(); - let count = index.mods.iter().map(|m| m.versions.len()).sum::(); - assert!(raw_count > 0); - assert_eq!(count, raw_count); - assert_eq!(index.weft.len(), raw["weft"].as_array().unwrap().len()); - } - #[test] - fn another_key_is_refused() { - assert!(matches!( - verify(FIXTURE, FIXTURE_SIG, INDEX_PUBLIC_KEY), - Err(ModsError::IndexSignatureInvalid(_)) - )); - } - - fn index_at(generated: &str) -> Index { - Index { - schema: 1, - generated: generated.into(), - weft: vec![], - mods: vec![], - revoked: vec![], + fn versions_commits_and_summaries() { + for good in [ + "0.2.0", + "0.2.1-rc.1", + "0.1.2-experimental.4563878.917596d", + "1.0.0+build.5", + ] { + assert!(is_semver(good), "{good}"); } - } - - #[test] - fn an_older_index_is_refused() { - let held = || Some(index_at("2026-11-04T18:00:00Z")); - let (state, store) = decide(held(), Ok(index_at("2026-11-01T00:00:00Z"))); - assert!(!store); - assert_eq!(state.freshness, Freshness::Cached); - assert!(matches!(state.error, Some(ModsError::IndexRollback))); - assert_eq!(state.index.unwrap().generated, "2026-11-04T18:00:00Z"); - - let (state, store) = decide(held(), Ok(index_at("2026-11-05T00:00:00Z"))); - assert!(store); - assert_eq!(state.freshness, Freshness::Fresh); - assert!(state.error.is_none()); - - let (state, store) = decide(held(), Err(ModsError::IndexInvalid("x".into()))); - assert!(!store); - assert_eq!(state.freshness, Freshness::Cached); - assert!(state.index.is_some() && state.error.is_some()); - - let (state, _) = decide(None, Err(ModsError::IndexInvalid("x".into()))); - assert_eq!(state.freshness, Freshness::Unavailable); - } - - fn cache(dir: &Path) -> IndexCache { - IndexCache::new(dir, FIXTURE_KEY.to_string()) - } - - #[test] - fn the_cache_falls_back_to_the_previous_generation() { - let tmp = tempfile::tempdir().unwrap(); - let c = cache(tmp.path()); - assert!(c.read().is_none()); - assert_eq!(c.write(FIXTURE, FIXTURE_SIG).unwrap(), 1); - assert_eq!(c.write(FIXTURE, FIXTURE_SIG).unwrap(), 2); - assert_eq!(c.read().unwrap().0, 2); - - // The newest pair no longer verifies: the one before it is used. - let newest = tmp.path().join("mod-index").join("2").join("index.json"); - let mut bytes = std::fs::read(&newest).unwrap(); - bytes[10] ^= 1; - std::fs::write(&newest, bytes).unwrap(); - assert_eq!(c.read().unwrap().0, 1); - - // A crash half way through a write: a third generation without its signature, and - // `current` pointing at it. - let third = tmp.path().join("mod-index").join("3"); - std::fs::create_dir_all(&third).unwrap(); - std::fs::write(third.join("index.json"), FIXTURE).unwrap(); - std::fs::write(tmp.path().join("mod-index").join("current"), "3").unwrap(); - assert_eq!(c.read().unwrap().0, 1); - - // Only the newest few are kept. - c.write(FIXTURE, FIXTURE_SIG).unwrap(); - assert_eq!(c.generations(), [4, 3]); - assert_eq!(c.read().unwrap().0, 4); - } - - #[test] - fn the_cache_reads_the_pair_older_launchers_wrote() { - let tmp = tempfile::tempdir().unwrap(); - std::fs::write(tmp.path().join("mod-index.json"), FIXTURE).unwrap(); - std::fs::write(tmp.path().join("mod-index.json.minisig"), FIXTURE_SIG).unwrap(); - assert_eq!(cache(tmp.path()).read().unwrap().0, 0); - std::fs::write(tmp.path().join("mod-index.json.minisig"), "junk").unwrap(); - assert!(cache(tmp.path()).read().is_none()); - } - - #[test] - fn load_keeps_the_cache_when_refresh_fails() { - let tmp = tempfile::tempdir().unwrap(); - let c = cache(tmp.path()); - c.write(FIXTURE, FIXTURE_SIG).unwrap(); - fn run(fut: F) -> F::Output { - tokio::runtime::Builder::new_current_thread() - .build() - .unwrap() - .block_on(fut) + for bad in [ + "", + "0.2", + "v0.2.0", + "0.2.0.1", + "0.2.0-", + "0.2.0-rc..1", + "a.b.c", + "0.2.0 ", + ] { + assert!(!is_semver(bad), "{bad:?}"); } - let state = run(load(&c, true, async { - Ok((FIXTURE.to_vec(), "bad signature".to_string())) - })); - assert_eq!(state.freshness, Freshness::Cached); - assert!(matches!( - state.error, - Some(ModsError::IndexSignatureInvalid(_)) - )); - assert!(state.index.is_some()); - assert_eq!(c.generations(), [1], "nothing new was stored"); - - let state = run(load(&c, true, async { - Ok((FIXTURE.to_vec(), FIXTURE_SIG.to_string())) - })); - assert_eq!(state.freshness, Freshness::Fresh); - assert_eq!(c.generations(), [2, 1]); + assert!(is_commit(&"a".repeat(40)) && is_commit(&"a".repeat(64))); + assert!(!is_commit(&"a".repeat(41)) && !is_commit("cceea78")); + + // An optional weft commit may be left out; an empty `published` too. + let mut v = version("cogwork", "Zorro909/weft-mods", &"a".repeat(64)); + v.weft.commit = String::new(); + v.published = String::new(); + assert!(version_problem("cogwork", &v).is_none()); + + let mut long = "x".repeat(MAX_SUMMARY_CHARS + 10); + clamp_summary(&mut long); + assert_eq!(long.chars().count(), MAX_SUMMARY_CHARS); + assert!(long.ends_with('…')); + let mut two_lines = "First line.\nSecond line.".to_string(); + clamp_summary(&mut two_lines); + assert_eq!(two_lines, "First line."); } } diff --git a/src-tauri/src/mods/index/mod.rs b/src-tauri/src/mods/index/mod.rs new file mode 100644 index 0000000..d456618 --- /dev/null +++ b/src-tauri/src/mods/index/mod.rs @@ -0,0 +1,24 @@ +//! The curated mod index: `index.json` plus its minisign signature, as described in +//! `docs/mod-manager/INDEX_FORMAT.md`. Only an index signed with the embedded key is used. The +//! last one that verified is kept, so the catalogue works offline, and an older one than any +//! accepted before is refused. +//! +//! - `format.rs`: the JSON and the rules each entry must follow; +//! - `signature.rs`: the signature, and when it was made; +//! - `cache.rs`: the verified copies on disk, rollback protection and staleness; +//! - `fetch.rs`: getting it from the network, or not. + +mod cache; +mod fetch; +mod format; +mod signature; + +pub use cache::{Age, FUTURE_SLACK, Freshness, IndexCache, IndexState, STALE_AFTER, decide}; +pub use fetch::{INDEX_URL, MAX_INDEX_BYTES, fetch_bytes, index_url, load}; +pub use format::*; +pub use signature::{INDEX_PUBLIC_KEY, Signed, public_key, signed_at, verify}; + +#[cfg(test)] +pub(crate) mod tests { + pub use super::format::tests::*; +} diff --git a/src-tauri/src/mods/index/signature.rs b/src-tauri/src/mods/index/signature.rs new file mode 100644 index 0000000..461aebf --- /dev/null +++ b/src-tauri/src/mods/index/signature.rs @@ -0,0 +1,148 @@ +//! The minisign signature over `index.json`. Only an index signed with the embedded key is used; +//! the signature's trusted comment, which the key also signs, says when it was signed. + +use minisign_verify::{PublicKey, Signature}; +use time::OffsetDateTime; + +use super::super::error::ModsError; +use super::format::{Index, parse}; + +/// `keys/index.pub` of the index repository. +pub const INDEX_PUBLIC_KEY: &str = "RWRelY1XUps7n+bc3RVDFwMAWzPYz4orZvxzRTo0M+hK2A0w02cQmfvh"; + +pub fn public_key() -> String { + #[cfg(debug_assertions)] + if let Ok(key) = std::env::var("WEFT_MOD_INDEX_PUBKEY") { + return key; + } + INDEX_PUBLIC_KEY.to_string() +} + +/// An index whose signature verified, and when it was signed. +#[derive(Debug, Clone)] +pub struct Signed { + pub index: Index, + /// From the trusted comment's `timestamp:`, which `minisign -S` writes by + /// default. `None` when the comment has none. + pub signed_at: Option, +} + +/// The `timestamp:` of a trusted comment such as `timestamp:1793819114 file:index.json`. +pub fn signed_at(trusted_comment: &str) -> Option { + trusted_comment + .split(['\t', ' ']) + .find_map(|part| part.strip_prefix("timestamp:")) + .and_then(|secs| secs.parse::().ok()) + .and_then(|secs| OffsetDateTime::from_unix_timestamp(secs).ok()) +} + +pub fn verify(bytes: &[u8], signature: &str, public_key: &str) -> Result { + let key = PublicKey::from_base64(public_key.trim()) + .or_else(|_| PublicKey::decode(public_key)) + .map_err(|e| ModsError::IndexSignatureInvalid(format!("public key: {e}")))?; + let signature = Signature::decode(signature) + .map_err(|e| ModsError::IndexSignatureInvalid(e.to_string()))?; + key.verify(bytes, &signature, false) + .map_err(|e| ModsError::IndexSignatureInvalid(e.to_string()))?; + Ok(Signed { + index: parse(bytes)?, + signed_at: signed_at(signature.trusted_comment()), + }) +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + + // Signed with a throw-away key made for these tests (`minisign -G -W`); the private half was + // never kept. The real key belongs to the index repository. + pub const FIXTURE: &[u8] = include_bytes!("../testdata/index.json"); + /// Signed 2026-11-04T18:05:00Z; `generated` is 2026-11-04T18:00:00Z. + pub const FIXTURE_SIG: &str = include_str!("../testdata/index.json.minisig"); + /// The same bytes signed again a week later, as the index repository does. + pub const FIXTURE_RESIGNED_SIG: &str = include_str!("../testdata/index.json.resigned.minisig"); + /// The same index with `generated` 2026-11-01T00:00:00Z, signed 2026-11-01T00:05:00Z. + pub const OLDER: &[u8] = include_bytes!("../testdata/index-older.json"); + pub const OLDER_SIG: &str = include_str!("../testdata/index-older.json.minisig"); + pub const FIXTURE_KEY: &str = include_str!("../testdata/test.pub"); + + pub fn at(rfc3339: &str) -> OffsetDateTime { + OffsetDateTime::parse(rfc3339, &time::format_description::well_known::Rfc3339).unwrap() + } + + #[test] + fn a_signed_index_verifies_and_says_when_it_was_signed() { + let signed = verify(FIXTURE, FIXTURE_SIG, FIXTURE_KEY).unwrap(); + assert_eq!(signed.index.mods[0].id, "cogwork"); + assert_eq!(signed.index.weft[0].abi, "f470b1cbb1f9ad91"); + assert_eq!(signed.signed_at, Some(at("2026-11-04T18:05:00Z"))); + // The bare base64 line works too, as embedded in the launcher. + let key_line = FIXTURE_KEY.lines().nth(1).unwrap(); + assert!(verify(FIXTURE, FIXTURE_SIG, key_line).is_ok()); + } + + #[test] + fn trusted_comments() { + assert_eq!( + signed_at("timestamp:1793819114\tfile:index.json\thashed"), + Some(at("2026-11-04T19:05:14Z")) + ); + assert_eq!( + signed_at("timestamp:1793819114 file:index.json commit:5e4de0c"), + Some(at("2026-11-04T19:05:14Z")) + ); + assert_eq!(signed_at("test fixture"), None); + assert_eq!(signed_at("timestamp:soon"), None); + } + + #[test] + fn any_change_breaks_the_signature() { + let mut tampered = FIXTURE.to_vec(); + let at = tampered.windows(7).position(|w| w == b"Cogwork").unwrap(); + tampered[at] = b'K'; + assert!(matches!( + verify(&tampered, FIXTURE_SIG, FIXTURE_KEY), + Err(ModsError::IndexSignatureInvalid(_)) + )); + assert!(matches!( + verify(FIXTURE, "not a signature", FIXTURE_KEY), + Err(ModsError::IndexSignatureInvalid(_)) + )); + // A trusted comment changed to claim a newer signing time. + let forged = FIXTURE_SIG.replace("timestamp:1793815500", "timestamp:1893815500"); + assert!(matches!( + verify(FIXTURE, &forged, FIXTURE_KEY), + Err(ModsError::IndexSignatureInvalid(_)) + )); + } + + /// A copy of the live index, signed by the real key: the embedded key is the right + /// one, and nothing the index publishes is dropped by the contract checks here. + #[test] + fn the_published_index_verifies_and_keeps_every_entry() { + let bytes = include_bytes!("../testdata/published-index.json"); + let sig = include_str!("../testdata/published-index.json.minisig"); + let signed = verify(bytes, sig, INDEX_PUBLIC_KEY).unwrap(); + assert!(signed.signed_at.is_some()); + let index = signed.index; + let raw: serde_json::Value = serde_json::from_slice(bytes).unwrap(); + let raw_count: usize = raw["mods"] + .as_array() + .unwrap() + .iter() + .map(|m| m["versions"].as_array().unwrap().len()) + .sum(); + let count = index.mods.iter().map(|m| m.versions.len()).sum::(); + assert!(raw_count > 0); + assert_eq!(count, raw_count); + assert_eq!(index.weft.len(), raw["weft"].as_array().unwrap().len()); + } + + #[test] + fn another_key_is_refused() { + assert!(matches!( + verify(FIXTURE, FIXTURE_SIG, INDEX_PUBLIC_KEY), + Err(ModsError::IndexSignatureInvalid(_)) + )); + } +} diff --git a/src-tauri/src/mods/profile.rs b/src-tauri/src/mods/profile.rs index 6988595..5ac5a0a 100644 --- a/src-tauri/src/mods/profile.rs +++ b/src-tauri/src/mods/profile.rs @@ -456,6 +456,7 @@ pub(crate) mod tests { index: IndexStatus { freshness: Freshness::Fresh, generated: None, + signed_at: None, error: None, }, sources: vec![], diff --git a/src-tauri/src/mods/service.rs b/src-tauri/src/mods/service.rs index 6120022..35d97d3 100644 --- a/src-tauri/src/mods/service.rs +++ b/src-tauri/src/mods/service.rs @@ -104,7 +104,8 @@ pub async fn load_catalog(app: &AppHandle, refresh: bool) -> Result Result) -> Vec { mods } -/// Reads at most `max` bytes of a response; `None` if it is longer. -async fn read_capped(mut response: reqwest::Response, max: usize) -> Option> { - if response.content_length().is_some_and(|n| n as usize > max) { - return None; - } - let mut bytes = Vec::new(); - while let Some(chunk) = response.chunk().await.ok()? { - if bytes.len() + chunk.len() > max { - return None; - } - bytes.extend_from_slice(&chunk); - } - Some(bytes) -} - async fn get_mod_json(client: &reqwest::Client, url: &str) -> Option { let response = client .get(url) @@ -241,7 +227,7 @@ async fn get_mod_json(client: &reqwest::Client, url: &str) -> Option { if !response.status().is_success() { return None; } - let bytes = read_capped(response, MAX_MOD_JSON_BYTES).await?; + let bytes = read_capped(response, MAX_MOD_JSON_BYTES).await.ok()??; serde_json::from_slice(&bytes).ok() } @@ -598,11 +584,16 @@ pub(crate) mod tests { let response = |body: Vec| reqwest::Response::from(http_response(body)); assert_eq!( run(read_capped(response(vec![b'x'; 10]), 10)) + .unwrap() .unwrap() .len(), 10 ); - assert!(run(read_capped(response(vec![b'x'; 11]), 10)).is_none()); + assert!( + run(read_capped(response(vec![b'x'; 11]), 10)) + .unwrap() + .is_none() + ); } fn http_response(body: Vec) -> ::http::Response> { diff --git a/src-tauri/src/mods/testdata/index-older.json b/src-tauri/src/mods/testdata/index-older.json new file mode 100644 index 0000000..1fbb499 --- /dev/null +++ b/src-tauri/src/mods/testdata/index-older.json @@ -0,0 +1 @@ +{"schema":1,"generated":"2026-11-01T00:00:00Z","weft":[{"tag":"v26.11.1","commit":"ff9c7128511fc0dce7719b67c9a59274d70bdd5a","abi":"f470b1cbb1f9ad91"}],"mods":[{"id":"cogwork","name":"Cogwork","summary":"Gears","authors":["Zorro909"],"license":null,"homepage":null,"icon":null,"versions":[{"version":"0.2.0","channel":"release","tag":"cogwork-v0.2.0","published":"2026-11-04T00:00:00Z","source":{"repo":"Zorro909/weft-mods","commit":"cceea7805df5902d62017d2008d1bb29ed84e152"},"weft":{"tag":"v26.11.1","commit":"ff9c7128511fc0dce7719b67c9a59274d70bdd5a","abi":"f470b1cbb1f9ad91"},"deps":[{"id":"base"}],"platforms":["windows-x64","linux-x64","macos-arm64"],"asset":{"name":"cogwork.zip","url":"https://github.com/Zorro909/weft-mods/releases/download/cogwork-v0.2.0/cogwork.zip","size":2642469,"sha256":"989b2191aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"provenance":null,"reviewed":{"by":"Zorro909","at":"2026-11-04"}}]}],"revoked":[]} diff --git a/src-tauri/src/mods/testdata/index-older.json.minisig b/src-tauri/src/mods/testdata/index-older.json.minisig new file mode 100644 index 0000000..b6bd163 --- /dev/null +++ b/src-tauri/src/mods/testdata/index-older.json.minisig @@ -0,0 +1,4 @@ +untrusted comment: signature from minisign secret key +RUR87y/Jcn346suBC8b4+krn9FKYGJX1JwggxKtJx4Y+bo43FI3aypRzUDVkJnBMNX6/p6usTP4GYc5LAVo7+p0M3l6h6CUf6g0= +trusted comment: timestamp:1793491500 file:index.json +DiOOUAMLrN3rIAKw1qBrRPIk6GzrgzfYLmoNRCZnU/hRIQF7SqGCp5EhTBDIO/67ZX8MjI8INurP/ELV7T/WAA== diff --git a/src-tauri/src/mods/testdata/index.json.minisig b/src-tauri/src/mods/testdata/index.json.minisig index 30c9507..4c74451 100644 --- a/src-tauri/src/mods/testdata/index.json.minisig +++ b/src-tauri/src/mods/testdata/index.json.minisig @@ -1,4 +1,4 @@ untrusted comment: signature from minisign secret key -RUROq6v/++BGPAr5Js76VsVq7EGSPRYcNjNkIFxZ6jNakuhvZDWV3CESll/WvEWacFNBsqsFJ7wCsagm9uSn97XO+PEv6QICIA4= -trusted comment: test fixture -FB1d/8sDgLDzQ52JLJ70/zwyih9B9yjtw5Jb9wpnI8bZipsUU/KbQ+i/OpNuanKpzNAb2plhR1KGJtbeDo8NAg== +RUR87y/Jcn346ofVrC9aY6WKGkDKqRWq2NdHMnrBgJ7SjX9omCxk7nEdvIAZ9p0wNbVgLmrOh9/d64qW0O9kf2iahSMt+m2PNgk= +trusted comment: timestamp:1793815500 file:index.json +r6NABTl1RS/DrHNf/hKcoJgrV50D94sWCoNpRJIVbdsHE9WnLr4gK/VRwkA1pqFy4wrr0nBf7JxlLZzuoe4CDQ== diff --git a/src-tauri/src/mods/testdata/index.json.resigned.minisig b/src-tauri/src/mods/testdata/index.json.resigned.minisig new file mode 100644 index 0000000..33d6314 --- /dev/null +++ b/src-tauri/src/mods/testdata/index.json.resigned.minisig @@ -0,0 +1,4 @@ +untrusted comment: signature from minisign secret key +RUR87y/Jcn346ofVrC9aY6WKGkDKqRWq2NdHMnrBgJ7SjX9omCxk7nEdvIAZ9p0wNbVgLmrOh9/d64qW0O9kf2iahSMt+m2PNgk= +trusted comment: timestamp:1794420000 file:index.json +ly/2Z83CHYqjFOVH4SqeD0Kv+Ulx+0N10rKNqzi1DmQp10+1CkypZbGKu/f2aG27I/I2qAp5GSt6Mc0xJxvJAg== diff --git a/src-tauri/src/mods/testdata/test.pub b/src-tauri/src/mods/testdata/test.pub index c913bd9..e2447bd 100644 --- a/src-tauri/src/mods/testdata/test.pub +++ b/src-tauri/src/mods/testdata/test.pub @@ -1,2 +1,2 @@ -untrusted comment: minisign public key 3C46E0FBFFABAB4E -RWROq6v/++BGPNeKSeypLw4KkzVB4KN1ysJqNO7j9XdXwXCwbnRpqHwT +untrusted comment: minisign public key EAF87D72C92FEF7C +RWR87y/Jcn346oYW5xzn+XIs4Tl1GaujTfgcqxVJo1OUwatPbwYeU3Wa diff --git a/src/mods/bindings/Freshness.ts b/src/mods/bindings/Freshness.ts index 9c9ff41..0f4d4d1 100644 --- a/src/mods/bindings/Freshness.ts +++ b/src/mods/bindings/Freshness.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type Freshness = "fresh" | "cached" | "unavailable"; +export type Freshness = "fresh" | "stale" | "cached" | "unavailable"; diff --git a/src/mods/bindings/IndexStatus.ts b/src/mods/bindings/IndexStatus.ts index 4f41a59..34df174 100644 --- a/src/mods/bindings/IndexStatus.ts +++ b/src/mods/bindings/IndexStatus.ts @@ -2,4 +2,8 @@ import type { AppErrorPayload } from "./AppErrorPayload"; import type { Freshness } from "./Freshness"; -export type IndexStatus = { freshness: Freshness, generated: string | null, error: AppErrorPayload | null, }; +export type IndexStatus = { freshness: Freshness, generated: string | null, +/** + * RFC 3339, from the signature's trusted comment (`timestamp:`). + */ +signedAt: string | null, error: AppErrorPayload | null, }; From 0953465c2389a4c559da464c052f272a2cbd04d9 Mon Sep 17 00:00:00 2001 From: Zorro909 Date: Wed, 4 Nov 2026 21:35:38 +0000 Subject: [PATCH 022/156] Run mod changes on a context and check them again before committing The operations move from commands.rs into service.rs and take a Ctx (paths, mods lock, reservations, held catalogue, running games, progress), so they run in tests without Tauri. Every command is one call. - Installs are staged, fetched and then committed under the mods lock, after checking that the version is still installed and not running, no build was revoked in the latest catalogue, and resolving again against the latest profile needs nothing more and changes nothing else. Otherwise the new ModChangedMeanwhile error, and nothing is written. - A mod that needs a missing dependency is installed turned off (InstallPlan.turnsOn, ChangeResult.installedOff); copies follow the same rule per mod, and CopyPreview lists the dependencies, the mods it turns on and the ones that stay off. - Revocations are recorded in the lock file and outlive the index. InstalledMod.depsNotInstalled tells a missing dependency from one that is off. - remove_mod keeps the entry in removed.json, and its store file, until the version's next change; restore_mod puts it back. - store::admit hashes the file itself and checks the zip and platform, also for a build already in the store; a damaged stored copy is fetched again. Lock files are validated on read. - prepare_launch uses only the held or cached catalogue and cached ABI. - forget_version takes the mods lock. Hashing and copying run in spawn_blocking. - Redirects stay on GitHub hosts over https; icons are read with a 512 KB cap and must be PNGs. --- src-tauri/src/error.rs | 1 + src-tauri/src/install.rs | 2 +- src-tauri/src/launch.rs | 37 +- src-tauri/src/lib.rs | 3 +- src-tauri/src/mods/catalog.rs | 7 +- src-tauri/src/mods/commands.rs | 286 +--------- src-tauri/src/mods/context.rs | 227 ++++++++ src-tauri/src/mods/error.rs | 13 +- src-tauri/src/mods/index/fetch.rs | 15 +- src-tauri/src/mods/index/mod.rs | 2 +- src-tauri/src/mods/inspect.rs | 1 + src-tauri/src/mods/install.rs | 40 +- src-tauri/src/mods/mod.rs | 32 +- src-tauri/src/mods/paths.rs | 13 + src-tauri/src/mods/plan.rs | 373 ++++++++++++- src-tauri/src/mods/profile.rs | 111 +++- src-tauri/src/mods/service.rs | 816 ++++++++++++++++++++-------- src-tauri/src/mods/service_tests.rs | 674 +++++++++++++++++++++++ src-tauri/src/mods/sources.rs | 28 +- src-tauri/src/mods/store.rs | 259 ++++++++- src/mods/bindings/ChangeResult.ts | 10 +- src/mods/bindings/CopyPreview.ts | 15 +- src/mods/bindings/CopyResult.ts | 10 +- src/mods/bindings/ErrorCode.ts | 2 +- src/mods/bindings/InstallPlan.ts | 5 + src/mods/bindings/InstalledMod.ts | 18 +- src/mods/bindings/Revocation.ts | 10 + src/mods/bindings/commands.ts | 1 + src/mods/bindings/index.ts | 1 + 29 files changed, 2427 insertions(+), 585 deletions(-) create mode 100644 src-tauri/src/mods/context.rs create mode 100644 src-tauri/src/mods/service_tests.rs create mode 100644 src/mods/bindings/Revocation.ts diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index 44d2fcc..36418d5 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -77,6 +77,7 @@ error_codes! { ModDownloadNotFound, ModTooLarge, ModIdReserved, + ModChangedMeanwhile, } impl std::fmt::Display for ErrorCode { diff --git a/src-tauri/src/install.rs b/src-tauri/src/install.rs index f517dd2..da64ba6 100644 --- a/src-tauri/src/install.rs +++ b/src-tauri/src/install.rs @@ -579,7 +579,7 @@ pub async fn remove_version(app_handle: tauri::AppHandle, tag: String) -> Result Ok(_) => { // Rename succeeded, now delete the renamed folder fs::remove_dir_all(&removing_dir).await?; - crate::mods::forget_version(&app_handle, &tag); + crate::mods::forget_version(&app_handle, &tag).await; Ok(()) } Err(e) => { diff --git a/src-tauri/src/launch.rs b/src-tauri/src/launch.rs index 4f7054b..b5a4fc6 100644 --- a/src-tauri/src/launch.rs +++ b/src-tauri/src/launch.rs @@ -120,15 +120,25 @@ pub struct GameStateEvent { #[derive(Default, Clone)] pub struct RunningGames(Arc>>); +impl RunningGames { + /// The game that is running now, if any: only one runs at a time. + pub fn current(&self) -> Option { + self.0.lock().unwrap().iter().next().cloned() + } + + #[cfg(test)] + pub fn insert(&self, tag: &str) { + self.0.lock().unwrap().insert(tag.to_string()); + } +} + fn running_games(app_handle: &AppHandle) -> RunningGames { app_handle.state::().inner().clone() } /// The game that is running now, if any: only one runs at a time. pub fn running_game(app_handle: &AppHandle) -> Option { - let games = running_games(app_handle); - let games = games.0.lock().unwrap(); - games.iter().next().cloned() + running_games(app_handle).current() } fn get_versions_dir(app_handle: &AppHandle) -> Result { @@ -222,6 +232,15 @@ pub async fn launch_version(app_handle: AppHandle, tag: String) -> Result<(), Ap Ok(()) } +/// The game's arguments. `--mods` is searched before the player's own mods folder, and the +/// first copy of a mod wins; without a folder nothing is passed. +fn game_args(mods_dir: Option<&Path>) -> Vec { + match mods_dir { + Some(dir) => vec!["--mods".into(), dir.as_os_str().to_owned()], + None => Vec::new(), + } +} + struct Launch<'a> { tag: &'a str, executable: &'a Path, @@ -268,12 +287,9 @@ async fn start_game( let mut cmd = TokioCommand::new(executable); cmd.current_dir(version_dir) + .args(game_args(mods_dir)) .stdout(Stdio::piped()) .stderr(Stdio::piped()); - // Searched before the player's own mods folder, and the first copy of a mod wins. - if let Some(dir) = mods_dir { - cmd.arg("--mods").arg(dir); - } #[cfg(target_os = "windows")] { @@ -351,9 +367,12 @@ pub async fn open_game_folder(app_handle: AppHandle, _tag: String) -> Result<(), /// The game's data folder, as the newest log that names one says. The game prints it at start. pub async fn find_game_folder(app_handle: &AppHandle) -> Option { - let logs_dir = get_logs_dir(app_handle).ok()?; + find_game_folder_in(&get_logs_dir(app_handle).ok()?).await +} - let mut entries = fs::read_dir(&logs_dir).await.ok()?; +/// The same, given the launcher's logs folder. +pub async fn find_game_folder_in(logs_dir: &Path) -> Option { + let mut entries = fs::read_dir(logs_dir).await.ok()?; let mut log_files = Vec::new(); while let Ok(Some(entry)) = entries.next_entry().await { let path = entry.path(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 86e0f4e..650b5da 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -36,7 +36,7 @@ pub fn run() { .manage(install::ActiveInstalls::default()) .manage(launch::RunningGames::default()) .manage(mods::catalog::CatalogState::default()) - .manage(mods::service::ModsLock::default()) + .manage(mods::ModsLock::default()) .manage(mods::store::Reservations::default()) .setup(|app| { install::sweep_leftovers(app.handle()); @@ -62,6 +62,7 @@ pub fn run() { mods::commands::install_mod_file, mods::commands::set_mod_enabled, mods::commands::remove_mod, + mods::commands::restore_mod, mods::commands::preview_copy_mod_profile, mods::commands::copy_mod_profile, mods::commands::get_mod_icon, diff --git a/src-tauri/src/mods/catalog.rs b/src-tauri/src/mods/catalog.rs index 63b5006..0aa2bc8 100644 --- a/src-tauri/src/mods/catalog.rs +++ b/src-tauri/src/mods/catalog.rs @@ -3,7 +3,7 @@ //! hash, and the URL and hash always come from here, never from the page. use serde::Serialize; -use std::sync::Mutex; +use std::sync::{Arc, Mutex}; use ts_rs::TS; use super::archive::current_platform; @@ -161,8 +161,9 @@ impl Catalog { } } -#[derive(Default)] -pub struct CatalogState(pub Mutex>); +/// The catalogue the page last loaded. +#[derive(Default, Clone)] +pub struct CatalogState(pub Arc>>); fn version_view(v: &IndexVersion, trust: Trust, from_index: bool) -> CatalogVersion { CatalogVersion { diff --git a/src-tauri/src/mods/commands.rs b/src-tauri/src/mods/commands.rs index d4d3fd3..cbf161c 100644 --- a/src-tauri/src/mods/commands.rs +++ b/src-tauri/src/mods/commands.rs @@ -1,140 +1,68 @@ -//! The mod manager's Tauri commands: argument checks and locking around the steps in -//! `service.rs` and the rules in `plan.rs`. Every change to a profile goes through `ModsLock`, so -//! two clicks never write `mods.lock.json` at once; downloads happen before taking it, and -//! whether the game is running is checked again once it is held. +//! The mod manager's Tauri commands. Each one takes the app's [`Ctx`] and calls one operation in +//! `service.rs`, where the checks, the locking and the rules are. -use std::path::PathBuf; use tauri::AppHandle; -use tokio::sync::OwnedMutexGuard; -use super::archive::current_platform; use super::catalog::Catalog; -use super::error::ModsError; -use super::index::RESERVED_ID; -use super::inspect::{self, FileInspection}; -use super::paths::Paths; -use super::plan::{self, ChangeResult, CopyOutcome, CopyPreview, CopyResult, InstallPlan}; -use super::profile::{self, Fit, ProfileReport}; -use super::service::{ - self, build_report, change_result, check_sha, current_catalog, ensure_installed, - ensure_not_running, lock_mods, require_abi, run_install, version_abi, -}; -use super::sources; -use super::store; +use super::context::Ctx; +use super::inspect::FileInspection; +use super::plan::{ChangeResult, CopyPreview, CopyResult, InstallPlan}; +use super::profile::ProfileReport; +use super::service; use crate::error::AppError; #[tauri::command] pub async fn list_mod_catalog(app: AppHandle, force_refresh: bool) -> Result { - Ok(service::load_catalog(&app, force_refresh).await?) + Ok(service::load_catalog(&Ctx::of(&app)?, force_refresh).await?) } #[tauri::command] pub async fn get_mod_sources(app: AppHandle) -> Result, AppError> { - Ok(sources::list(&Paths::of(&app)?)) + Ok(service::source_list(&Ctx::of(&app)?)) } -/// Adds a repo as GitHub spells it. Adding one already there, in any case, keeps one entry. #[tauri::command] pub async fn add_mod_source(app: AppHandle, repo: String) -> Result, AppError> { - let paths = Paths::of(&app)?; - let repo = sources::normalize(&repo)?; - let canonical = sources::canonical_name(&service::http()?, &repo).await?; - let _held = lock_mods(&app).await; - let repos = sources::with_repo(sources::list(&paths), &canonical); - sources::save(&paths, &repos)?; - Ok(repos) + Ok(service::add_source(&Ctx::of(&app)?, &repo).await?) } -/// Mods installed from the repo stay installed; they just stop getting updates. #[tauri::command] pub async fn remove_mod_source(app: AppHandle, repo: String) -> Result, AppError> { - let paths = Paths::of(&app)?; - let _held = lock_mods(&app).await; - let repos = sources::without_repo(sources::list(&paths), &repo); - sources::save(&paths, &repos)?; - Ok(repos) + Ok(service::remove_source(&Ctx::of(&app)?, &repo).await?) } #[tauri::command] pub async fn get_mod_profile(app: AppHandle, tag: String) -> Result { - let paths = Paths::of(&app)?; - ensure_installed(&paths, &tag)?; - let catalog = current_catalog(&app).await?; - let _held = lock_mods(&app).await; - let mut lock = store::read_lock(&paths, &tag)?; - if profile::apply_revocations(&mut lock, &catalog) { - store::write_lock(&paths, &tag, &lock)?; - store::reconcile(&paths, &tag, &lock)?; - } - Ok(build_report(&app, &paths, &tag, &lock, &catalog, true).await) + Ok(service::profile(&Ctx::of(&app)?, &tag).await?) } -/// What `install_mod` would do with this build, without doing it. #[tauri::command] pub async fn plan_mod_install( app: AppHandle, tag: String, sha256: String, ) -> Result { - let paths = Paths::of(&app)?; - ensure_installed(&paths, &tag)?; - let sha = check_sha(&sha256)?; - let catalog = current_catalog(&app).await?; - let lock = store::read_lock(&paths, &tag)?; - let (abi, _) = version_abi(&paths, &catalog, &tag, true).await; - let fit = abi.as_deref().map(Fit::here); - let running = service::is_running(&app, &tag); - Ok(plan::plan_install( - &catalog, &lock, &tag, &sha, fit, running, - )?) + Ok(service::plan(&Ctx::of(&app)?, &tag, &sha256).await?) } -/// Installs the catalogue build with this hash, and what it needs. Installing another build of -/// an installed mod replaces it and keeps it on or off as it was. #[tauri::command] pub async fn install_mod( app: AppHandle, tag: String, sha256: String, ) -> Result { - let paths = Paths::of(&app)?; - ensure_installed(&paths, &tag)?; - let sha = check_sha(&sha256)?; - ensure_not_running(&app, &tag)?; - let catalog = current_catalog(&app).await?; - let (m, v) = catalog - .find(&sha) - .map(|(m, v)| (m.clone(), v.clone())) - .ok_or_else(|| ModsError::NotInCatalog(sha.clone()))?; - let abi = require_abi(&paths, &catalog, &tag).await?; - plan::check_installable(&catalog, &m, &v, Fit::here(&abi))?; - Ok(run_install(&app, &paths, &tag, &catalog, &abi, vec![(m, v)], Vec::new()).await?) + Ok(service::install(&Ctx::of(&app)?, &tag, &sha256).await?) } -/// What the launcher can tell about a zip from the player's computer, before installing it. #[tauri::command] pub async fn inspect_mod_file( app: AppHandle, tag: String, path: String, ) -> Result { - let paths = Paths::of(&app)?; - ensure_installed(&paths, &tag)?; - let catalog = current_catalog(&app).await?; - let (abi, _) = version_abi(&paths, &catalog, &tag, true).await; - let lock = store::read_lock(&paths, &tag)?; - Ok(inspect::inspect( - &PathBuf::from(path), - &catalog, - abi.as_deref(), - current_platform(), - &lock, - )?) + Ok(service::inspect_file(&Ctx::of(&app)?, &tag, &path).await?) } -/// Installs a zip from the player's computer. `sha256` is what `inspect_mod_file` showed, so a -/// file swapped in between is refused. A file that is in the catalogue installs from these same -/// bytes, with the catalogue's metadata and trust; anything else is Unverified. #[tauri::command] pub async fn install_mod_file( app: AppHandle, @@ -142,50 +70,9 @@ pub async fn install_mod_file( path: String, sha256: String, ) -> Result { - let paths = Paths::of(&app)?; - ensure_installed(&paths, &tag)?; - let sha = check_sha(&sha256)?; - ensure_not_running(&app, &tag)?; - let catalog = current_catalog(&app).await?; - if let Some(r) = catalog.revocation(&sha) { - return Err(ModsError::Revoked(r.reason.clone()).into()); - } - let source = PathBuf::from(&path); - inspect::checked_size(&source)?; - let hold = service::reservations(&app).reserve(&sha); - - if let Some((m, v)) = catalog.find(&sha).map(|(m, v)| (m.clone(), v.clone())) { - let abi = require_abi(&paths, &catalog, &tag).await?; - let fit = Fit::here(&abi); - plan::check_installable(&catalog, &m, &v, fit)?; - store::admit_local(&paths, &source, &sha, Some(&m.id), fit.platform)?; - let held = vec![hold]; - return Ok(run_install(&app, &paths, &tag, &catalog, &abi, vec![(m, v)], held).await?); - } - - let info = store::admit_local(&paths, &source, &sha, None, current_platform())?; - if info.id == RESERVED_ID { - return Err(ModsError::IdReserved(info.id).into()); - } - let mut entry = inspect::local_entry(&info, &paths.store_object(&sha), &source, &sha)?; - - let _guard = lock_mods(&app).await; - ensure_not_running(&app, &tag)?; - let mut lock = store::read_lock(&paths, &tag)?; - entry.enabled = lock.get(&entry.id).is_none_or(|e| e.enabled); - lock.put(entry); - store::write_lock(&paths, &tag, &lock)?; - drop(hold); - store::reconcile(&paths, &tag, &lock)?; - service::collect_garbage(&app, &paths); - Ok(ChangeResult { - installed: vec![info.id], - ..change_result(&app, &paths, &tag, &lock, &catalog).await - }) + Ok(service::install_file(&Ctx::of(&app)?, &tag, &path, &sha256).await?) } -/// Turning a mod on turns on the installed mods it needs, and is refused when one of them is -/// revoked; turning it off turns off what needs it. #[tauri::command] pub async fn set_mod_enabled( app: AppHandle, @@ -193,121 +80,46 @@ pub async fn set_mod_enabled( id: String, enabled: bool, ) -> Result { - let paths = Paths::of(&app)?; - ensure_installed(&paths, &tag)?; - ensure_not_running(&app, &tag)?; - let catalog = current_catalog(&app).await?; - let _guard = lock_mods(&app).await; - ensure_not_running(&app, &tag)?; - let mut lock = store::read_lock(&paths, &tag)?; - let (also_enabled, also_disabled) = plan::set_enabled(&mut lock, &catalog, &id, enabled)?; - store::write_lock(&paths, &tag, &lock)?; - store::reconcile(&paths, &tag, &lock)?; - Ok(ChangeResult { - also_enabled, - also_disabled, - ..change_result(&app, &paths, &tag, &lock, &catalog).await - }) + Ok(service::set_enabled(&Ctx::of(&app)?, &tag, &id, enabled).await?) } #[tauri::command] pub async fn remove_mod(app: AppHandle, tag: String, id: String) -> Result { - let paths = Paths::of(&app)?; - ensure_installed(&paths, &tag)?; - ensure_not_running(&app, &tag)?; - let catalog = current_catalog(&app).await?; - let _guard = lock_mods(&app).await; - ensure_not_running(&app, &tag)?; - let mut lock = store::read_lock(&paths, &tag)?; - let also_disabled = plan::remove(&mut lock, &id)?; - store::write_lock(&paths, &tag, &lock)?; - store::reconcile(&paths, &tag, &lock)?; - service::collect_garbage(&app, &paths); - Ok(ChangeResult { - also_disabled, - ..change_result(&app, &paths, &tag, &lock, &catalog).await - }) -} - -struct CopyInputs { - catalog: Catalog, - abi: String, - preview: CopyPreview, - picks: Vec<(super::catalog::CatalogMod, super::catalog::CatalogVersion)>, + Ok(service::remove(&Ctx::of(&app)?, &tag, &id).await?) } -async fn copy_inputs( - app: &AppHandle, - paths: &Paths, - from: &str, - to: &str, -) -> Result { - ensure_installed(paths, to)?; - let source = store::read_lock(paths, from)?; - let target = store::read_lock(paths, to)?; - let catalog = current_catalog(app).await?; - let abi = require_abi(paths, &catalog, to).await?; - let (preview, picks) = - plan::copy_preview(&catalog, (from, &source), (to, &target), Fit::here(&abi)); - Ok(CopyInputs { - catalog, - abi, - preview, - picks, - }) +#[tauri::command] +pub async fn restore_mod( + app: AppHandle, + tag: String, + id: String, +) -> Result { + Ok(service::restore(&Ctx::of(&app)?, &tag, &id).await?) } -/// What `copy_mod_profile` would bring over. #[tauri::command] pub async fn preview_copy_mod_profile( app: AppHandle, from: String, to: String, ) -> Result { - let paths = Paths::of(&app)?; - Ok(copy_inputs(&app, &paths, &from, &to).await?.preview) + Ok(service::copy_preview(&Ctx::of(&app)?, &from, &to).await?) } -/// Brings the turned-on mods of one version to another, each as the build made for the new one. -/// Mods the new version already has are left as they are. #[tauri::command] pub async fn copy_mod_profile( app: AppHandle, from: String, to: String, ) -> Result { - let paths = Paths::of(&app)?; - ensure_not_running(&app, &to)?; - let inputs = copy_inputs(&app, &paths, &from, &to).await?; - let left_behind = inputs - .preview - .items - .into_iter() - .filter(|i| { - matches!( - i.outcome, - CopyOutcome::NoBuild | CopyOutcome::LocalFile | CopyOutcome::Revoked - ) - }) - .collect(); - let change = if inputs.picks.is_empty() { - let lock = store::read_lock(&paths, &to)?; - change_result(&app, &paths, &to, &lock, &inputs.catalog).await - } else { - let (catalog, abi) = (&inputs.catalog, &inputs.abi); - run_install(&app, &paths, &to, catalog, abi, inputs.picks, Vec::new()).await? - }; - Ok(CopyResult { - change, - left_behind, - }) + Ok(service::copy(&Ctx::of(&app)?, &from, &to).await?) } /// A mod icon as a `data:` URL: the page may not load images from the network. #[tauri::command] pub async fn get_mod_icon(app: AppHandle, url: String) -> Result { use base64::Engine; - let bytes = service::icon_png(&app, &url).await?; + let bytes = service::icon_png(&Ctx::of(&app)?, &url).await?; Ok(format!( "data:image/png;base64,{}", base64::engine::general_purpose::STANDARD.encode(bytes) @@ -323,45 +135,3 @@ pub async fn open_player_mods_folder(app: AppHandle) -> Result<(), AppError> { crate::launch::open_path(&app, if mods.exists() { &mods } else { &folder })?; Ok(()) } - -/// The outcome of the pre-Play check. Holds `ModsLock` until [`LaunchMods::release`], so no mod -/// change can land between this check and the game counting as running. -pub struct LaunchMods { - dir: Option, - _held: OwnedMutexGuard<()>, -} - -impl LaunchMods { - /// The folder to hand to `--mods`, letting mod changes go ahead again. - pub fn release(self) -> Option { - self.dir - } -} - -/// Before Play: the folder to hand to `--mods`, or why the game would not start. Never waits -/// long on the network; without a known ABI the game itself has the last word. -pub async fn prepare_launch(app: &AppHandle, tag: &str) -> Result { - let paths = Paths::of(app)?; - let catalog = current_catalog(app).await?; - let held = lock_mods(app).await; - let mut lock = store::read_lock(&paths, tag)?; - if profile::apply_revocations(&mut lock, &catalog) { - store::write_lock(&paths, tag, &lock)?; - } - store::reconcile(&paths, tag, &lock)?; - if !lock.mods.iter().any(|m| m.enabled) { - return Ok(LaunchMods { - dir: None, - _held: held, - }); - } - let report = build_report(app, &paths, tag, &lock, &catalog, true).await; - if !report.blocking.is_empty() { - let names: Vec = report.blocking.iter().map(|id| lock.name_of(id)).collect(); - return Err(ModsError::Blocked(names.join(", ")).into()); - } - Ok(LaunchMods { - dir: Some(paths.mods_dir(tag)?), - _held: held, - }) -} diff --git a/src-tauri/src/mods/context.rs b/src-tauri/src/mods/context.rs new file mode 100644 index 0000000..80cc739 --- /dev/null +++ b/src-tauri/src/mods/context.rs @@ -0,0 +1,227 @@ +//! What the mod manager needs from the running app, in one value: its folders, its locks, the +//! catalogue the page last saw, which game runs, and where progress goes. [`Ctx::of`] takes them +//! from Tauri; tests build one on a temporary folder, so the whole service runs without the app. + +use std::sync::Arc; +use tauri::{AppHandle, Emitter, Manager}; +use tokio::sync::{Mutex, OwnedMutexGuard}; + +use super::catalog::{Catalog, CatalogState}; +use super::error::ModsError; +use super::index::is_sha256; +use super::install::{ModProgress, ProgressStage}; +use super::paths::Paths; +use super::store::{self, Reservations}; +use crate::launch::RunningGames; + +/// Held while a profile or the store changes, and by a launch from its last check until the +/// game counts as running. +#[derive(Default, Clone)] +pub struct ModsLock(Arc>); + +impl ModsLock { + pub async fn acquire(&self) -> OwnedMutexGuard<()> { + self.0.clone().lock_owned().await + } + + #[cfg(test)] + pub fn is_held(&self) -> bool { + self.0.clone().try_lock_owned().is_err() + } +} + +type Progress = Arc; + +#[derive(Clone)] +pub struct Ctx { + pub paths: Paths, + pub lock: ModsLock, + pub reservations: Reservations, + pub catalog: CatalogState, + pub games: RunningGames, + progress: Progress, +} + +impl Ctx { + pub fn of(app: &AppHandle) -> Result { + let emitter = app.clone(); + Ok(Ctx { + paths: Paths::of(app)?, + lock: app.state::().inner().clone(), + reservations: app.state::().inner().clone(), + catalog: app.state::().inner().clone(), + games: app.state::().inner().clone(), + progress: Arc::new(move |p| { + let _ = emitter.emit(crate::ipc::MOD_PROGRESS, p); + }), + }) + } + + /// A context of its own on `paths`: nothing running, nothing held, progress dropped. + pub fn new(paths: Paths) -> Ctx { + Ctx { + paths, + lock: ModsLock::default(), + reservations: Reservations::default(), + catalog: CatalogState::default(), + games: RunningGames::default(), + progress: Arc::new(|_| {}), + } + } + + pub fn with_progress(mut self, progress: impl Fn(ModProgress) + Send + Sync + 'static) -> Ctx { + self.progress = Arc::new(progress); + self + } + + pub fn emit(&self, sha256: &str, mod_id: &str, stage: ProgressStage, percent: u32) { + (self.progress)(ModProgress { + sha256: sha256.to_string(), + mod_id: mod_id.to_string(), + stage, + percent, + }); + } + + pub async fn lock_mods(&self) -> OwnedMutexGuard<()> { + self.lock.acquire().await + } + + pub fn is_running(&self, tag: &str) -> bool { + self.games.current().is_some_and(|r| r == tag) + } + + /// The running game read its mods at start; changing them under it would only confuse. + pub fn ensure_not_running(&self, tag: &str) -> Result<(), ModsError> { + match self.games.current() { + Some(running) if running == tag => Err(ModsError::GameRunning(running)), + _ => Ok(()), + } + } + + /// The catalogue the page last loaded, if it loaded one. + pub fn held_catalog(&self) -> Option { + self.catalog.0.lock().unwrap().clone() + } + + pub fn hold_catalog(&self, catalog: Catalog) { + *self.catalog.0.lock().unwrap() = Some(catalog); + } + + /// Deletes store files nothing uses. The caller holds `ModsLock`. + pub async fn collect_garbage(&self) { + let (paths, reservations) = (self.paths.clone(), self.reservations.clone()); + let result = blocking(move || store::collect_garbage(&paths, &reservations)).await; + if let Err(e) = result { + eprintln!("mods: garbage collection skipped: {e}"); + } + } +} + +/// Runs disk work that may take long (hashing and copying files of up to 512 MB) off the async +/// runtime. +pub async fn blocking(f: F) -> Result +where + T: Send + 'static, + F: FnOnce() -> Result + Send + 'static, +{ + tokio::task::spawn_blocking(f) + .await + .map_err(|e| ModsError::Io(std::io::Error::other(e)))? +} + +fn executable_name() -> &'static str { + if cfg!(target_os = "windows") { + "weft.exe" + } else { + "weft" + } +} + +pub fn ensure_installed(paths: &Paths, tag: &str) -> Result<(), ModsError> { + crate::install::validate_name(tag).map_err(|_| ModsError::InvalidName(tag.to_string()))?; + if paths + .versions_dir() + .join(tag) + .join(executable_name()) + .exists() + { + Ok(()) + } else { + Err(ModsError::VersionNotInstalled(tag.to_string())) + } +} + +/// The page names builds by hash; anything else is refused before it reaches a path or a lookup. +pub fn check_sha(sha256: &str) -> Result { + let sha = sha256.to_ascii_lowercase(); + if is_sha256(&sha) { + Ok(sha) + } else { + Err(ModsError::InvalidName(sha256.to_string())) + } +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + + /// A data folder with game version `tag` installed. + pub fn with_version(root: &std::path::Path, tag: &str) -> Ctx { + let paths = Paths::new(root); + let dir = paths.versions_dir().join(tag); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(executable_name()), b"").unwrap(); + Ctx::new(paths) + } + + #[test] + fn sha256_arguments_are_checked() { + let sha = "AB".repeat(32); + assert_eq!(check_sha(&sha).unwrap(), "ab".repeat(32)); + for bad in [ + "", + "abc", + "../../etc/passwd", + &"g".repeat(64), + &"a".repeat(65), + ] { + assert!( + matches!(check_sha(bad), Err(ModsError::InvalidName(_))), + "{bad:?}" + ); + } + } + + #[test] + fn the_mods_lock_is_held_until_released() { + let rt = tokio::runtime::Builder::new_current_thread() + .build() + .unwrap(); + rt.block_on(async { + let lock = ModsLock::default(); + let held = lock.acquire().await; + assert!(lock.is_held()); + drop(held); + assert!(!lock.is_held()); + }); + } + + #[test] + fn versions_and_running_games() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = with_version(tmp.path(), "v1"); + assert!(ensure_installed(&ctx.paths, "v1").is_ok()); + assert!(matches!( + ensure_installed(&ctx.paths, "v2"), + Err(ModsError::VersionNotInstalled(_)) + )); + assert!(ensure_installed(&ctx.paths, "../v1").is_err()); + ctx.games.insert("v1"); + assert!(ctx.is_running("v1") && !ctx.is_running("v2")); + assert!(matches!( + ctx.ensure_not_running("v1"), + Err(ModsError::GameRunning(_)) + )); + } +} diff --git a/src-tauri/src/mods/error.rs b/src-tauri/src/mods/error.rs index 0b7b734..4c2ee6d 100644 --- a/src-tauri/src/mods/error.rs +++ b/src-tauri/src/mods/error.rs @@ -64,6 +64,13 @@ pub enum ModsError { VersionNotInstalled(String), #[error("The game is running: {0}")] GameRunning(String), + /// A download finished, but the profile, the catalogue or the version changed meanwhile in + /// a way that makes the plan wrong. + #[error("Changed meanwhile: {0}")] + ChangedMeanwhile(String), + /// `mods.lock.json` can't be used as it is. + #[error("Mod set unreadable: {0}")] + LockInvalid(String), } impl From for ModsError { @@ -124,6 +131,8 @@ impl ModsError { IdReserved(_) => C::ModIdReserved, VersionNotInstalled(_) => C::NotInstalled, GameRunning(_) => C::GameRunning, + ChangedMeanwhile(_) => C::ModChangedMeanwhile, + LockInvalid(_) => C::Io, } } } @@ -158,7 +167,9 @@ impl From for AppError { | InvalidName(d) | IdReserved(d) | VersionNotInstalled(d) - | GameRunning(d) => Some(d), + | GameRunning(d) + | ChangedMeanwhile(d) => Some(d), + LockInvalid(d) => Some(format!("mods.lock.json: {d}")), }; AppError::new(code, detail) } diff --git a/src-tauri/src/mods/index/fetch.rs b/src-tauri/src/mods/index/fetch.rs index 47fbba4..b69ddb4 100644 --- a/src-tauri/src/mods/index/fetch.rs +++ b/src-tauri/src/mods/index/fetch.rs @@ -45,18 +45,25 @@ fn remember(cache: &IndexCache, age: Option) { } } +/// The cached index, without asking for a newer one. +pub fn cached(cache: &IndexCache, now: OffsetDateTime) -> IndexState { + let cached = cache.read().map(|(_, s)| s); + // A cache written before the state file existed still counts as accepted. + remember(cache, cached.as_ref().and_then(Age::of)); + from_cache(cached, now) +} + /// The newest index this launcher can trust. With `refresh` false only the cache is read, and /// nothing touches the network. `fetch` returns the index bytes and their signature. pub async fn load(cache: &IndexCache, refresh: bool, now: OffsetDateTime, fetch: F) -> IndexState where F: std::future::Future, String), ModsError>>, { - let cached = cache.read().map(|(_, s)| s); - // A cache written before the state file existed still counts as accepted. - remember(cache, cached.as_ref().and_then(Age::of)); if !refresh { - return from_cache(cached, now); + return cached(cache, now); } + let cached = cache.read().map(|(_, s)| s); + remember(cache, cached.as_ref().and_then(Age::of)); let fetched = fetch.await.and_then(|(bytes, sig)| { let signed = verify(&bytes, &sig, &cache.key)?; Ok((bytes, sig, signed)) diff --git a/src-tauri/src/mods/index/mod.rs b/src-tauri/src/mods/index/mod.rs index d456618..6ee8a04 100644 --- a/src-tauri/src/mods/index/mod.rs +++ b/src-tauri/src/mods/index/mod.rs @@ -14,7 +14,7 @@ mod format; mod signature; pub use cache::{Age, FUTURE_SLACK, Freshness, IndexCache, IndexState, STALE_AFTER, decide}; -pub use fetch::{INDEX_URL, MAX_INDEX_BYTES, fetch_bytes, index_url, load}; +pub use fetch::{INDEX_URL, MAX_INDEX_BYTES, cached, fetch_bytes, index_url, load}; pub use format::*; pub use signature::{INDEX_PUBLIC_KEY, Signed, public_key, signed_at, verify}; diff --git a/src-tauri/src/mods/inspect.rs b/src-tauri/src/mods/inspect.rs index eade4c4..ed40d68 100644 --- a/src-tauri/src/mods/inspect.rs +++ b/src-tauri/src/mods/inspect.rs @@ -143,6 +143,7 @@ pub fn local_entry( source_commit: None, icon: None, installed_at: now(), + revoked: None, }) } diff --git a/src-tauri/src/mods/install.rs b/src-tauri/src/mods/install.rs index 90f007c..7321fdb 100644 --- a/src-tauri/src/mods/install.rs +++ b/src-tauri/src/mods/install.rs @@ -1,17 +1,19 @@ -//! Getting a catalogue build into the store: download it through the shared `download.rs`, check -//! its hash and its zip, and move it in under its hash. Callers reserve the hash first (see -//! `store::Reservations`) and hold it until the lock file names the build. +//! Getting a catalogue build into the store: download it through the shared `download.rs`, and +//! hand it to `store::admit`, which checks its hash and its zip and moves it in under its hash. +//! Callers reserve the hash first (see `store::Reservations`) and hold it until the lock file +//! names the build. use serde::Serialize; use ts_rs::TS; use super::archive::MAX_FILE_BYTES; use super::catalog::{CatalogMod, CatalogVersion}; +use super::context::blocking; use super::error::ModsError; use super::index::Platform; use super::paths::Paths; use super::store; -use crate::download::{self, sha256_file}; +use crate::download; #[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq, TS)] #[serde(rename_all = "camelCase")] @@ -32,7 +34,9 @@ pub struct ModProgress { pub percent: u32, } -/// Downloads one build into the store, unless the store already has it intact. +/// Downloads one build into the store, unless the store already has it intact. Either way the +/// store's own checks ([`store::admit`], [`store::admit_stored`]) decide what goes in: a +/// download that doesn't hash to the pinned SHA-256 never reaches the store. pub async fn fetch_to_store( paths: &Paths, m: &CatalogMod, @@ -40,8 +44,11 @@ pub async fn fetch_to_store( platform: Option, mut progress: impl FnMut(ProgressStage, u32), ) -> Result<(), ModsError> { - let stored = paths.store_object(&v.sha256); - if stored.exists() && sha256_file(&stored)? == v.sha256 { + let admit_stored = { + let (paths, sha, id) = (paths.clone(), v.sha256.clone(), m.id.clone()); + blocking(move || store::admit_stored(&paths, &sha, Some(&id), platform)) + }; + if admit_stored.await?.is_some() { return Ok(()); } let tmp = store::temp_path(paths, &v.sha256)?; @@ -52,21 +59,12 @@ pub async fn fetch_to_store( progress(ProgressStage::Downloading, p.percent); }) .await; - let done = match result { - Ok(d) => d, - Err(e) => { - let _ = std::fs::remove_file(&tmp); - return Err(ModsError::from_download(e, &v.url)); - } - }; - progress(ProgressStage::Verifying, 100); - if done.sha256 != v.sha256 { + if let Err(e) = result { let _ = std::fs::remove_file(&tmp); - return Err(ModsError::Sha256Mismatch { - expected: v.sha256.clone(), - actual: done.sha256, - }); + return Err(ModsError::from_download(e, &v.url)); } - store::admit(paths, &tmp, &v.sha256, Some(&m.id), platform)?; + progress(ProgressStage::Verifying, 100); + let (paths, sha, id) = (paths.clone(), v.sha256.clone(), m.id.clone()); + blocking(move || store::admit(&paths, &tmp, &sha, Some(&id), platform)).await?; Ok(()) } diff --git a/src-tauri/src/mods/mod.rs b/src-tauri/src/mods/mod.rs index 4990261..98d7404 100644 --- a/src-tauri/src/mods/mod.rs +++ b/src-tauri/src/mods/mod.rs @@ -3,15 +3,18 @@ //! Design: `docs/mod-manager/PROPOSAL.md`; index contract: `docs/mod-manager/INDEX_FORMAT.md`. //! //! - Rules on plain data: `plan.rs` (installs, copies, on/off, removal), `profile.rs` (statuses, -//! updates, revocations), `catalog.rs`, `index.rs` (the contract), `diagnose.rs`. +//! updates, revocations), `catalog.rs`, `index/` (the contract), `diagnose.rs`. //! - Disk and network: `store.rs`, `install.rs`, `inspect.rs`, `sources.rs`, `abi.rs`, all on a -//! [`Paths`]; `service.rs` for the steps that need the running app. -//! - `commands.rs`: the Tauri commands, which check arguments and take the locks. +//! [`Paths`]. +//! - `service.rs`: the operations, on a [`Ctx`] (`context.rs`) that holds what they need from the +//! app, so they run without it in tests. +//! - `commands.rs`: the Tauri commands, one call into `service.rs` each. pub mod abi; pub mod archive; pub mod catalog; pub mod commands; +pub mod context; pub mod diagnose; pub mod error; pub mod index; @@ -27,15 +30,26 @@ pub mod version; #[cfg(test)] mod doc_tests; +#[cfg(test)] +mod service_tests; -pub use commands::{LaunchMods, prepare_launch}; +pub use context::{Ctx, ModsLock}; pub use paths::Paths; +pub use service::LaunchMods; + +use tauri::AppHandle; -use tauri::{AppHandle, Manager}; +/// Before Play: see [`service::prepare_launch`]. +pub async fn prepare_launch( + app: &AppHandle, + tag: &str, +) -> Result { + Ok(service::prepare_launch(&Ctx::of(app)?, tag).await?) +} -/// A deleted game version takes its mod set along. -pub fn forget_version(app: &AppHandle, tag: &str) { - if let Ok(paths) = Paths::of(app) { - store::forget_version(&paths, &app.state::(), tag); +/// A deleted game version takes its mod set along, under the mods lock. +pub async fn forget_version(app: &AppHandle, tag: &str) { + if let Ok(ctx) = Ctx::of(app) { + service::forget_version(&ctx, tag).await; } } diff --git a/src-tauri/src/mods/paths.rs b/src-tauri/src/mods/paths.rs index 1bf432c..c915be4 100644 --- a/src-tauri/src/mods/paths.rs +++ b/src-tauri/src/mods/paths.rs @@ -5,7 +5,9 @@ //! mods/sources.json GitHub repos the player added as sources //! profiles//mods/.zip the turned-on mods of one game version, handed to `--mods` //! profiles//mods.lock.json everything installed for that version +//! profiles//removed.json the last removal, until the next change (for Undo) //! cache/mod-index//index.json(.minisig) verified index generations; `current` names one +//! cache/mod-index.state.json the newest index age ever accepted (rollback protection) //! cache/mod-sources/.json the last good answer of each source //! cache/abi.json the mod interface hash of each Weft tag //! downloads/mod--.part a download in progress @@ -63,6 +65,11 @@ impl Paths { Ok(self.profile_dir(tag)?.join(LOCK_FILE)) } + /// The most recent removal from that version, while it can be undone. + pub fn removed_file(&self, tag: &str) -> Result { + Ok(self.profile_dir(tag)?.join(REMOVED_FILE)) + } + pub fn mods_dir(&self, tag: &str) -> Result { Ok(self.profile_dir(tag)?.join("mods")) } @@ -78,9 +85,15 @@ impl Paths { pub fn versions_dir(&self) -> PathBuf { self.root.join("versions") } + + /// The game logs the launcher writes, one per version. + pub fn logs_dir(&self) -> PathBuf { + self.root.join("logs") + } } pub const LOCK_FILE: &str = "mods.lock.json"; +pub const REMOVED_FILE: &str = "removed.json"; /// A name no other download or write in this process uses, so two of them never share a file. pub fn unique_suffix() -> String { diff --git a/src-tauri/src/mods/plan.rs b/src-tauri/src/mods/plan.rs index 3bcf921..1a092e9 100644 --- a/src-tauri/src/mods/plan.rs +++ b/src-tauri/src/mods/plan.rs @@ -1,8 +1,12 @@ //! The rules for changing a mod set, on plain data: what an install brings along, what a copy //! brings over, and what turning a mod on or off or removing it does to the others. Nothing here -//! touches the disk or the network; `commands.rs` does that around these. +//! touches the disk or the network; `service.rs` does that around these. +//! +//! An installed mod that needs something no build provides is installed turned off, so an +//! install never stops Play from working. use serde::Serialize; +use std::collections::BTreeSet; use ts_rs::TS; use super::catalog::{Catalog, CatalogMod, CatalogVersion, Origin, Trust}; @@ -59,6 +63,9 @@ pub struct InstallPlan { pub enable: Vec, /// Dependency ids with no fitting, non-revoked build. pub missing: Vec, + /// Whether the target is on after this install: it is new or on now, and nothing is + /// missing. With something missing it is installed turned off. + pub turns_on: bool, /// Why the install would fail, if it would. pub problem: Option, } @@ -76,6 +83,25 @@ pub struct ChangeResult { pub also_disabled: Vec, /// Dependencies nothing in the catalogue provides for this version. pub missing: Vec, + /// Mods this change installed turned off, because something they need is missing. + pub installed_off: Vec, + /// The id of the mod this change removed, while `restore_mod` can still bring it back. + pub undo: Option, +} + +impl ChangeResult { + /// A change that did nothing beyond what the report shows. + pub fn of(report: ProfileReport) -> Self { + ChangeResult { + report, + installed: Vec::new(), + also_enabled: Vec::new(), + also_disabled: Vec::new(), + missing: Vec::new(), + installed_off: Vec::new(), + undo: None, + } + } } /// What a copy did; `left_behind` are the turned-on mods of the old version that weren't @@ -153,7 +179,7 @@ pub fn resolve( } if let Some(have) = lock.get(dep) && have.abi.as_deref().is_none_or(|a| a == fit.abi) - && catalog.revocation(&have.sha256).is_none() + && have.revoked_reason(catalog).is_none() { if !have.enabled && !res.enable.contains(dep) { res.enable.push(dep.clone()); @@ -177,51 +203,142 @@ pub fn resolve( res } -/// Whether the dependencies a change brings in end up on: they do when a wanted mod ends up on. -/// A wanted mod keeps its on/off state when it replaces an installed one; new ones are on. -fn deps_on(lock: &Lock, wanted: &[String]) -> bool { - wanted +impl Resolution { + fn deps_of(&self, id: &str) -> &[String] { + self.picks + .iter() + .find(|(m, _)| m.id == id) + .map_or(&[], |(_, v)| v.deps.as_slice()) + } + + /// Whether `id`, through the builds this resolution picks, needs something missing. + fn needs_missing(&self, id: &str) -> bool { + let mut seen = BTreeSet::new(); + let mut todo = vec![id.to_string()]; + while let Some(next) = todo.pop() { + if !seen.insert(next.clone()) { + continue; + } + for dep in self.deps_of(&next) { + if self.missing.contains(dep) { + return true; + } + todo.push(dep.clone()); + } + } + false + } +} + +/// What committing a resolution to a lock does to on and off. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Outcome { + /// Picked and installed mods that end up on because a wanted mod that ends up on needs them, + /// and those wanted mods. + pub on: BTreeSet, + /// Installed mods that are off and are turned on. + pub enable: Vec, + /// Wanted mods that are installed turned off, because something they need is missing. + pub installed_off: Vec, +} + +/// A wanted mod ends up on when it is new or on now, and needs nothing missing; what it needs +/// ends up on with it. A wanted mod that is off stays off after an update, and so does what it +/// brings along. +pub fn outcome(lock: &Lock, res: &Resolution) -> Outcome { + let mut installed_off = Vec::new(); + let mut todo = Vec::new(); + for id in &res.wanted { + if res.needs_missing(id) { + installed_off.push(id.clone()); + } else if lock.get(id).is_none_or(|e| e.enabled) { + todo.push(id.clone()); + } + } + let mut on = BTreeSet::new(); + while let Some(id) = todo.pop() { + if !on.insert(id.clone()) { + continue; + } + for dep in res.deps_of(&id) { + if res.picks.iter().any(|(m, _)| &m.id == dep) { + todo.push(dep.clone()); + } else if res.enable.contains(dep) { + on.insert(dep.clone()); + } + } + } + let enable = res + .enable .iter() - .any(|id| lock.get(id).is_none_or(|e| e.enabled)) + .filter(|id| on.contains(*id)) + .cloned() + .collect(); + Outcome { + on, + enable, + installed_off, + } } pub struct Committed { pub installed: Vec, pub also_enabled: Vec, + pub installed_off: Vec, } -/// Writes a resolution into the lock. Replacing an installed mod keeps its on/off state; a mod -/// that is off stays off after an update, and so do the dependencies it brings. +/// Writes a resolution into the lock, following [`outcome`]. pub fn commit(lock: &mut Lock, res: &Resolution) -> Committed { - let on = deps_on(lock, &res.wanted); + let out = outcome(lock, res); let mut installed = Vec::new(); for (m, v) in &res.picks { - let existing = lock.get(&m.id).map(|e| e.enabled); - let enabled = if res.wanted.contains(&m.id) { - existing.unwrap_or(true) - } else { - existing.unwrap_or(false) || on - }; + let was_on = lock.get(&m.id).is_some_and(|e| e.enabled); + let wanted = res.wanted.contains(&m.id); + let enabled = out.on.contains(&m.id) || (!wanted && was_on); lock.put(LockEntry::from_catalog(m, v, enabled)); installed.push(m.id.clone()); } let mut also_enabled = Vec::new(); - if on { - for id in &res.enable { - if let Some(e) = lock.get_mut(id) - && !e.enabled - { - e.enabled = true; - also_enabled.push(id.clone()); - } + for id in &out.enable { + if let Some(e) = lock.get_mut(id) + && !e.enabled + { + e.enabled = true; + also_enabled.push(id.clone()); } } Committed { installed, also_enabled, + installed_off: out.installed_off, } } +/// Whether a resolution made before a download still describes what committing now would do. +/// `before` is the lock and resolution the player agreed to; `now` the same against the latest +/// lock and catalogue. It holds when nothing more needs downloading, nothing more is turned on, +/// and each wanted mod ends up on or off as agreed. Otherwise, why not. +pub fn still_holds(before: (&Lock, &Resolution), now: (&Lock, &Resolution)) -> Result<(), String> { + let (old_lock, old) = before; + let (lock, new) = now; + for (m, v) in &new.picks { + if !old.picks.iter().any(|(_, o)| o.sha256 == v.sha256) { + return Err(format!("{} {} is needed now", m.name, v.version)); + } + } + let (was, will) = (outcome(old_lock, old), outcome(lock, new)); + if let Some(id) = will.enable.iter().find(|id| !was.enable.contains(*id)) { + return Err(format!("{} would be turned on too", lock.name_of(id))); + } + for id in &new.wanted { + if was.on.contains(id) != will.on.contains(id) { + let state = if will.on.contains(id) { "on" } else { "off" }; + return Err(format!("{} would end up {state}", lock.name_of(id))); + } + } + Ok(()) +} + /// The plan for installing the catalogue build `sha256` into game version `tag`. `fit` is `None` /// when the version's mod interface is unknown; `running` when that version's game is running. pub fn plan_install( @@ -243,6 +360,7 @@ pub fn plan_install( }); let mut plan = InstallPlan { target: PlanItem::of(m, v), + turns_on: replaces.as_ref().is_none_or(|r| r.enabled), replaces, install: Vec::new(), enable: Vec::new(), @@ -254,14 +372,15 @@ pub fn plan_install( return Ok(plan); }; let res = resolve(catalog, lock, vec![(m.clone(), v.clone())], fit); - let on = deps_on(lock, &res.wanted); + let out = outcome(lock, &res); plan.install = res .picks .iter() .filter(|(p, _)| p.id != m.id) .map(|(p, pv)| PlanItem::of(p, pv)) .collect(); - plan.enable = if on { res.enable } else { Vec::new() }; + plan.enable = out.enable; + plan.turns_on = out.on.contains(&m.id); plan.missing = res.missing; plan.problem = check_installable(catalog, m, v, fit) .err() @@ -282,9 +401,8 @@ pub fn set_enabled( .get(id) .ok_or_else(|| ModsError::NotInstalled(id.to_string()))?; let revoked = |e: &LockEntry| { - catalog - .revocation(&e.sha256) - .map(|r| ModsError::Revoked(format!("{}: {}", e.name, r.reason))) + e.revoked_reason(catalog) + .map(|reason| ModsError::Revoked(format!("{}: {reason}", e.name))) }; let (mut also_enabled, mut also_disabled) = (Vec::new(), Vec::new()); if enabled { @@ -364,10 +482,17 @@ pub struct CopyPreview { pub from: String, pub to: String, pub items: Vec, + /// Dependencies of the items that will be downloaded as well. + pub deps: Vec, + /// Ids of mods `to` already has, off, that will be turned on. + pub enable: Vec, + /// Ids of items that will be installed turned off, because something they need is missing. + pub installed_off: Vec, } /// What copying the turned-on mods of `from` into `to` would bring over, each as the build made -/// for `to`, and the builds to install. Mods `to` already has are skipped. +/// for `to`, with what that pulls in, and the builds to install. Mods `to` already has are +/// skipped. pub fn copy_preview( catalog: &Catalog, from: (&str, &Lock), @@ -402,10 +527,20 @@ pub fn copy_preview( items.push(item(CopyOutcome::NoBuild, None)); } } + let res = resolve(catalog, to.1, picks.clone(), fit); + let out = outcome(to.1, &res); let preview = CopyPreview { from: from.0.to_string(), to: to.0.to_string(), items, + deps: res + .picks + .iter() + .filter(|(m, _)| !res.wanted.contains(&m.id)) + .map(|(m, v)| PlanItem::of(m, v)) + .collect(), + enable: out.enable, + installed_off: out.installed_off, }; (preview, picks) } @@ -771,5 +906,183 @@ mod tests { let picked: Vec<&str> = picks.iter().map(|(m, _)| m.id.as_str()).collect(); assert_eq!(picked, ["cogwork", "lanterns"]); assert_eq!(preview.items[0].trust, Some(Trust::Reviewed)); + // What the items pull in: glow from the lanterns source; showcase is already there. + assert_eq!(ids(&preview.deps), ["glow"]); + assert!(preview.enable.is_empty() && preview.installed_off.is_empty()); + } + + #[test] + fn a_copy_lists_what_it_turns_on_and_what_stays_off() { + let c = world(); + let from = lock(vec![ + entry("cogwork", &["showcase"], Some(OLD_ABI)), + entry("shiny", &["rcdep"], Some(OLD_ABI)), + ]); + let mut showcase_off = entry("showcase", &[], Some(ABI)); + showcase_off.enabled = false; + let to = lock(vec![showcase_off]); + let (preview, picks) = copy_preview(&c, ("v1", &from), ("v2", &to), fit()); + assert_eq!(preview.enable, ["showcase"]); + assert_eq!( + preview.installed_off, + ["shiny"], + "rcdep has only a prerelease" + ); + + let mut l = to.clone(); + let done = commit(&mut l, &resolve(&c, &to, picks, fit())); + assert!(l.get("cogwork").unwrap().enabled && l.get("showcase").unwrap().enabled); + assert!(!l.get("shiny").unwrap().enabled); + assert_eq!(done.installed_off, ["shiny"]); + assert_eq!(done.also_enabled, ["showcase"]); + } + + /// `turnsOn = (replaces === null || replaces.enabled) && missing.length === 0`, and the + /// commit does what the plan said. + #[test] + fn turns_on_rules() { + let c = world(); + let off = |id: &str, deps: &[&str]| { + let mut e = entry(id, deps, Some(OLD_ABI)); + e.enabled = false; + e + }; + let mut showcase_off = entry("showcase", &[], Some(ABI)); + showcase_off.enabled = false; + // What, lock, build, turns on, installed off, also enabled. + type Case<'a> = (&'a str, Lock, String, bool, &'a [&'a str], &'a [&'a str]); + let cases: Vec = vec![ + ( + "new, nothing missing", + lock(vec![]), + sha("cogwork", "0.2.0"), + true, + &[], + &[], + ), + ( + "new, a dependency is missing", + lock(vec![]), + sha("shiny", "1.0.0"), + false, + &["shiny"], + &[], + ), + ( + "an update of a mod that is on, a dependency missing", + lock(vec![entry("shiny", &["rcdep"], Some(OLD_ABI))]), + sha("shiny", "1.0.0"), + false, + &["shiny"], + &[], + ), + ( + "an update of a mod that is off", + lock(vec![off("cogwork", &["showcase"])]), + sha("cogwork", "0.2.0"), + false, + &[], + &[], + ), + ( + "a dependency that is off is turned on", + lock(vec![showcase_off.clone()]), + sha("cogwork", "0.2.0"), + true, + &[], + &["showcase"], + ), + ( + "but not for a mod installed turned off", + lock(vec![showcase_off, entry("rcdep", &[], Some(OLD_ABI))]), + sha("bad", "1.0.0"), + false, + &["bad"], + &[], + ), + ]; + for (what, l, build, turns_on, off_ids, enabled) in cases { + let plan = plan_install(&c, &l, "v1", &build, Some(fit()), false).unwrap(); + assert_eq!(plan.turns_on, turns_on, "{what}: plan"); + assert_eq!(plan.enable, enabled, "{what}: plan enables"); + let rule = plan.replaces.as_ref().is_none_or(|r| r.enabled) && plan.missing.is_empty(); + assert_eq!(plan.turns_on, rule, "{what}: the contract's rule"); + + let (m, v) = c.find(&build).unwrap(); + let mut after = l.clone(); + let res = resolve(&c, &l, vec![(m.clone(), v.clone())], fit()); + let done = commit(&mut after, &res); + let target = after.get(&m.id).unwrap(); + assert_eq!(target.enabled, turns_on, "{what}: committed"); + assert_eq!(done.installed_off, off_ids, "{what}: installed off"); + assert_eq!(done.also_enabled, enabled, "{what}: also enabled"); + for dep in res.picks.iter().filter(|(p, _)| p.id != m.id) { + assert_eq!( + after.get(&dep.0.id).unwrap().enabled, + turns_on, + "{what}: {} comes along on or off with it", + dep.0.id + ); + } + } + } + + #[test] + fn a_plan_holds_until_the_profile_changes_under_it() { + let c = world(); + let pick = |id: &str, v: &str| { + let (m, v) = c.find(&sha(id, v)).unwrap(); + (m.clone(), v.clone()) + }; + let before = lock(vec![]); + let res = resolve(&c, &before, vec![pick("cogwork", "0.2.0")], fit()); + assert!(still_holds((&before, &res), (&before, &res)).is_ok()); + + // Showcase got installed meanwhile: less to do, still fine. + let with_showcase = lock(vec![entry("showcase", &[], Some(ABI))]); + let now = resolve(&c, &with_showcase, vec![pick("cogwork", "0.2.0")], fit()); + assert!(still_holds((&before, &res), (&with_showcase, &now)).is_ok()); + + // Planned with showcase installed, which was removed meanwhile: it needs downloading. + let res = resolve(&c, &with_showcase, vec![pick("cogwork", "0.2.0")], fit()); + let now = resolve(&c, &before, vec![pick("cogwork", "0.2.0")], fit()); + let why = still_holds((&with_showcase, &res), (&before, &now)).unwrap_err(); + assert!(why.contains("showcase"), "{why}"); + + // Showcase was turned off meanwhile: committing would turn it on unasked. + let mut showcase_off = with_showcase.clone(); + showcase_off.get_mut("showcase").unwrap().enabled = false; + let now = resolve(&c, &showcase_off, vec![pick("cogwork", "0.2.0")], fit()); + assert!(still_holds((&with_showcase, &res), (&showcase_off, &now)).is_err()); + + // The mod being updated was turned off meanwhile: it would end up off. + let on = lock(vec![ + entry("cogwork", &["showcase"], Some(OLD_ABI)), + entry("showcase", &[], Some(ABI)), + ]); + let mut off = on.clone(); + off.get_mut("cogwork").unwrap().enabled = false; + let res = resolve(&c, &on, vec![pick("cogwork", "0.2.0")], fit()); + let now = resolve(&c, &off, vec![pick("cogwork", "0.2.0")], fit()); + let why = still_holds((&on, &res), (&off, &now)).unwrap_err(); + assert!(why.contains("off"), "{why}"); + } + + #[test] + fn a_recorded_revocation_is_honoured_without_the_catalogue() { + let c = catalog(vec![], &[]); + let mut pulled = entry("pulled", &[], Some(ABI)); + pulled.enabled = false; + pulled.revoked = Some(super::super::profile::Revocation { + reason: "deletes saves".into(), + at: "2026-11-05T00:00:00Z".into(), + }); + let mut needs = entry("needs", &["pulled"], Some(ABI)); + needs.enabled = false; + let mut l = lock(vec![pulled, needs]); + let err = set_enabled(&mut l, &c, "pulled", true).unwrap_err(); + assert!(matches!(err, ModsError::Revoked(ref d) if d.contains("deletes saves"))); + assert!(set_enabled(&mut l, &c, "needs", true).is_err()); + assert!(!l.get("pulled").unwrap().enabled && !l.get("needs").unwrap().enabled); } } diff --git a/src-tauri/src/mods/profile.rs b/src-tauri/src/mods/profile.rs index 5ac5a0a..9ddef06 100644 --- a/src-tauri/src/mods/profile.rs +++ b/src-tauri/src/mods/profile.rs @@ -37,6 +37,19 @@ pub struct LockEntry { pub source_commit: Option, pub icon: Option, pub installed_at: String, + /// Set when the index revoked this build, and kept even if the index can't be loaded later: + /// the mod stays revoked, and off, until it is replaced or removed. + #[serde(default)] + pub revoked: Option, +} + +/// Why and when a revocation was applied to an installed build. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +pub struct Revocation { + pub reason: String, + /// RFC 3339 time the launcher applied it. + pub at: String, } impl LockEntry { @@ -60,9 +73,19 @@ impl LockEntry { source_commit: Some(v.source_commit.clone()), icon: m.icon.clone(), installed_at: now(), + revoked: None, } } + /// Why this build may not be used: the revocation recorded in the lock file, else the + /// catalogue's. + pub fn revoked_reason(&self, catalog: &Catalog) -> Option { + self.revoked + .as_ref() + .map(|r| r.reason.clone()) + .or_else(|| catalog.revocation(&self.sha256).map(|r| r.reason.clone())) + } + pub fn catalog_key(&self) -> Option { self.origin.key(&self.id) } @@ -179,7 +202,10 @@ pub struct InstalledMod { #[serde(flatten)] pub entry: LockEntry, pub status: ModStatus, + /// Dependencies that aren't on: not installed, or installed and off. pub missing_deps: Vec, + /// The part of `missing_deps` with no build installed at all. + pub deps_not_installed: Vec, pub revoked_reason: Option, pub update: Option, } @@ -301,13 +327,18 @@ pub fn report( let mut mods = Vec::new(); let mut blocking = Vec::new(); for entry in &lock.mods { - let revoked = catalog.revocation(&entry.sha256); + let revoked = entry.revoked_reason(catalog); let missing: Vec = entry .deps .iter() .filter(|d| !BUILTIN.contains(&d.as_str()) && !enabled.contains(d.as_str())) .cloned() .collect(); + let not_installed = missing + .iter() + .filter(|d| lock.get(d).is_none()) + .cloned() + .collect(); let status = if revoked.is_some() { ModStatus::Revoked } else if !entry.enabled { @@ -331,7 +362,8 @@ pub fn report( entry: entry.clone(), status, missing_deps: missing, - revoked_reason: revoked.map(|r| r.reason.clone()), + deps_not_installed: not_installed, + revoked_reason: revoked, }); } ProfileReport { @@ -344,11 +376,21 @@ pub fn report( } } -/// Turns off every revoked mod. Returns whether anything changed. +/// Records every revocation the catalogue lists for an installed build, and turns off every +/// revoked mod. Returns whether anything changed. pub fn apply_revocations(lock: &mut Lock, catalog: &Catalog) -> bool { let mut changed = false; for m in &mut lock.mods { - if m.enabled && catalog.revocation(&m.sha256).is_some() { + if m.revoked.is_none() + && let Some(r) = catalog.revocation(&m.sha256) + { + m.revoked = Some(Revocation { + reason: r.reason.clone(), + at: now(), + }); + changed = true; + } + if m.enabled && m.revoked.is_some() { m.enabled = false; changed = true; } @@ -389,6 +431,7 @@ pub(crate) mod tests { source_commit: None, icon: None, installed_at: String::new(), + revoked: None, } } @@ -630,7 +673,67 @@ pub(crate) mod tests { let c = catalog(vec![], &[&sha("a", "0.1.0")]); assert!(apply_revocations(&mut l, &c)); assert!(!l.get("a").unwrap().enabled); + assert_eq!( + l.get("a").unwrap().revoked.as_ref().unwrap().reason, + "broken" + ); assert!(l.get("b").unwrap().enabled); + assert!(l.get("b").unwrap().revoked.is_none()); assert!(!apply_revocations(&mut l, &c)); } + + /// The index can't be loaded any more: the lock file still knows, so the mod stays revoked + /// and says why. + #[test] + fn a_recorded_revocation_survives_without_the_index() { + let mut l = lock(vec![entry("a", &[], Some(ABI))]); + apply_revocations(&mut l, &catalog(vec![], &[&sha("a", "0.1.0")])); + let saved = serde_json::to_vec(&l).unwrap(); + let mut l: Lock = serde_json::from_slice(&saved).unwrap(); + + let unavailable = catalog(vec![], &[]); + let r = report( + "v1", + &l, + Some(ABI.into()), + None, + &unavailable, + fit().platform, + vec![], + ); + assert_eq!(r.mods[0].status, ModStatus::Revoked); + assert_eq!(r.mods[0].revoked_reason.as_deref(), Some("broken")); + + // Even if something turned it back on, the next check turns it off again. + l.mods[0].enabled = true; + assert!(apply_revocations(&mut l, &unavailable)); + assert!(!l.mods[0].enabled); + + // A lock file written before revocations were recorded still reads. + let old = r#"{"schema":1,"mods":[]}"#; + assert!(serde_json::from_str::(old).is_ok()); + } + + #[test] + fn missing_dependencies_say_whether_they_are_installed() { + let mut off = entry("showcase", &[], Some(ABI)); + off.enabled = false; + let l = lock(vec![ + entry("cogwork", &["base", "showcase", "signals"], Some(ABI)), + off, + ]); + let r = report( + "v1", + &l, + Some(ABI.into()), + None, + &catalog(vec![], &[]), + fit().platform, + vec![], + ); + let cogwork = r.mods.iter().find(|m| m.entry.id == "cogwork").unwrap(); + assert_eq!(cogwork.status, ModStatus::MissingDeps); + assert_eq!(cogwork.missing_deps, ["showcase", "signals"]); + assert_eq!(cogwork.deps_not_installed, ["signals"]); + } } diff --git a/src-tauri/src/mods/service.rs b/src-tauri/src/mods/service.rs index 35d97d3..a0ba424 100644 --- a/src-tauri/src/mods/service.rs +++ b/src-tauri/src/mods/service.rs @@ -1,111 +1,82 @@ -//! The steps the commands are made of that need the running app: its state, its events and the -//! network. Each one is small; the rules they apply live in `plan.rs` and `profile.rs`. +//! The mod manager's operations, one per command, on a [`Ctx`]. The rules they apply live in +//! `plan.rs` and `profile.rs`; this is where the disk, the network and the locks come in. +//! +//! - Every change to a profile or the store happens under `ModsLock`, so two clicks never write +//! `mods.lock.json` at once. Downloads happen before taking it. +//! - An install resolves what it needs, downloads it, then takes the lock and checks again +//! against the latest profile and catalogue before writing anything (see [`commit`]). +//! - Hashing and copying files run off the async runtime ([`blocking`]). -use std::sync::Arc; +use std::path::PathBuf; use std::time::Duration; -use tauri::{AppHandle, Emitter, Manager}; -use tokio::sync::{Mutex, OwnedMutexGuard}; +use time::OffsetDateTime; +use time::format_description::well_known::Rfc3339; +use tokio::sync::OwnedMutexGuard; use super::abi::{abi_for_tag, cached_abi}; use super::archive::current_platform; -use super::catalog::{ - self, Catalog, CatalogMod, CatalogState, CatalogVersion, IndexStatus, SourceResult, -}; +use super::catalog::{self, Catalog, CatalogMod, CatalogVersion, IndexStatus, SourceResult}; +use super::context::{Ctx, blocking, check_sha, ensure_installed}; use super::error::ModsError; -use super::index::{self, IndexCache, is_sha256}; -use super::install::{self, ModProgress, ProgressStage}; +use super::index::{self, IndexCache, IndexState, RESERVED_ID}; +use super::inspect::{self, FileInspection}; +use super::install::{self, ProgressStage}; use super::paths::{Paths, write_atomic}; -use super::plan::{self, ChangeResult}; +use super::plan::{ + self, ChangeResult, CopyOutcome, CopyPreview, CopyResult, InstallPlan, Resolution, +}; use super::profile::{self, Fit, Lock, ProfileReport}; -use super::sources; -use super::store::{self, Reservation, Reservations}; +use super::sources::{self, SourceFetch}; +use super::store::{self, Removed, Reservation}; +use crate::download::{read_capped, sha256_file}; use crate::error::AppError; -/// Held while a profile changes, and by a launch from its last check until the game counts as -/// running. -#[derive(Default)] -pub struct ModsLock(Arc>); - -impl ModsLock { - pub async fn acquire(&self) -> OwnedMutexGuard<()> { - self.0.clone().lock_owned().await - } -} - -pub async fn lock_mods(app: &AppHandle) -> OwnedMutexGuard<()> { - app.state::().acquire().await -} - +/// For the index, sources, icons and the ABI: redirects stay on GitHub, over https. pub fn http() -> Result { Ok(reqwest::Client::builder() .connect_timeout(Duration::from_secs(15)) + .redirect(crate::download::redirect_policy()) .build()?) } -fn executable_name() -> &'static str { - if cfg!(target_os = "windows") { - "weft.exe" - } else { - "weft" - } -} - -pub fn ensure_installed(paths: &Paths, tag: &str) -> Result<(), ModsError> { - crate::install::validate_name(tag).map_err(|_| ModsError::InvalidName(tag.to_string()))?; - if paths - .versions_dir() - .join(tag) - .join(executable_name()) - .exists() - { - Ok(()) - } else { - Err(ModsError::VersionNotInstalled(tag.to_string())) - } -} +// --------------------------------------------------------------------------------------------- +// Catalogue -/// The page names builds by hash; anything else is refused before it reaches a path or a lookup. -pub fn check_sha(sha256: &str) -> Result { - let sha = sha256.to_ascii_lowercase(); - if is_sha256(&sha) { - Ok(sha) - } else { - Err(ModsError::InvalidName(sha256.to_string())) - } +fn rfc3339(t: OffsetDateTime) -> String { + t.format(&Rfc3339).unwrap_or_default() } -/// The running game read its mods at start; changing them under it would only confuse. -pub fn ensure_not_running(app: &AppHandle, tag: &str) -> Result<(), ModsError> { - match crate::launch::running_game(app) { - Some(running) if running == tag => Err(ModsError::GameRunning(running)), - _ => Ok(()), +fn source_result(repo: String, f: SourceFetch) -> SourceResult { + SourceResult { + repo, + mods: f.mods, + freshness: f.freshness, + checked: f.checked, + error: f.error.map(AppError::from), } } -pub fn is_running(app: &AppHandle, tag: &str) -> bool { - crate::launch::running_game(app).is_some_and(|r| r == tag) -} - -pub fn reservations(app: &AppHandle) -> Reservations { - app.state::().inner().clone() +fn build_catalog(state: IndexState, sources: Vec) -> Catalog { + let status = IndexStatus { + freshness: state.freshness, + generated: state.index.as_ref().map(|i| i.generated.clone()), + signed_at: state.signed_at.map(rfc3339), + error: state.error.map(AppError::from), + }; + catalog::build(state.index.as_ref(), status, sources) } -pub fn collect_garbage(app: &AppHandle, paths: &Paths) { - if let Err(e) = store::collect_garbage(paths, &reservations(app)) { - eprintln!("mods: garbage collection skipped: {e}"); - } +fn index_cache(paths: &Paths) -> IndexCache { + IndexCache::new(&paths.cache_dir(), index::public_key()) } -// --------------------------------------------------------------------------------------------- -// Catalogue - -pub async fn load_catalog(app: &AppHandle, refresh: bool) -> Result { - let paths = Paths::of(app)?; - let client = http().unwrap_or_else(|_| reqwest::Client::new()); - let cache = IndexCache::new(&paths.cache_dir(), index::public_key()); +/// The index and every source; refreshed from the network when `refresh` is set. The page sees +/// this list, and installs only what is in it. +pub async fn load_catalog(ctx: &Ctx, refresh: bool) -> Result { + let client = http()?; + let cache = index_cache(&ctx.paths); let url = index::index_url(); - let now = time::OffsetDateTime::now_utc(); - let state = index::load(&cache, refresh, now, async { + let state = index::load(&cache, refresh, OffsetDateTime::now_utc(), async { let bytes = index::fetch_bytes(&client, &url).await?; let sig = index::fetch_bytes(&client, &format!("{url}.minisig")).await?; let sig = String::from_utf8(sig) @@ -113,60 +84,44 @@ pub async fn load_catalog(app: &AppHandle, refresh: bool) -> Result().0.lock().unwrap() = Some(catalog.clone()); + let catalog = build_catalog(state, fetched); + ctx.hold_catalog(catalog.clone()); Ok(catalog) } -/// The list the page last saw, or the cached one: never waits on the network. -pub async fn current_catalog(app: &AppHandle) -> Result { - let held = app.state::().0.lock().unwrap().clone(); - match held { - Some(c) => Ok(c), - None => load_catalog(app, false).await, - } +/// The catalogue from what is on disk: the cached index and each source's last answer. +pub fn offline_catalog(paths: &Paths) -> Catalog { + let state = index::cached(&index_cache(paths), OffsetDateTime::now_utc()); + let sources = sources::list(paths) + .into_iter() + .map(|repo| { + let f = sources::cached(paths, &repo); + source_result(repo, f) + }) + .collect(); + build_catalog(state, sources) } -/// A catalogue icon as PNG bytes, from the cache or the network. -pub async fn icon_png(app: &AppHandle, url: &str) -> Result, ModsError> { - let known = app - .state::() - .0 - .lock() - .unwrap() - .as_ref() - .is_some_and(|c| c.mods.iter().any(|m| m.icon.as_deref() == Some(url))); - if !known || !url.starts_with("https://") { - return Err(ModsError::NotInCatalog(url.to_string())); - } - let cache = Paths::of(app)?.cache_dir().join("mod-icons").join(format!( - "{}.png", - &crate::download::sha256_hex(url.as_bytes())[..32] - )); - if let Ok(bytes) = std::fs::read(&cache) { - return Ok(bytes); +/// The list the page last saw, else the one on disk: never touches the network. +pub async fn current_catalog(ctx: &Ctx) -> Result { + if let Some(c) = ctx.held_catalog() { + return Ok(c); } - let response = http()? + let paths = ctx.paths.clone(); + blocking(move || Ok(offline_catalog(&paths))).await +} + +pub const MAX_ICON_BYTES: usize = 512 * 1024; +const PNG_MAGIC: &[u8] = b"\x89PNG\r\n\x1a\n"; + +/// An icon's bytes, read up to [`MAX_ICON_BYTES`] and only if they are a PNG. +pub async fn fetch_icon(client: &reqwest::Client, url: &str) -> Result, ModsError> { + let response = client .get(url) .timeout(Duration::from_secs(15)) .send() @@ -177,14 +132,64 @@ pub async fn icon_png(app: &AppHandle, url: &str) -> Result, ModsError> url: url.to_string(), }); } - let bytes = response.bytes().await?.to_vec(); - if bytes.len() > 512 * 1024 || !bytes.starts_with(b"\x89PNG\r\n\x1a\n") { - return Err(ModsError::TooLarge("icon is not a PNG under 512 KB".into())); + let bytes = read_capped(response, MAX_ICON_BYTES) + .await? + .ok_or_else(|| ModsError::TooLarge("icon over 512 KB".into()))?; + if !bytes.starts_with(PNG_MAGIC) { + return Err(ModsError::TooLarge("icon is not a PNG".into())); + } + Ok(bytes) +} + +/// A catalogue icon as PNG bytes, from the cache or the network. Only icons the catalogue +/// names, and only over https. +pub async fn icon_png(ctx: &Ctx, url: &str) -> Result, ModsError> { + let known = ctx + .held_catalog() + .is_some_and(|c| c.mods.iter().any(|m| m.icon.as_deref() == Some(url))); + if !known || !url.starts_with("https://") { + return Err(ModsError::NotInCatalog(url.to_string())); + } + let cache = ctx.paths.cache_dir().join("mod-icons").join(format!( + "{}.png", + &crate::download::sha256_hex(url.as_bytes())[..32] + )); + if let Ok(bytes) = std::fs::read(&cache) + && bytes.len() <= MAX_ICON_BYTES + && bytes.starts_with(PNG_MAGIC) + { + return Ok(bytes); } + let bytes = fetch_icon(&http()?, url).await?; let _ = write_atomic(&cache, &bytes); Ok(bytes) } +// --------------------------------------------------------------------------------------------- +// Sources + +pub fn source_list(ctx: &Ctx) -> Vec { + sources::list(&ctx.paths) +} + +/// Adds a repo as GitHub spells it. Adding one already there, in any case, keeps one entry. +pub async fn add_source(ctx: &Ctx, repo: &str) -> Result, ModsError> { + let repo = sources::normalize(repo)?; + let canonical = sources::canonical_name(&http()?, &repo).await?; + let _held = ctx.lock_mods().await; + let repos = sources::with_repo(sources::list(&ctx.paths), &canonical); + sources::save(&ctx.paths, &repos)?; + Ok(repos) +} + +/// Mods installed from the repo stay installed; they just stop getting updates. +pub async fn remove_source(ctx: &Ctx, repo: &str) -> Result, ModsError> { + let _held = ctx.lock_mods().await; + let repos = sources::without_repo(sources::list(&ctx.paths), repo); + sources::save(&ctx.paths, &repos)?; + Ok(repos) +} + // --------------------------------------------------------------------------------------------- // Profiles @@ -226,8 +231,8 @@ pub async fn require_abi(paths: &Paths, catalog: &Catalog, tag: &str) -> Result< } /// Mods the player put into their own Weft folder, which every version loads. -async fn unmanaged(app: &AppHandle) -> Vec { - let Some(folder) = crate::launch::find_game_folder(app).await else { +async fn unmanaged(paths: &Paths) -> Vec { + let Some(folder) = crate::launch::find_game_folder_in(&paths.logs_dir()).await else { return Vec::new(); }; let Ok(entries) = std::fs::read_dir(folder.join("mods")) else { @@ -243,14 +248,13 @@ async fn unmanaged(app: &AppHandle) -> Vec { } pub async fn build_report( - app: &AppHandle, - paths: &Paths, + ctx: &Ctx, tag: &str, lock: &Lock, catalog: &Catalog, network: bool, ) -> ProfileReport { - let (abi, abi_error) = version_abi(paths, catalog, tag, network).await; + let (abi, abi_error) = version_abi(&ctx.paths, catalog, tag, network).await; profile::report( tag, lock, @@ -258,118 +262,498 @@ pub async fn build_report( abi_error, catalog, current_platform(), - unmanaged(app).await, + unmanaged(&ctx.paths).await, ) } -/// The profile after a change that installed nothing. -pub async fn change_result( - app: &AppHandle, - paths: &Paths, +/// The profile after a change, with nothing else to report yet. +async fn change_result(ctx: &Ctx, tag: &str, lock: &Lock, catalog: &Catalog) -> ChangeResult { + ChangeResult::of(build_report(ctx, tag, lock, catalog, false).await) +} + +/// Makes the version's `mods/` folder hold exactly its turned-on mods. +async fn reconcile(ctx: &Ctx, tag: &str, lock: &Lock) -> Result<(), ModsError> { + let (paths, tag, lock) = (ctx.paths.clone(), tag.to_string(), lock.clone()); + blocking(move || store::reconcile(&paths, &tag, &lock)).await +} + +/// Writes a changed mod set under `ModsLock`: the lock file, then the undo record (kept for a +/// removal, else cleared, since this is the version's next change), then the folder. `held` +/// reservations are let go once the lock file names their builds, and unused store files go. +async fn save( + ctx: &Ctx, tag: &str, lock: &Lock, - catalog: &Catalog, -) -> ChangeResult { - ChangeResult { - report: build_report(app, paths, tag, lock, catalog, false).await, - installed: Vec::new(), - also_enabled: Vec::new(), - also_disabled: Vec::new(), - missing: Vec::new(), + undo: Option<&Removed>, + held: Vec, +) -> Result<(), ModsError> { + store::write_lock(&ctx.paths, tag, lock)?; + match undo { + Some(removed) => store::write_removed(&ctx.paths, tag, removed)?, + None => store::clear_removed(&ctx.paths, tag)?, } + drop(held); + reconcile(ctx, tag, lock).await?; + ctx.collect_garbage().await; + Ok(()) } -fn emit_progress(app: &AppHandle, sha256: &str, mod_id: &str, stage: ProgressStage, percent: u32) { - let _ = app.emit( - crate::ipc::MOD_PROGRESS, - ModProgress { - sha256: sha256.to_string(), - mod_id: mod_id.to_string(), - stage, - percent, - }, - ); +/// The version's mod set, with revocations applied. +pub async fn profile(ctx: &Ctx, tag: &str) -> Result { + ensure_installed(&ctx.paths, tag)?; + let catalog = current_catalog(ctx).await?; + let lock = { + let _held = ctx.lock_mods().await; + let mut lock = store::read_lock(&ctx.paths, tag)?; + if profile::apply_revocations(&mut lock, &catalog) { + store::write_lock(&ctx.paths, tag, &lock)?; + reconcile(ctx, tag, &lock).await?; + } + lock + }; + Ok(build_report(ctx, tag, &lock, &catalog, true).await) } -/// Downloads what `wanted` needs, then writes it into the profile. `held` are reservations the -/// caller already took (a local file admitted into the store). -pub async fn run_install( - app: &AppHandle, - paths: &Paths, +/// What `install` would do with this build, without doing it. +pub async fn plan(ctx: &Ctx, tag: &str, sha256: &str) -> Result { + ensure_installed(&ctx.paths, tag)?; + let sha = check_sha(sha256)?; + let catalog = current_catalog(ctx).await?; + let lock = store::read_lock(&ctx.paths, tag)?; + let (abi, _) = version_abi(&ctx.paths, &catalog, tag, true).await; + let fit = abi.as_deref().map(Fit::here); + plan::plan_install(&catalog, &lock, tag, &sha, fit, ctx.is_running(tag)) +} + +// --------------------------------------------------------------------------------------------- +// Installing: stage, fetch, commit + +/// An install resolved against the profile as it was, with its builds reserved. +pub struct Staged { + tag: String, + catalog: Catalog, + abi: String, + wanted: Vec<(CatalogMod, CatalogVersion)>, + snapshot: Lock, + res: Resolution, + held: Vec, +} + +/// Resolves what `wanted` needs. `held` are reservations the caller already took. +pub fn stage( + ctx: &Ctx, tag: &str, - catalog: &Catalog, - abi: &str, + catalog: Catalog, + abi: String, wanted: Vec<(CatalogMod, CatalogVersion)>, mut held: Vec, -) -> Result { - let fit = Fit::here(abi); - let snapshot = store::read_lock(paths, tag)?; - let res = plan::resolve(catalog, &snapshot, wanted, fit); - let reservations = reservations(app); +) -> Result { + let snapshot = store::read_lock(&ctx.paths, tag)?; + let res = plan::resolve(&catalog, &snapshot, wanted.clone(), Fit::here(&abi)); held.extend( res.picks .iter() - .map(|(_, v)| reservations.reserve(&v.sha256)), + .map(|(_, v)| ctx.reservations.reserve(&v.sha256)), ); - for (m, v) in &res.picks { - install::fetch_to_store(paths, m, v, fit.platform, |stage, percent| { - emit_progress(app, &v.sha256, &m.id, stage, percent) + Ok(Staged { + tag: tag.to_string(), + catalog, + abi, + wanted, + snapshot, + res, + held, + }) +} + +/// Downloads every build the install needs into the store, unless it is there and intact. +pub async fn fetch(ctx: &Ctx, staged: &Staged) -> Result<(), ModsError> { + let platform = Fit::here(&staged.abi).platform; + for (m, v) in &staged.res.picks { + install::fetch_to_store(&ctx.paths, m, v, platform, |stage, percent| { + ctx.emit(&v.sha256, &m.id, stage, percent) }) .await?; } + Ok(()) +} + +/// Writes a downloaded install into the profile. Under `ModsLock`, it first checks that what +/// the player agreed to still holds: the game version is still installed and not running, no +/// build is revoked in the latest catalogue, and resolving again against the latest profile +/// needs nothing more and changes nothing else. Otherwise `ModChangedMeanwhile`, and nothing is +/// written. +pub async fn commit(ctx: &Ctx, staged: Staged) -> Result { + let Staged { + tag, + catalog, + abi, + wanted, + snapshot, + res, + held, + } = staged; + let _guard = ctx.lock_mods().await; + let changed = ModsError::ChangedMeanwhile; + if ensure_installed(&ctx.paths, &tag).is_err() { + return Err(changed(format!("{tag} was removed"))); + } + if ctx.is_running(&tag) { + return Err(changed(format!("{tag} was started"))); + } + let catalog = ctx.held_catalog().unwrap_or(catalog); for (m, v) in &res.picks { - emit_progress(app, &v.sha256, &m.id, ProgressStage::Installing, 100); + if let Some(r) = catalog.revocation(&v.sha256) { + return Err(changed(format!( + "{} {} was revoked: {}", + m.name, v.version, r.reason + ))); + } } + let mut lock = store::read_lock(&ctx.paths, &tag)?; + let now = plan::resolve(&catalog, &lock, wanted, Fit::here(&abi)); + plan::still_holds((&snapshot, &res), (&lock, &now)).map_err(changed)?; - let _guard = lock_mods(app).await; - ensure_not_running(app, tag)?; - let mut lock = store::read_lock(paths, tag)?; - let done = plan::commit(&mut lock, &res); - store::write_lock(paths, tag, &lock)?; - drop(held); - store::reconcile(paths, tag, &lock)?; - collect_garbage(app, paths); + for (m, v) in &now.picks { + ctx.emit(&v.sha256, &m.id, ProgressStage::Installing, 100); + } + let done = plan::commit(&mut lock, &now); + save(ctx, &tag, &lock, None, held).await?; Ok(ChangeResult { installed: done.installed, also_enabled: done.also_enabled, - missing: res.missing, - ..change_result(app, paths, tag, &lock, catalog).await + installed_off: done.installed_off, + missing: now.missing, + ..change_result(ctx, &tag, &lock, &catalog).await + }) +} + +async fn run_install( + ctx: &Ctx, + tag: &str, + catalog: Catalog, + abi: String, + wanted: Vec<(CatalogMod, CatalogVersion)>, + held: Vec, +) -> Result { + let staged = stage(ctx, tag, catalog, abi, wanted, held)?; + fetch(ctx, &staged).await?; + commit(ctx, staged).await +} + +/// Installs the catalogue build with this hash, and what it needs. Installing another build of +/// an installed mod replaces it and keeps it on or off as it was; a mod that needs something +/// missing is installed turned off. +pub async fn install(ctx: &Ctx, tag: &str, sha256: &str) -> Result { + ensure_installed(&ctx.paths, tag)?; + let sha = check_sha(sha256)?; + ctx.ensure_not_running(tag)?; + let catalog = current_catalog(ctx).await?; + let (m, v) = catalog + .find(&sha) + .map(|(m, v)| (m.clone(), v.clone())) + .ok_or_else(|| ModsError::NotInCatalog(sha.clone()))?; + let abi = require_abi(&ctx.paths, &catalog, tag).await?; + plan::check_installable(&catalog, &m, &v, Fit::here(&abi))?; + run_install(ctx, tag, catalog, abi, vec![(m, v)], Vec::new()).await +} + +/// What the launcher can tell about a zip from the player's computer, before installing it. +pub async fn inspect_file(ctx: &Ctx, tag: &str, path: &str) -> Result { + ensure_installed(&ctx.paths, tag)?; + let catalog = current_catalog(ctx).await?; + let (abi, _) = version_abi(&ctx.paths, &catalog, tag, true).await; + let lock = store::read_lock(&ctx.paths, tag)?; + let path = PathBuf::from(path); + blocking(move || inspect::inspect(&path, &catalog, abi.as_deref(), current_platform(), &lock)) + .await +} + +/// Installs a zip from the player's computer. `sha256` is what `inspect_file` showed, so a file +/// swapped in between is refused. A file that is in the catalogue installs from these same +/// bytes, with the catalogue's metadata, trust and dependencies; anything else is Unverified. +pub async fn install_file( + ctx: &Ctx, + tag: &str, + path: &str, + sha256: &str, +) -> Result { + ensure_installed(&ctx.paths, tag)?; + let sha = check_sha(sha256)?; + ctx.ensure_not_running(tag)?; + let catalog = current_catalog(ctx).await?; + if let Some(r) = catalog.revocation(&sha) { + return Err(ModsError::Revoked(r.reason.clone())); + } + let source = PathBuf::from(path); + inspect::checked_size(&source)?; + let hold = ctx.reservations.reserve(&sha); + let admit = |id: Option, platform| { + let (paths, source, sha) = (ctx.paths.clone(), source.clone(), sha.clone()); + blocking(move || store::admit_local(&paths, &source, &sha, id.as_deref(), platform)) + }; + + if let Some((m, v)) = catalog.find(&sha).map(|(m, v)| (m.clone(), v.clone())) { + let abi = require_abi(&ctx.paths, &catalog, tag).await?; + let fit = Fit::here(&abi); + plan::check_installable(&catalog, &m, &v, fit)?; + admit(Some(m.id.clone()), fit.platform).await?; + return run_install(ctx, tag, catalog, abi, vec![(m, v)], vec![hold]).await; + } + + let info = admit(None, current_platform()).await?; + if info.id == RESERVED_ID { + return Err(ModsError::IdReserved(info.id)); + } + let mut entry = inspect::local_entry(&info, &ctx.paths.store_object(&sha), &source, &sha)?; + + let _guard = ctx.lock_mods().await; + if ensure_installed(&ctx.paths, tag).is_err() { + return Err(ModsError::ChangedMeanwhile(format!("{tag} was removed"))); + } + if ctx.is_running(tag) { + return Err(ModsError::ChangedMeanwhile(format!("{tag} was started"))); + } + let mut lock = store::read_lock(&ctx.paths, tag)?; + entry.enabled = lock.get(&entry.id).is_none_or(|e| e.enabled); + lock.put(entry); + save(ctx, tag, &lock, None, vec![hold]).await?; + Ok(ChangeResult { + installed: vec![info.id], + ..change_result(ctx, tag, &lock, &catalog).await + }) +} + +// --------------------------------------------------------------------------------------------- +// On, off, remove, undo + +/// Turning a mod on turns on the installed mods it needs, and is refused when one of them is +/// revoked; turning it off turns off what needs it. +pub async fn set_enabled( + ctx: &Ctx, + tag: &str, + id: &str, + enabled: bool, +) -> Result { + ensure_installed(&ctx.paths, tag)?; + ctx.ensure_not_running(tag)?; + let catalog = current_catalog(ctx).await?; + let _guard = ctx.lock_mods().await; + ctx.ensure_not_running(tag)?; + let mut lock = store::read_lock(&ctx.paths, tag)?; + let (also_enabled, also_disabled) = plan::set_enabled(&mut lock, &catalog, id, enabled)?; + save(ctx, tag, &lock, None, Vec::new()).await?; + Ok(ChangeResult { + also_enabled, + also_disabled, + ..change_result(ctx, tag, &lock, &catalog).await + }) +} + +/// Removes a mod and turns off what needed it. Until the version's next change, the removal +/// can be undone with [`restore`]: the entry is kept, and so is its store file. +pub async fn remove(ctx: &Ctx, tag: &str, id: &str) -> Result { + ensure_installed(&ctx.paths, tag)?; + ctx.ensure_not_running(tag)?; + let catalog = current_catalog(ctx).await?; + let _guard = ctx.lock_mods().await; + ctx.ensure_not_running(tag)?; + let mut lock = store::read_lock(&ctx.paths, tag)?; + let entry = lock + .get(id) + .cloned() + .ok_or_else(|| ModsError::NotInstalled(id.to_string()))?; + let also_disabled = plan::remove(&mut lock, id)?; + let removed = Removed { + entry, + also_disabled: also_disabled.clone(), + }; + save(ctx, tag, &lock, Some(&removed), Vec::new()).await?; + Ok(ChangeResult { + also_disabled, + undo: Some(id.to_string()), + ..change_result(ctx, tag, &lock, &catalog).await }) } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn sha256_arguments_are_checked() { - let sha = "AB".repeat(32); - assert_eq!(check_sha(&sha).unwrap(), "ab".repeat(32)); - for bad in [ - "", - "abc", - "../../etc/passwd", - &"g".repeat(64), - &"a".repeat(65), - ] { - assert!( - matches!(check_sha(bad), Err(ModsError::InvalidName(_))), - "{bad:?}" - ); +/// Puts the most recently removed mod back exactly as it was: the same build, on or off as it +/// was, and the mods its removal turned off on again. `ModNotInstalled` when that isn't +/// possible any more: the version changed since, or the store file is gone or changed. +pub async fn restore(ctx: &Ctx, tag: &str, id: &str) -> Result { + ensure_installed(&ctx.paths, tag)?; + ctx.ensure_not_running(tag)?; + let catalog = current_catalog(ctx).await?; + let _guard = ctx.lock_mods().await; + ctx.ensure_not_running(tag)?; + let gone = || ModsError::NotInstalled(id.to_string()); + let removed = store::read_removed(&ctx.paths, tag)? + .filter(|r| r.entry.id == id) + .ok_or_else(gone)?; + let mut lock = store::read_lock(&ctx.paths, tag)?; + if lock.get(id).is_some() { + return Err(gone()); + } + let (stored, sha) = ( + ctx.paths.store_object(&removed.entry.sha256), + removed.entry.sha256.clone(), + ); + let intact = + blocking(move || Ok(sha256_file(&stored).is_ok_and(|actual| actual == sha))).await?; + if !intact { + return Err(gone()); + } + lock.put(removed.entry); + let mut also_enabled = Vec::new(); + for dep in &removed.also_disabled { + if let Some(e) = lock.get_mut(dep) + && !e.enabled + { + e.enabled = true; + also_enabled.push(dep.clone()); } } + profile::apply_revocations(&mut lock, &catalog); + also_enabled.retain(|id| lock.get(id).is_some_and(|e| e.enabled)); + save(ctx, tag, &lock, None, Vec::new()).await?; + Ok(ChangeResult { + also_enabled, + ..change_result(ctx, tag, &lock, &catalog).await + }) +} - #[test] - fn the_mods_lock_is_held_until_released() { - let rt = tokio::runtime::Builder::new_current_thread() - .build() - .unwrap(); - rt.block_on(async { - let lock = ModsLock::default(); - let held = lock.acquire().await; - assert!(lock.0.clone().try_lock_owned().is_err()); - drop(held); - assert!(lock.0.clone().try_lock_owned().is_ok()); - }); +// --------------------------------------------------------------------------------------------- +// Copying a mod set + +struct CopyInputs { + catalog: Catalog, + abi: String, + preview: CopyPreview, + picks: Vec<(CatalogMod, CatalogVersion)>, +} + +async fn copy_inputs(ctx: &Ctx, from: &str, to: &str) -> Result { + ensure_installed(&ctx.paths, to)?; + let source = store::read_lock(&ctx.paths, from)?; + let target = store::read_lock(&ctx.paths, to)?; + let catalog = current_catalog(ctx).await?; + let abi = require_abi(&ctx.paths, &catalog, to).await?; + let (preview, picks) = + plan::copy_preview(&catalog, (from, &source), (to, &target), Fit::here(&abi)); + Ok(CopyInputs { + catalog, + abi, + preview, + picks, + }) +} + +/// What `copy` would bring over. +pub async fn copy_preview(ctx: &Ctx, from: &str, to: &str) -> Result { + Ok(copy_inputs(ctx, from, to).await?.preview) +} + +/// Brings the turned-on mods of one version to another, each as the build made for the new one. +/// Mods the new version already has are left as they are. +pub async fn copy(ctx: &Ctx, from: &str, to: &str) -> Result { + ctx.ensure_not_running(to)?; + let inputs = copy_inputs(ctx, from, to).await?; + let left_behind = inputs + .preview + .items + .into_iter() + .filter(|i| { + matches!( + i.outcome, + CopyOutcome::NoBuild | CopyOutcome::LocalFile | CopyOutcome::Revoked + ) + }) + .collect(); + let change = if inputs.picks.is_empty() { + let lock = store::read_lock(&ctx.paths, to)?; + change_result(ctx, to, &lock, &inputs.catalog).await + } else { + run_install( + ctx, + to, + inputs.catalog, + inputs.abi, + inputs.picks, + Vec::new(), + ) + .await? + }; + Ok(CopyResult { + change, + left_behind, + }) +} + +/// A deleted game version takes its mod set along. +pub async fn forget_version(ctx: &Ctx, tag: &str) { + let _guard = ctx.lock_mods().await; + let (paths, reservations, tag) = (ctx.paths.clone(), ctx.reservations.clone(), tag.to_string()); + let _ = blocking(move || { + store::forget_version(&paths, &reservations, &tag); + Ok(()) + }) + .await; +} + +// --------------------------------------------------------------------------------------------- +// Before Play + +/// What Play does with a version's mod set. +#[derive(Debug, PartialEq, Eq)] +pub enum LaunchPlan { + /// Nothing is on: the game starts without `--mods`. + NoMods, + /// `--mods `. + Mods, + /// The game would refuse to start: the names of the mods that stop it. + Blocked(Vec), +} + +pub fn launch_plan(lock: &Lock, report: &ProfileReport) -> LaunchPlan { + if !lock.mods.iter().any(|m| m.enabled) { + return LaunchPlan::NoMods; + } + if report.blocking.is_empty() { + LaunchPlan::Mods + } else { + LaunchPlan::Blocked(report.blocking.iter().map(|id| lock.name_of(id)).collect()) + } +} + +/// The outcome of the pre-Play check. Holds `ModsLock` until [`LaunchMods::release`], so no mod +/// change can land between this check and the game counting as running. +pub struct LaunchMods { + dir: Option, + _held: OwnedMutexGuard<()>, +} + +impl LaunchMods { + /// The folder to hand to `--mods`, letting mod changes go ahead again. + pub fn release(self) -> Option { + self.dir + } +} + +/// Before Play: the folder to hand to `--mods`, or why the game would not start. Never touches +/// the network: it uses the catalogue the page last saw or the cached one, and the cached ABI. +/// Without a known ABI the mods are only unchecked, and the game itself has the last word. +pub async fn prepare_launch(ctx: &Ctx, tag: &str) -> Result { + let catalog = current_catalog(ctx).await?; + let held = ctx.lock_mods().await; + let mut lock = store::read_lock(&ctx.paths, tag)?; + if profile::apply_revocations(&mut lock, &catalog) { + store::write_lock(&ctx.paths, tag, &lock)?; } + reconcile(ctx, tag, &lock).await?; + let report = build_report(ctx, tag, &lock, &catalog, false).await; + let dir = match launch_plan(&lock, &report) { + LaunchPlan::NoMods => None, + LaunchPlan::Mods => Some(ctx.paths.mods_dir(tag)?), + LaunchPlan::Blocked(names) => return Err(ModsError::Blocked(names.join(", "))), + }; + Ok(LaunchMods { dir, _held: held }) } diff --git a/src-tauri/src/mods/service_tests.rs b/src-tauri/src/mods/service_tests.rs new file mode 100644 index 0000000..8980ba7 --- /dev/null +++ b/src-tauri/src/mods/service_tests.rs @@ -0,0 +1,674 @@ +//! The operations in `service.rs`, run as the app runs them but without Tauri: a [`Ctx`] on a +//! temporary folder, a catalogue held as if the page had loaded it, and a local HTTP server +//! standing in for GitHub. + +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::sync::{Arc, Mutex}; + +use super::archive::tests::make_zip; +use super::catalog::{Catalog, CatalogVersion, Origin, RevokedView}; +use super::context::{Ctx, tests::with_version}; +use super::error::ModsError; +use super::index::{Channel, WeftAbi}; +use super::install::ProgressStage; +use super::profile::tests::{ABI, build, catalog, catalog_mod}; +use super::profile::{Lock, ModStatus}; +use super::service::{self, LaunchPlan}; +use super::store; +use crate::download::sha256_hex; + +const TAG: &str = "v1"; + +fn run(fut: F) -> F::Output { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(fut) +} + +/// What the stand-in server answers: status, extra headers, body. +type Answer = (u16, Vec<(&'static str, String)>, Vec); + +/// Serves `answer(path)` to every request until the test ends. Returns the base URL. +fn serve(answer: impl Fn(&str) -> Answer + Send + 'static) -> String { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(mut stream) = stream else { continue }; + let mut request = Vec::new(); + let mut buf = [0u8; 1024]; + while !request.windows(4).any(|w| w == b"\r\n\r\n") { + match stream.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => request.extend_from_slice(&buf[..n]), + } + } + let text = String::from_utf8_lossy(&request); + let path = text.split_whitespace().nth(1).unwrap_or("/").to_string(); + let (status, headers, body) = answer(&path); + let mut head = format!("HTTP/1.1 {status} X\r\nConnection: close\r\n"); + if !headers.iter().any(|(k, _)| *k == "no-length") { + head.push_str(&format!("Content-Length: {}\r\n", body.len())); + } + for (k, v) in headers.iter().filter(|(k, _)| *k != "no-length") { + head.push_str(&format!("{k}: {v}\r\n")); + } + head.push_str("\r\n"); + let _ = stream.write_all(head.as_bytes()); + let _ = stream.write_all(&body); + } + }); + base +} + +fn ok(body: Vec) -> Answer { + (200, Vec::new(), body) +} + +/// A mod zip with a Linux library; `salt` makes builds of one mod differ. +fn zip_of(id: &str, salt: &str) -> Vec { + let lib = format!("lib{id}.so"); + make_zip(&[(lib.as_str(), salt.as_bytes())]) +} + +/// A catalogue build of `id` whose bytes are `zip`, downloaded from `base`. +fn build_of(id: &str, version: &str, zip: &[u8], deps: &[&str], base: &str) -> CatalogVersion { + let mut v = build(id, version, ABI, Channel::Release, deps); + v.sha256 = sha256_hex(zip); + v.size = zip.len() as u64; + v.url = format!("{base}/{id}.zip"); + v +} + +/// cogwork needs showcase; railways needs signals, which has no build. +struct World { + catalog: Catalog, + cogwork: Vec, + showcase: Vec, + railways: Vec, +} + +impl World { + fn new(base: &str) -> World { + let cogwork = zip_of("cogwork", "0.2.0"); + let showcase = zip_of("showcase", "0.1.0"); + let railways = zip_of("railways", "1.0.0"); + let mut catalog = catalog( + vec![ + catalog_mod( + "cogwork", + Origin::Index, + vec![build_of( + "cogwork", + "0.2.0", + &cogwork, + &["base", "showcase"], + base, + )], + ), + catalog_mod( + "showcase", + Origin::Index, + vec![build_of("showcase", "0.1.0", &showcase, &[], base)], + ), + catalog_mod( + "railways", + Origin::Index, + vec![build_of("railways", "1.0.0", &railways, &["signals"], base)], + ), + ], + &[], + ); + catalog.weft = vec![WeftAbi { + tag: TAG.into(), + commit: "f".repeat(40), + abi: ABI.into(), + }]; + World { + catalog, + cogwork, + showcase, + railways, + } + } + + fn sha(&self, id: &str) -> String { + self.catalog + .mods + .iter() + .find(|m| m.id == id) + .unwrap() + .versions[0] + .sha256 + .clone() + } + + /// Puts every build into the store, as if downloaded before. + fn stored(self, ctx: &Ctx) -> World { + for zip in [&self.cogwork, &self.showcase, &self.railways] { + std::fs::create_dir_all(ctx.paths.store_dir()).unwrap(); + std::fs::write(ctx.paths.store_object(&sha256_hex(zip)), zip).unwrap(); + } + self + } +} + +/// A server for the world's builds, and a context whose page loaded its catalogue. +fn setup(tmp: &tempfile::TempDir) -> (Ctx, World) { + type Files = Arc)>>>; + let bytes: Files = Arc::default(); + let served = bytes.clone(); + let base = serve(move |path| { + let files = served.lock().unwrap(); + match files.iter().find(|(p, _)| p == path) { + Some((_, b)) => ok(b.clone()), + None => (404, Vec::new(), Vec::new()), + } + }); + let world = World::new(&base); + *bytes.lock().unwrap() = vec![ + ("/cogwork.zip".into(), world.cogwork.clone()), + ("/showcase.zip".into(), world.showcase.clone()), + ("/railways.zip".into(), world.railways.clone()), + ]; + let ctx = with_version(tmp.path(), TAG); + ctx.hold_catalog(world.catalog.clone()); + (ctx, world) +} + +fn build_mut<'a>(catalog: &'a mut Catalog, id: &str) -> &'a mut CatalogVersion { + &mut catalog + .mods + .iter_mut() + .find(|m| m.id == id) + .unwrap() + .versions[0] +} + +fn lock_of(ctx: &Ctx) -> Lock { + store::read_lock(&ctx.paths, TAG).unwrap() +} + +fn handed_to_game(ctx: &Ctx) -> Vec { + let mut names: Vec = std::fs::read_dir(ctx.paths.mods_dir(TAG).unwrap()) + .map(|d| { + d.flatten() + .map(|e| e.file_name().to_string_lossy().to_string()) + .collect() + }) + .unwrap_or_default(); + names.sort(); + names +} + +#[test] +fn an_install_downloads_checks_and_commits() { + let tmp = tempfile::tempdir().unwrap(); + let (ctx, world) = setup(&tmp); + let stages: Arc>> = Arc::default(); + let seen = stages.clone(); + let ctx = ctx.with_progress(move |p| seen.lock().unwrap().push((p.mod_id, p.stage))); + + let done = run(service::install(&ctx, TAG, &world.sha("cogwork"))).unwrap(); + assert_eq!(done.installed, ["cogwork", "showcase"]); + assert!(done.installed_off.is_empty() && done.undo.is_none()); + assert_eq!(handed_to_game(&ctx), ["cogwork.zip", "showcase.zip"]); + assert!(ctx.paths.store_object(&world.sha("cogwork")).exists()); + let stages = stages.lock().unwrap(); + for stage in [ + ProgressStage::Downloading, + ProgressStage::Verifying, + ProgressStage::Installing, + ] { + assert!(stages.contains(&("cogwork".into(), stage)), "{stage:?}"); + } + assert!(!ctx.lock.is_held()); +} + +/// A download whose bytes aren't the pinned ones never reaches the store or the profile. +#[test] +fn a_tampered_download_never_reaches_the_store() { + let tmp = tempfile::tempdir().unwrap(); + let world = World::new(""); + let tampered = zip_of("showcase", "evil"); + let base = serve(move |_| ok(tampered.clone())); + let mut catalog = world.catalog.clone(); + for m in &mut catalog.mods { + m.versions[0].url = format!("{base}/{}.zip", m.id); + } + let ctx = with_version(tmp.path(), TAG); + ctx.hold_catalog(catalog); + + let err = run(service::install(&ctx, TAG, &world.sha("showcase"))).unwrap_err(); + assert!(matches!(err, ModsError::Sha256Mismatch { .. }), "{err:?}"); + assert!(!ctx.paths.store_object(&world.sha("showcase")).exists()); + let leftovers = std::fs::read_dir(ctx.paths.downloads_dir()) + .unwrap() + .count(); + assert_eq!(leftovers, 0, "the temporary file is gone"); + assert!(lock_of(&ctx).mods.is_empty()); +} + +#[test] +fn a_download_larger_than_announced_is_stopped() { + for headers in [Vec::new(), vec![("no-length", String::new())]] { + let tmp = tempfile::tempdir().unwrap(); + let world = World::new(""); + let big = vec![b'x'; world.showcase.len() + 4096]; + let base = serve(move |_| (200, headers.clone(), big.clone())); + let mut catalog = world.catalog.clone(); + build_mut(&mut catalog, "showcase").url = format!("{base}/showcase.zip"); + let ctx = with_version(tmp.path(), TAG); + ctx.hold_catalog(catalog); + let err = run(service::install(&ctx, TAG, &world.sha("showcase"))).unwrap_err(); + assert!(matches!(err, ModsError::TooLarge(_)), "{err:?}"); + assert!(!ctx.paths.store_object(&world.sha("showcase")).exists()); + } +} + +/// The store already has a file under the build's hash, but it is another mod: refused, and +/// nothing is fetched (the URL leads nowhere). +#[test] +fn a_stored_file_with_the_right_hash_is_still_checked() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = with_version(tmp.path(), TAG); + let other = zip_of("structures", "x"); + let mut catalog = World::new("http://127.0.0.1:9").catalog; + let showcase = build_mut(&mut catalog, "showcase"); + showcase.sha256 = sha256_hex(&other); + std::fs::create_dir_all(ctx.paths.store_dir()).unwrap(); + std::fs::write(ctx.paths.store_object(&showcase.sha256), &other).unwrap(); + let sha = showcase.sha256.clone(); + ctx.hold_catalog(catalog); + + let err = run(service::install(&ctx, TAG, &sha)).unwrap_err(); + assert!(matches!(err, ModsError::ArchiveInvalid(_)), "{err:?}"); + assert!(lock_of(&ctx).mods.is_empty()); +} + +/// Contract item 1: a mod that needs something missing is installed turned off, and Play keeps +/// working. +#[test] +fn a_missing_dependency_installs_the_mod_turned_off() { + let tmp = tempfile::tempdir().unwrap(); + let (ctx, world) = setup(&tmp); + let plan = run(service::plan(&ctx, TAG, &world.sha("railways"))).unwrap(); + assert!(!plan.turns_on); + assert_eq!(plan.missing, ["signals"]); + + let done = run(service::install(&ctx, TAG, &world.sha("railways"))).unwrap(); + assert_eq!(done.installed_off, ["railways"]); + assert_eq!(done.missing, ["signals"]); + assert!(!lock_of(&ctx).get("railways").unwrap().enabled); + assert!(done.report.blocking.is_empty()); + let launch = run(service::prepare_launch(&ctx, TAG)).unwrap(); + assert_eq!(launch.release(), None, "nothing is on"); +} + +mod meanwhile { + use super::*; + + /// Stages cogwork with showcase installed, so only cogwork is downloaded; `change` happens + /// during the download; the commit must refuse. + fn refused(change: impl FnOnce(&Ctx, &World)) -> ModsError { + let tmp = tempfile::tempdir().unwrap(); + let (ctx, world) = setup(&tmp); + let world = world.stored(&ctx); + run(service::install(&ctx, TAG, &world.sha("showcase"))).unwrap(); + let before = std::fs::read(ctx.paths.lock_file(TAG).unwrap()).unwrap(); + + let (m, v) = world.catalog.find(&world.sha("cogwork")).unwrap(); + let staged = service::stage( + &ctx, + TAG, + world.catalog.clone(), + ABI.into(), + vec![(m.clone(), v.clone())], + Vec::new(), + ) + .unwrap(); + run(service::fetch(&ctx, &staged)).unwrap(); + change(&ctx, &world); + let err = run(service::commit(&ctx, staged)).unwrap_err(); + if ctx.paths.lock_file(TAG).unwrap().exists() { + let after = std::fs::read(ctx.paths.lock_file(TAG).unwrap()).unwrap(); + let unchanged = after == before || !String::from_utf8_lossy(&after).contains("cogwork"); + assert!(unchanged, "nothing was written"); + } + err + } + + fn detail(err: ModsError) -> String { + match err { + ModsError::ChangedMeanwhile(why) => why, + e => panic!("expected ModChangedMeanwhile, got {e:?}"), + } + } + + #[test] + fn the_version_was_removed() { + let why = detail(refused(|ctx, _| { + std::fs::remove_dir_all(ctx.paths.versions_dir().join(TAG)).unwrap(); + })); + assert!(why.contains("removed"), "{why}"); + } + + #[test] + fn the_build_was_revoked() { + let why = detail(refused(|ctx, world| { + let mut c = world.catalog.clone(); + c.revoked.push(RevokedView { + sha256: world.sha("cogwork"), + reason: "deletes saves".into(), + }); + ctx.hold_catalog(c); + })); + assert!(why.contains("deletes saves"), "{why}"); + } + + #[test] + fn the_game_was_started() { + let why = detail(refused(|ctx, _| ctx.games.insert(TAG))); + assert!(why.contains("started"), "{why}"); + } + + #[test] + fn the_plan_no_longer_holds() { + // Showcase was removed meanwhile: cogwork would need it downloaded. + let why = detail(refused(|ctx, _| { + run(service::remove(ctx, TAG, "showcase")).unwrap(); + })); + assert!(why.contains("showcase"), "{why}"); + // Showcase was turned off meanwhile: committing would turn it on unasked. + let why = detail(refused(|ctx, _| { + run(service::set_enabled(ctx, TAG, "showcase", false)).unwrap(); + })); + assert!(why.contains("turned on"), "{why}"); + } + + #[test] + fn nothing_changed() { + let tmp = tempfile::tempdir().unwrap(); + let (ctx, world) = setup(&tmp); + let (m, v) = world.catalog.find(&world.sha("cogwork")).unwrap(); + let wanted = vec![(m.clone(), v.clone())]; + let staged = service::stage( + &ctx, + TAG, + world.catalog.clone(), + ABI.into(), + wanted, + Vec::new(), + ) + .unwrap(); + run(service::fetch(&ctx, &staged)).unwrap(); + let done = run(service::commit(&ctx, staged)).unwrap(); + assert_eq!(done.installed, ["cogwork", "showcase"]); + } +} + +#[test] +fn remove_can_be_undone_until_the_next_change() { + let tmp = tempfile::tempdir().unwrap(); + let (ctx, world) = setup(&tmp); + let world = world.stored(&ctx); + run(service::install(&ctx, TAG, &world.sha("cogwork"))).unwrap(); + let showcase_before = lock_of(&ctx).get("showcase").unwrap().clone(); + + let done = run(service::remove(&ctx, TAG, "showcase")).unwrap(); + assert_eq!(done.undo.as_deref(), Some("showcase")); + assert_eq!(done.also_disabled, ["cogwork"]); + assert!( + ctx.paths.store_object(&world.sha("showcase")).exists(), + "its file is kept for Undo" + ); + assert_eq!(handed_to_game(&ctx), Vec::::new()); + + let done = run(service::restore(&ctx, TAG, "showcase")).unwrap(); + assert!(done.undo.is_none()); + assert_eq!(done.also_enabled, ["cogwork"]); + let lock = lock_of(&ctx); + let showcase = lock.get("showcase").unwrap(); + assert_eq!(showcase.sha256, showcase_before.sha256); + assert_eq!(showcase.enabled, showcase_before.enabled); + assert_eq!(showcase.installed_at, showcase_before.installed_at); + assert_eq!(handed_to_game(&ctx), ["cogwork.zip", "showcase.zip"]); + assert!(matches!( + run(service::restore(&ctx, TAG, "showcase")), + Err(ModsError::NotInstalled(_)) + )); + + // Any other change ends the undo, and the file goes with the next collection. + run(service::remove(&ctx, TAG, "showcase")).unwrap(); + run(service::set_enabled(&ctx, TAG, "cogwork", false)).unwrap(); + assert!(matches!( + run(service::restore(&ctx, TAG, "showcase")), + Err(ModsError::NotInstalled(_)) + )); + assert!(!ctx.paths.store_object(&world.sha("showcase")).exists()); +} + +#[test] +fn undo_needs_the_same_bytes() { + let tmp = tempfile::tempdir().unwrap(); + let (ctx, world) = setup(&tmp); + let world = world.stored(&ctx); + run(service::install(&ctx, TAG, &world.sha("showcase"))).unwrap(); + run(service::set_enabled(&ctx, TAG, "showcase", false)).unwrap(); + run(service::remove(&ctx, TAG, "showcase")).unwrap(); + assert!(matches!( + run(service::restore(&ctx, TAG, "cogwork")), + Err(ModsError::NotInstalled(_)) + )); + std::fs::write(ctx.paths.store_object(&world.sha("showcase")), b"changed").unwrap(); + assert!(matches!( + run(service::restore(&ctx, TAG, "showcase")), + Err(ModsError::NotInstalled(_)) + )); + std::fs::write( + ctx.paths.store_object(&world.sha("showcase")), + &world.showcase, + ) + .unwrap(); + run(service::restore(&ctx, TAG, "showcase")).unwrap(); + assert!( + !lock_of(&ctx).get("showcase").unwrap().enabled, + "back as it was: off" + ); +} + +/// Before Play, with no catalogue loaded, nothing cached and no network: statuses are only +/// unchecked, recorded revocations still apply, and missing dependencies still block. +#[test] +fn prepare_launch_needs_neither_tauri_nor_the_network() { + let tmp = tempfile::tempdir().unwrap(); + let world = World::new("http://127.0.0.1:9"); + let installer = with_version(tmp.path(), TAG); + installer.hold_catalog(world.catalog.clone()); + let world = world.stored(&installer); + run(service::install(&installer, TAG, &world.sha("cogwork"))).unwrap(); + // A fresh start: no catalogue held, no index cached, no ABI cached. + let ctx = Ctx::new(installer.paths.clone()); + + let launch = run(service::prepare_launch(&ctx, TAG)).unwrap(); + assert!(ctx.lock.is_held(), "held until the game counts as running"); + assert_eq!(launch.release(), Some(ctx.paths.mods_dir(TAG).unwrap())); + assert!(!ctx.lock.is_held()); + let offline = service::offline_catalog(&ctx.paths); + assert_eq!( + offline.index.freshness, + super::index::Freshness::Unavailable + ); + let report = run(service::build_report( + &ctx, + TAG, + &lock_of(&ctx), + &offline, + false, + )); + assert!( + report.mods.iter().all(|m| m.status == ModStatus::Unchecked), + "without the index or the ABI nothing can be called wrong" + ); + + // A revocation recorded earlier still turns the mod off, without the index. + let mut lock = lock_of(&ctx); + lock.get_mut("showcase").unwrap().revoked = Some(super::profile::Revocation { + reason: "deletes saves".into(), + at: String::new(), + }); + store::write_lock(&ctx.paths, TAG, &lock).unwrap(); + let err = run(service::prepare_launch(&ctx, TAG)).err().unwrap(); + assert!( + matches!(err, ModsError::Blocked(ref names) if names == "cogwork name"), + "{err:?}" + ); + let lock = lock_of(&ctx); + assert!(!lock.get("showcase").unwrap().enabled); + assert_eq!(handed_to_game(&ctx), ["cogwork.zip"]); +} + +#[test] +fn the_launch_plan() { + use super::profile::tests::{entry, lock}; + use super::profile::{report, tests::fit}; + let mut off = entry("showcase", &[], Some(ABI)); + off.enabled = false; + let mut revoked = entry("pulled", &[], Some(ABI)); + revoked.enabled = false; + revoked.revoked = Some(super::profile::Revocation { + reason: "x".into(), + at: String::new(), + }); + let cases: Vec<(&str, Lock, LaunchPlan)> = vec![ + ("nothing installed", lock(vec![]), LaunchPlan::NoMods), + ( + "only mods that are off", + lock(vec![off.clone(), revoked.clone()]), + LaunchPlan::NoMods, + ), + ( + "a mod that is on", + lock(vec![entry("a", &["base"], Some(ABI)), off.clone()]), + LaunchPlan::Mods, + ), + ( + "a local file, unchecked", + lock(vec![entry("mytweak", &[], None)]), + LaunchPlan::Mods, + ), + ( + "a dependency that is off", + lock(vec![ + entry("cogwork", &["showcase"], Some(ABI)), + off.clone(), + ]), + LaunchPlan::Blocked(vec!["cogwork name".into()]), + ), + ( + "built for another version", + lock(vec![entry("old", &[], Some("5f0569a6bea71b81"))]), + LaunchPlan::Blocked(vec!["old name".into()]), + ), + ]; + let c = catalog(vec![], &[]); + for (what, l, want) in cases { + let r = report(TAG, &l, Some(ABI.into()), None, &c, fit().platform, vec![]); + assert_eq!(service::launch_plan(&l, &r), want, "{what}"); + } +} + +#[test] +fn forgetting_a_version_waits_for_the_mods_lock() { + let tmp = tempfile::tempdir().unwrap(); + let (ctx, world) = setup(&tmp); + let world = world.stored(&ctx); + run(service::install(&ctx, TAG, &world.sha("showcase"))).unwrap(); + run(async { + let held = ctx.lock_mods().await; + let waited = tokio::time::timeout( + std::time::Duration::from_millis(100), + service::forget_version(&ctx, TAG), + ) + .await; + assert!(waited.is_err(), "it waits while an install holds the lock"); + assert!(ctx.paths.profile_dir(TAG).unwrap().exists()); + drop(held); + service::forget_version(&ctx, TAG).await; + }); + assert!(!ctx.paths.profile_dir(TAG).unwrap().exists()); + assert!(!ctx.paths.store_object(&world.sha("showcase")).exists()); +} + +mod icons { + use super::*; + + fn png(len: usize) -> Vec { + let mut b = b"\x89PNG\r\n\x1a\n".to_vec(); + b.resize(len, 0); + b + } + + #[test] + fn only_small_pngs() { + let base = serve(|path| match path { + "/small.png" => ok(png(1000)), + "/big.png" => ok(png(service::MAX_ICON_BYTES + 1)), + "/big-unannounced.png" => ( + 200, + vec![("no-length", String::new())], + png(service::MAX_ICON_BYTES + 1), + ), + "/not.png" => ok(b"".to_vec()), + _ => (404, Vec::new(), Vec::new()), + }); + let client = service::http().unwrap(); + let get = |p: &str| run(service::fetch_icon(&client, &format!("{base}{p}"))); + assert_eq!(get("/small.png").unwrap().len(), 1000); + for bad in ["/big.png", "/big-unannounced.png", "/not.png"] { + assert!(matches!(get(bad), Err(ModsError::TooLarge(_))), "{bad}"); + } + assert!(matches!( + get("/gone.png"), + Err(ModsError::Http { status: 404, .. }) + )); + } + + #[test] + fn redirects_stay_on_github_over_https() { + let base = serve(|path| match path { + "/away" => ( + 302, + vec![("Location", "http://127.0.0.1:9/x.png".into())], + Vec::new(), + ), + _ => ok(png(10)), + }); + let client = service::http().unwrap(); + let got = run(service::fetch_icon(&client, &format!("{base}/away"))); + assert!(matches!(got, Err(ModsError::Network(_))), "{got:?}"); + } + + #[test] + fn only_catalogue_icons_over_https() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = with_version(tmp.path(), TAG); + let mut c = catalog(vec![], &[]); + let mut m = catalog_mod("cogwork", Origin::Index, vec![]); + m.icon = Some("http://example.com/icon.png".into()); + c.mods.push(m); + ctx.hold_catalog(c); + for url in [ + "http://example.com/icon.png", + "https://example.com/other.png", + ] { + assert!(matches!( + run(service::icon_png(&ctx, url)), + Err(ModsError::NotInCatalog(_)) + )); + } + } +} diff --git a/src-tauri/src/mods/sources.rs b/src-tauri/src/mods/sources.rs index 098ed8b..be708f3 100644 --- a/src-tauri/src/mods/sources.rs +++ b/src-tauri/src/mods/sources.rs @@ -321,6 +321,30 @@ async fn list_releases( Ok(Listing::Releases(releases, first_etag)) } +fn read_cache(paths: &Paths, repo: &str) -> Option { + std::fs::read(cache_path(paths, repo)) + .ok() + .and_then(|b| serde_json::from_slice(&b).ok()) +} + +/// The last good answer of one source, without asking GitHub. +pub fn cached(paths: &Paths, repo: &str) -> SourceFetch { + match read_cache(paths, repo) { + Some(c) => SourceFetch { + mods: c.mods, + freshness: Freshness::Cached, + checked: c.checked, + error: None, + }, + None => SourceFetch { + mods: Vec::new(), + freshness: Freshness::Unavailable, + checked: None, + error: None, + }, + } +} + /// The mods of one source. When GitHub can't be asked or refuses, the last good answer is used /// and the error comes along with it. pub async fn fetch( @@ -330,9 +354,7 @@ pub async fn fetch( refresh: bool, ) -> SourceFetch { let path = cache_path(paths, repo); - let cached: Option = std::fs::read(&path) - .ok() - .and_then(|b| serde_json::from_slice(&b).ok()); + let cached = read_cache(paths, repo); if !refresh && let Some(c) = cached { return SourceFetch { mods: c.mods, diff --git a/src-tauri/src/mods/store.rs b/src-tauri/src/mods/store.rs index df11d1a..5f94bbf 100644 --- a/src-tauri/src/mods/store.rs +++ b/src-tauri/src/mods/store.rs @@ -2,30 +2,94 @@ //! profile's `mods.lock.json` lists what is installed, and the profile's `mods/` folder holds //! exactly the turned-on mods. A mod that is off is not in that folder at all: the engine runs //! code even from mods it was told to skip, so leaving the file out is the only real "off". +//! +//! [`admit`] is the one way into the store: it hashes the file, checks the zip and the platform. +//! The callers hold `ModsLock` whenever they write a lock file or delete from the store. use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; +use serde::{Deserialize, Serialize}; + use super::archive::{self, ArchiveInfo}; use super::error::ModsError; -use super::index::{Platform, is_sha256}; -use super::paths::{LOCK_FILE, Paths, unique_suffix, write_atomic}; -use super::profile::Lock; +use super::index::{Platform, is_mod_id, is_sha256}; +use super::paths::{LOCK_FILE, Paths, REMOVED_FILE, unique_suffix, write_atomic}; +use super::profile::{Lock, LockEntry}; use crate::download::sha256_file; pub fn read_lock(paths: &Paths, tag: &str) -> Result { read_lock_file(&paths.lock_file(tag)?) } +/// A lock file names files and folders by its ids and hashes, so they are checked before use: +/// a damaged or hand-edited file is an error, never a path. +fn check_lock(lock: &Lock) -> Result<(), ModsError> { + if lock.schema > 1 { + return Err(ModsError::LockInvalid(format!( + "schema {} is from a newer launcher", + lock.schema + ))); + } + for m in &lock.mods { + if !is_mod_id(&m.id) { + return Err(ModsError::LockInvalid(format!("mod id {:?}", m.id))); + } + if !is_sha256(&m.sha256) { + return Err(ModsError::LockInvalid(format!("sha256 of {}", m.id))); + } + } + Ok(()) +} + fn read_lock_file(path: &Path) -> Result { match std::fs::read(path) { - Ok(bytes) => Ok(serde_json::from_slice(&bytes)?), + Ok(bytes) => { + let lock: Lock = serde_json::from_slice(&bytes)?; + check_lock(&lock)?; + Ok(lock) + } Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Lock::empty()), Err(e) => Err(e.into()), } } +/// The mod most recently removed from a version, kept so the removal can be undone. Its store +/// file is kept until the version's next change. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Removed { + pub entry: LockEntry, + /// Mods the removal turned off because they needed it. + #[serde(default)] + pub also_disabled: Vec, +} + +fn read_removed_file(path: &Path) -> Option { + let removed: Removed = serde_json::from_slice(&std::fs::read(path).ok()?).ok()?; + (is_mod_id(&removed.entry.id) && is_sha256(&removed.entry.sha256)).then_some(removed) +} + +pub fn read_removed(paths: &Paths, tag: &str) -> Result, ModsError> { + Ok(read_removed_file(&paths.removed_file(tag)?)) +} + +pub fn write_removed(paths: &Paths, tag: &str, removed: &Removed) -> Result<(), ModsError> { + write_atomic( + &paths.removed_file(tag)?, + &serde_json::to_vec_pretty(removed)?, + ) +} + +/// The version changed again: its last removal can't be undone any more. +pub fn clear_removed(paths: &Paths, tag: &str) -> Result<(), ModsError> { + match std::fs::remove_file(paths.removed_file(tag)?) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e.into()), + _ => Ok(()), + } +} + pub fn write_lock(paths: &Paths, tag: &str, lock: &Lock) -> Result<(), ModsError> { write_atomic(&paths.lock_file(tag)?, &serde_json::to_vec_pretty(lock)?) } @@ -132,6 +196,9 @@ pub fn collect_garbage(paths: &Paths, reservations: &Reservations) -> Result {} @@ -153,7 +220,7 @@ pub fn collect_garbage(paths: &Paths, reservations: &Reservations) -> Result Result { Ok(dir.join(format!("mod-{sha256}-{}.part", unique_suffix()))) } -/// Checks a downloaded or copied file and moves it into the store under its hash. The temporary -/// file is gone afterwards, either way. +/// What every file in the store passed: its bytes hash to `sha256`, it is a mod zip (of mod `id`, +/// when given), and it has a library for `platform`. +fn check( + path: &Path, + sha256: &str, + id: Option<&str>, + platform: Option, +) -> Result { + let actual = sha256_file(path)?; + if actual != sha256 { + return Err(ModsError::Sha256Mismatch { + expected: sha256.to_string(), + actual, + }); + } + let info = archive::inspect(std::fs::File::open(path)?, id)?; + if !platform.is_some_and(|p| info.platforms.contains(&p)) { + return Err(ModsError::NotForPlatform(info.id)); + } + Ok(info) +} + +/// Checks a downloaded or copied file (see [`check`]) and moves it into the store under its +/// hash. The temporary file is gone afterwards, either way. pub fn admit( paths: &Paths, tmp: &Path, @@ -180,13 +269,10 @@ pub fn admit( platform: Option, ) -> Result { let result = (|| { - let info = archive::inspect(std::fs::File::open(tmp)?, id)?; - if !platform.is_some_and(|p| info.platforms.contains(&p)) { - return Err(ModsError::NotForPlatform(info.id)); - } - let stored = paths.store_object(sha256); + let info = check(tmp, sha256, id, platform)?; + std::fs::File::open(tmp)?.sync_all()?; std::fs::create_dir_all(paths.store_dir())?; - std::fs::rename(tmp, &stored)?; + std::fs::rename(tmp, paths.store_object(sha256))?; Ok(info) })(); if result.is_err() { @@ -195,6 +281,29 @@ pub fn admit( result } +/// The same checks for a build the store already has. `None` when it has none, or only a +/// damaged copy, which is deleted so it can be fetched again; an error when the stored bytes +/// are right but aren't a mod that fits here. +pub fn admit_stored( + paths: &Paths, + sha256: &str, + id: Option<&str>, + platform: Option, +) -> Result, ModsError> { + let stored = paths.store_object(sha256); + if !stored.exists() { + return Ok(None); + } + match check(&stored, sha256, id, platform) { + Ok(info) => Ok(Some(info)), + Err(ModsError::Sha256Mismatch { .. }) => { + std::fs::remove_file(&stored)?; + Ok(None) + } + Err(e) => Err(e), + } +} + /// Copies a file from the player's computer into the store, refusing it if its bytes are not the /// ones that were inspected. pub fn admit_local( @@ -205,13 +314,14 @@ pub fn admit_local( platform: Option, ) -> Result { let tmp = temp_path(paths, sha256)?; - std::fs::copy(source, &tmp)?; - let copied = sha256_file(&tmp)?; - if copied != sha256 { + if let Err(e) = std::fs::copy(source, &tmp) { let _ = std::fs::remove_file(&tmp); - return Err(ModsError::FileChanged); + return Err(e.into()); } - admit(paths, &tmp, sha256, id, platform) + admit(paths, &tmp, sha256, id, platform).map_err(|e| match e { + ModsError::Sha256Mismatch { .. } => ModsError::FileChanged, + e => e, + }) } #[cfg(test)] @@ -364,6 +474,119 @@ mod tests { assert_eq!(leftovers, 0, "no temporary file is left behind"); } + /// The store checks what it already has too: right bytes for another mod or system are + /// refused, and a damaged copy is dropped so it is fetched again. + #[test] + fn a_stored_file_is_checked_again() { + let (_tmp, paths) = setup(); + let linux = Some(Platform::LinuxX64); + let zip = make_zip(&[("libcogwork.so", b"elf")]); + let sha = store(&paths, &zip); + assert_eq!( + admit_stored(&paths, &sha, Some("cogwork"), linux) + .unwrap() + .unwrap() + .id, + "cogwork" + ); + assert!(matches!( + admit_stored(&paths, &sha, Some("structures"), linux), + Err(ModsError::ArchiveInvalid(_)) + )); + assert!(matches!( + admit_stored(&paths, &sha, Some("cogwork"), Some(Platform::WindowsX64)), + Err(ModsError::NotForPlatform(_)) + )); + assert!( + admit_stored(&paths, &"0".repeat(64), Some("cogwork"), linux) + .unwrap() + .is_none() + ); + + std::fs::write(paths.store_object(&sha), b"damaged").unwrap(); + assert!( + admit_stored(&paths, &sha, Some("cogwork"), linux) + .unwrap() + .is_none() + ); + assert!( + !paths.store_object(&sha).exists(), + "the damaged copy is gone" + ); + } + + #[test] + fn admitting_hashes_the_file_itself() { + let (tmp, paths) = setup(); + let zip = make_zip(&[("libcogwork.so", b"elf")]); + let file = tmp.path().join("x.part"); + std::fs::write(&file, &zip).unwrap(); + let claimed = sha256_hex(b"something else"); + assert!(matches!( + admit( + &paths, + &file, + &claimed, + Some("cogwork"), + Some(Platform::LinuxX64) + ), + Err(ModsError::Sha256Mismatch { .. }) + )); + assert!(!file.exists() && !paths.store_object(&claimed).exists()); + } + + #[test] + fn a_removed_mod_keeps_its_file_until_the_next_change() { + let (_tmp, paths) = setup(); + let a = store(&paths, b"a"); + write_lock(&paths, "v1", &lock(vec![])).unwrap(); + let removed = Removed { + entry: installed("a", &a, true), + also_disabled: vec![], + }; + write_removed(&paths, "v1", &removed).unwrap(); + assert_eq!( + collect_garbage(&paths, &Reservations::default()).unwrap(), + 0 + ); + assert_eq!(read_removed(&paths, "v1").unwrap().unwrap().entry.id, "a"); + + clear_removed(&paths, "v1").unwrap(); + clear_removed(&paths, "v1").unwrap(); + assert!(read_removed(&paths, "v1").unwrap().is_none()); + assert_eq!( + collect_garbage(&paths, &Reservations::default()).unwrap(), + 1 + ); + } + + #[test] + fn a_damaged_lock_file_is_refused() { + let (_tmp, paths) = setup(); + let mut bad = lock(vec![installed("a", &"a".repeat(64), true)]); + bad.mods[0].id = "../../evil".into(); + write_lock(&paths, "v1", &bad).unwrap(); + assert!(matches!( + read_lock(&paths, "v1"), + Err(ModsError::LockInvalid(_)) + )); + + let bad = lock(vec![installed("a", "../x", true)]); + write_lock(&paths, "v1", &bad).unwrap(); + assert!(matches!( + read_lock(&paths, "v1"), + Err(ModsError::LockInvalid(_)) + )); + + let mut newer = lock(vec![]); + newer.schema = 2; + write_lock(&paths, "v1", &newer).unwrap(); + assert!(matches!( + read_lock(&paths, "v1"), + Err(ModsError::LockInvalid(_)) + )); + } + #[test] fn forgetting_a_version_frees_its_files() { let (_tmp, paths) = setup(); diff --git a/src/mods/bindings/ChangeResult.ts b/src/mods/bindings/ChangeResult.ts index aaaeda3..1b606f9 100644 --- a/src/mods/bindings/ChangeResult.ts +++ b/src/mods/bindings/ChangeResult.ts @@ -20,4 +20,12 @@ alsoDisabled: Array, /** * Dependencies nothing in the catalogue provides for this version. */ -missing: Array, }; +missing: Array, +/** + * Mods this change installed turned off, because something they need is missing. + */ +installedOff: Array, +/** + * The id of the mod this change removed, while `restore_mod` can still bring it back. + */ +undo: string | null, }; diff --git a/src/mods/bindings/CopyPreview.ts b/src/mods/bindings/CopyPreview.ts index 01a0b4f..e8e2607 100644 --- a/src/mods/bindings/CopyPreview.ts +++ b/src/mods/bindings/CopyPreview.ts @@ -1,4 +1,17 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { CopyItem } from "./CopyItem"; +import type { PlanItem } from "./PlanItem"; -export type CopyPreview = { from: string, to: string, items: Array, }; +export type CopyPreview = { from: string, to: string, items: Array, +/** + * Dependencies of the items that will be downloaded as well. + */ +deps: Array, +/** + * Ids of mods `to` already has, off, that will be turned on. + */ +enable: Array, +/** + * Ids of items that will be installed turned off, because something they need is missing. + */ +installedOff: Array, }; diff --git a/src/mods/bindings/CopyResult.ts b/src/mods/bindings/CopyResult.ts index 70a168c..a593ff8 100644 --- a/src/mods/bindings/CopyResult.ts +++ b/src/mods/bindings/CopyResult.ts @@ -22,4 +22,12 @@ alsoDisabled: Array, /** * Dependencies nothing in the catalogue provides for this version. */ -missing: Array, }; +missing: Array, +/** + * Mods this change installed turned off, because something they need is missing. + */ +installedOff: Array, +/** + * The id of the mod this change removed, while `restore_mod` can still bring it back. + */ +undo: string | null, }; diff --git a/src/mods/bindings/ErrorCode.ts b/src/mods/bindings/ErrorCode.ts index cf1552a..74d3f68 100644 --- a/src/mods/bindings/ErrorCode.ts +++ b/src/mods/bindings/ErrorCode.ts @@ -4,4 +4,4 @@ * Every code an [`AppError`] can carry, across the whole app. On the wire a code is its * variant name; `src/i18n/errors.ts` turns it into a translation key. */ -export type ErrorCode = "Network" | "Io" | "DiskFull" | "Serde" | "RateLimited" | "InvalidName" | "InvalidHeader" | "CacheCorrupted" | "AssetNotFound" | "MissingDigest" | "Forbidden" | "OfflineNoCache" | "Zip" | "CreateDir" | "Sha256Mismatch" | "HashMissing" | "AlreadyInstalled" | "NotInstalled" | "DownloadFailed" | "ExtractFailed" | "GameRunning" | "VersionInUse" | "InstallInProgress" | "AlreadyRunning" | "AnotherGameRunning" | "ExecutableNotFound" | "ProcessSpawnFailed" | "LogDirCreateFailed" | "GameFolderNotFound" | "OpenerError" | "IndexSignatureInvalid" | "IndexRollback" | "IndexInvalid" | "AbiUnavailable" | "ModNotInCatalog" | "ModIncompatible" | "ModNotForPlatform" | "ModRevoked" | "ModArchiveInvalid" | "ModFileChanged" | "ModNotInstalled" | "ModsBlocked" | "ModSourceInvalid" | "ModSourceNotFound" | "ModSourceRateLimited" | "ModDownloadNotFound" | "ModTooLarge" | "ModIdReserved"; +export type ErrorCode = "Network" | "Io" | "DiskFull" | "Serde" | "RateLimited" | "InvalidName" | "InvalidHeader" | "CacheCorrupted" | "AssetNotFound" | "MissingDigest" | "Forbidden" | "OfflineNoCache" | "Zip" | "CreateDir" | "Sha256Mismatch" | "HashMissing" | "AlreadyInstalled" | "NotInstalled" | "DownloadFailed" | "ExtractFailed" | "GameRunning" | "VersionInUse" | "InstallInProgress" | "AlreadyRunning" | "AnotherGameRunning" | "ExecutableNotFound" | "ProcessSpawnFailed" | "LogDirCreateFailed" | "GameFolderNotFound" | "OpenerError" | "IndexSignatureInvalid" | "IndexRollback" | "IndexInvalid" | "AbiUnavailable" | "ModNotInCatalog" | "ModIncompatible" | "ModNotForPlatform" | "ModRevoked" | "ModArchiveInvalid" | "ModFileChanged" | "ModNotInstalled" | "ModsBlocked" | "ModSourceInvalid" | "ModSourceNotFound" | "ModSourceRateLimited" | "ModDownloadNotFound" | "ModTooLarge" | "ModIdReserved" | "ModChangedMeanwhile"; diff --git a/src/mods/bindings/InstallPlan.ts b/src/mods/bindings/InstallPlan.ts index 05460ba..8f822d2 100644 --- a/src/mods/bindings/InstallPlan.ts +++ b/src/mods/bindings/InstallPlan.ts @@ -19,6 +19,11 @@ enable: Array, * Dependency ids with no fitting, non-revoked build. */ missing: Array, +/** + * Whether the target is on after this install: it is new or on now, and nothing is + * missing. With something missing it is installed turned off. + */ +turnsOn: boolean, /** * Why the install would fail, if it would. */ diff --git a/src/mods/bindings/InstalledMod.ts b/src/mods/bindings/InstalledMod.ts index 086efdd..aa1cb7f 100644 --- a/src/mods/bindings/InstalledMod.ts +++ b/src/mods/bindings/InstalledMod.ts @@ -2,11 +2,25 @@ import type { Channel } from "./Channel"; import type { ModStatus } from "./ModStatus"; import type { Origin } from "./Origin"; +import type { Revocation } from "./Revocation"; import type { Trust } from "./Trust"; import type { UpdateView } from "./UpdateView"; -export type InstalledMod = { status: ModStatus, missingDeps: Array, revokedReason: string | null, update: UpdateView | null, id: string, name: string, summary: string, version: string | null, channel: Channel | null, sha256: string, size: number, enabled: boolean, trust: Trust, origin: Origin, +export type InstalledMod = { status: ModStatus, +/** + * Dependencies that aren't on: not installed, or installed and off. + */ +missingDeps: Array, +/** + * The part of `missing_deps` with no build installed at all. + */ +depsNotInstalled: Array, revokedReason: string | null, update: UpdateView | null, id: string, name: string, summary: string, version: string | null, channel: Channel | null, sha256: string, size: number, enabled: boolean, trust: Trust, origin: Origin, /** * Unknown for a file from the player's computer: only the game can read it from the library. */ -abi: string | null, weftTag: string | null, deps: Array, tag: string | null, sourceRepo: string | null, sourceCommit: string | null, icon: string | null, installedAt: string, }; +abi: string | null, weftTag: string | null, deps: Array, tag: string | null, sourceRepo: string | null, sourceCommit: string | null, icon: string | null, installedAt: string, +/** + * Set when the index revoked this build, and kept even if the index can't be loaded later: + * the mod stays revoked, and off, until it is replaced or removed. + */ +revoked: Revocation | null, }; diff --git a/src/mods/bindings/Revocation.ts b/src/mods/bindings/Revocation.ts new file mode 100644 index 0000000..e7b952f --- /dev/null +++ b/src/mods/bindings/Revocation.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Why and when a revocation was applied to an installed build. + */ +export type Revocation = { reason: string, +/** + * RFC 3339 time the launcher applied it. + */ +at: string, }; diff --git a/src/mods/bindings/commands.ts b/src/mods/bindings/commands.ts index 645f700..56b40f5 100644 --- a/src/mods/bindings/commands.ts +++ b/src/mods/bindings/commands.ts @@ -21,6 +21,7 @@ export const COMMANDS = { installModFile: "install_mod_file", setModEnabled: "set_mod_enabled", removeMod: "remove_mod", + restoreMod: "restore_mod", previewCopyModProfile: "preview_copy_mod_profile", copyModProfile: "copy_mod_profile", getModIcon: "get_mod_icon", diff --git a/src/mods/bindings/index.ts b/src/mods/bindings/index.ts index 91be4f5..eb40a1d 100644 --- a/src/mods/bindings/index.ts +++ b/src/mods/bindings/index.ts @@ -34,6 +34,7 @@ export type * from "./ProgressStage"; export type * from "./ProvenanceView"; export type * from "./Release"; export type * from "./ReviewedView"; +export type * from "./Revocation"; export type * from "./RevokedView"; export type * from "./SourceStatus"; export type * from "./Trust"; From 5a5231f624d467b190165fe4bc85de498430aaee Mon Sep 17 00:00:00 2001 From: Zorro909 Date: Wed, 4 Nov 2026 21:36:15 +0000 Subject: [PATCH 023/156] Check source mod ids and merge their releases newest first A source's .mod.json is refused unless its id is a mod id. When several releases publish the same mod, the newest release names it and a version or zip published again later replaces nothing. Summaries are cut like the index's, and the ABI file is read with a 1 MB cap. --- src-tauri/src/mods/abi.rs | 10 +++-- src-tauri/src/mods/sources.rs | 78 +++++++++++++++++++++++++++++++---- 2 files changed, 78 insertions(+), 10 deletions(-) diff --git a/src-tauri/src/mods/abi.rs b/src-tauri/src/mods/abi.rs index 05fafe5..6a119db 100644 --- a/src-tauri/src/mods/abi.rs +++ b/src-tauri/src/mods/abi.rs @@ -35,6 +35,9 @@ pub fn is_safe_ref(s: &str) -> bool { .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_')) } +/// `crates/abi/src/lib.rs` is a few tens of KB. +const MAX_ABI_FILE_BYTES: usize = 1024 * 1024; + fn cache_file(paths: &Paths) -> std::path::PathBuf { paths.cache_dir().join("abi.json") } @@ -77,10 +80,11 @@ pub async fn abi_for_tag( if !response.status().is_success() { return Err(ModsError::AbiUnavailable(tag.to_string())); } - let bytes = response - .bytes() + let bytes = crate::download::read_capped(response, MAX_ABI_FILE_BYTES) .await - .map_err(|_| ModsError::AbiUnavailable(tag.to_string()))?; + .ok() + .flatten() + .ok_or_else(|| ModsError::AbiUnavailable(tag.to_string()))?; let abi = format_abi(fnv1a64(&bytes)); let mut cache = read_cache(paths); diff --git a/src-tauri/src/mods/sources.rs b/src-tauri/src/mods/sources.rs index be708f3..e504e17 100644 --- a/src-tauri/src/mods/sources.rs +++ b/src-tauri/src/mods/sources.rs @@ -9,8 +9,8 @@ use std::time::Duration; use super::error::ModsError; use super::index::{ - Asset, Channel, Dep, Freshness, IndexMod, IndexVersion, Provenance, SourceRef, WeftRef, - is_repo, same_repo, version_problem, + Asset, Channel, Dep, Freshness, IndexMod, IndexVersion, Provenance, RESERVED_ID, SourceRef, + WeftRef, clamp_summary, is_mod_id, is_repo, same_repo, version_problem, }; use super::paths::{Paths, write_atomic}; use super::profile::now; @@ -139,7 +139,8 @@ fn cache_path(paths: &Paths, repo: &str) -> std::path::PathBuf { } /// Checks one release's description against GitHub's record of the same release: -/// schema 1; the id is the zip's stem; the tag, repo, asset name, URL and size equal GitHub's; +/// schema 1; the id is a mod id (not `base`) and the zip's stem; the tag, repo, asset name, URL +/// and size equal GitHub's; /// the SHA-256 equals GitHub's digest (a release without a digest is skipped); and then every /// rule an index entry must follow. A GitHub prerelease is never a plain release. pub(crate) fn accept( @@ -151,6 +152,8 @@ pub(crate) fn accept( let stem = zip.name.strip_suffix(".zip")?; let digest = zip.digest.as_deref()?.strip_prefix("sha256:")?; let agrees = json.schema == 1 + && is_mod_id(&json.id) + && json.id != RESERVED_ID && json.id == stem && json.tag == release.tag_name && same_repo(&json.source.repo, repo) @@ -185,13 +188,15 @@ pub(crate) fn accept( // A source can't review itself. reviewed: None, }; - if version_problem(&json.id, &version).is_some() || json.id == super::index::RESERVED_ID { + if version_problem(&json.id, &version).is_some() { return None; } + let mut summary = json.summary; + clamp_summary(&mut summary); Some(IndexMod { id: json.id, name: json.name, - summary: json.summary, + summary, authors: json.authors, license: json.license, homepage: json.homepage.filter(|u| u.starts_with("https://")), @@ -200,12 +205,23 @@ pub(crate) fn accept( }) } -/// Folds one entry per release into one entry per mod, newest version first. +/// Folds one entry per release into one entry per mod, newest version first. `entries` come +/// in GitHub's order, newest release first: the mod's name, summary and icon come from its +/// newest release, and a version (or a zip) a later release publishes again replaces nothing. fn merge(entries: Vec) -> Vec { let mut mods: Vec = Vec::new(); for entry in entries { match mods.iter_mut().find(|m| m.id == entry.id) { - Some(m) => m.versions.extend(entry.versions), + Some(m) => { + for v in entry.versions { + let seen = m.versions.iter().any(|have| { + have.version == v.version || have.asset.sha256 == v.asset.sha256 + }); + if !seen { + m.versions.push(v); + } + } + } None => mods.push(entry), } } @@ -529,6 +545,54 @@ pub(crate) mod tests { assert!(accept(repo, &r, &no_digest, json()).is_none(), "no digest"); } + #[test] + fn the_id_must_be_a_mod_id() { + let repo = "Zorro909/weft-mods"; + for id in ["Cogwork", "cog-work", "cog.work", "base", ""] { + let (r, mut zip) = release(false); + zip.name = format!("{id}.zip"); + let mut j = json(); + j.id = id.to_string(); + j.asset.name = zip.name.clone(); + j.asset.url = + format!("https://github.com/{repo}/releases/download/cogwork-v0.2.0/{id}.zip"); + zip.browser_download_url = j.asset.url.clone(); + assert!(accept(repo, &r, &zip, j).is_none(), "{id:?}"); + } + } + + #[test] + fn releases_merge_newest_first() { + let entry = |name: &str, version: &str, sha: char| { + let mut m = super::super::catalog::tests::index_mod( + "cogwork", + "Zorro909/weft-mods", + &sha.to_string().repeat(64), + ); + m.name = name.into(); + m.versions[0].version = version.into(); + m + }; + let mods = merge(vec![ + entry("Cogwork 2", "0.3.0", 'c'), + entry("Cogwork", "0.2.0", 'b'), + entry("Old name", "0.2.0", 'a'), + entry("Old name", "0.1.0", 'c'), + ]); + assert_eq!(mods.len(), 1); + assert_eq!( + mods[0].name, "Cogwork 2", + "the newest release names the mod" + ); + let versions: Vec<&str> = mods[0] + .versions + .iter() + .map(|v| v.version.as_str()) + .collect(); + assert_eq!(versions, ["0.3.0", "0.2.0"]); + assert_eq!(mods[0].versions[1].asset.sha256, "b".repeat(64)); + } + #[test] fn github_prerelease_wins() { let (r, zip) = release(true); From c5630f15fa304e83ea4d50cba98720d5f2e1a21f Mon Sep 17 00:00:00 2001 From: Zorro909 Date: Wed, 4 Nov 2026 21:38:51 +0000 Subject: [PATCH 024/156] Generate typed command signatures and event payloads commands.ts now maps every command to its arguments and result (CommandSignatures), read from each command's Rust declaration, and every event to its payload (EventPayloads). install-progress gets a typed InstallProgress payload and GameStateEvent.state becomes running/stopped. api.ts sends every command through call(), typed by that map; modsApi keeps its names and gains restore, and launcherApi covers the version and launch commands. listenTo() types event payloads the same way. --- src-tauri/src/bindings.rs | 248 ++++++++++++++++++++++++++- src-tauri/src/install.rs | 87 ++++++---- src-tauri/src/ipc.rs | 16 +- src-tauri/src/launch.rs | 14 +- src/mods/api.ts | 82 ++++++--- src/mods/bindings/GameState.ts | 3 + src/mods/bindings/GameStateEvent.ts | 6 +- src/mods/bindings/InstallProgress.ts | 11 ++ src/mods/bindings/InstallStage.ts | 6 + src/mods/bindings/commands.ts | 48 ++++++ src/mods/bindings/index.ts | 3 + 11 files changed, 448 insertions(+), 76 deletions(-) create mode 100644 src/mods/bindings/GameState.ts create mode 100644 src/mods/bindings/InstallProgress.ts create mode 100644 src/mods/bindings/InstallStage.ts diff --git a/src-tauri/src/bindings.rs b/src-tauri/src/bindings.rs index 77ac244..f98b505 100644 --- a/src-tauri/src/bindings.rs +++ b/src-tauri/src/bindings.rs @@ -1,6 +1,10 @@ //! The TypeScript side of every type that crosses IPC, generated with `ts-rs` into //! `src/mods/bindings/` (one file per type, plus `index.ts` and `commands.ts`). //! +//! `commands.ts` also maps every command to its arguments and result (`CommandSignatures`), +//! read from the command's own declaration, and every event to its payload (`EventPayloads`). +//! A renamed argument or a changed result type changes the page's types with it. +//! //! `bindings_are_current` fails when the committed files differ from what the Rust types say. //! To regenerate them: `cargo test export_bindings -- --ignored` in `src-tauri`. @@ -9,7 +13,7 @@ use std::path::{Path, PathBuf}; use ts_rs::{Config, TS}; use crate::error::{AppError, ErrorCode}; -use crate::install::InstalledVersion; +use crate::install::{InstallProgress, InstalledVersion}; use crate::launch::GameStateEvent; use crate::mods::catalog::Catalog; use crate::mods::diagnose::{FailureKind, ModFailure}; @@ -28,8 +32,9 @@ fn committed_dir() -> PathBuf { const HEADER: &str = "// Generated from the Rust types by src-tauri/src/bindings.rs. Do not edit.\n"; -/// Every command `lib.rs` registers, read from its `generate_handler!` list. -fn command_names() -> Vec { +/// Every command `lib.rs` registers, as written in its `generate_handler!` list +/// (`mods::commands::install_mod`). +fn command_paths() -> Vec { let lib = include_str!("lib.rs"); let start = lib .find("generate_handler![") @@ -39,14 +44,160 @@ fn command_names() -> Vec { list.split(',') .map(|p| p.trim()) .filter(|p| !p.is_empty()) + .map(str::to_string) + .collect() +} + +fn command_names() -> Vec { + command_paths() + .iter() .map(|p| p.rsplit("::").next().unwrap().to_string()) .collect() } +/// A command's arguments as the page passes them, and its result. +#[derive(Debug, PartialEq)] +struct Signature { + args: Vec<(String, String)>, + result: String, +} + +/// Splits at commas that aren't inside `<>` or `()`. +fn split_top(s: &str) -> Vec<&str> { + let (mut depth, mut start, mut out) = (0i32, 0, Vec::new()); + for (i, c) in s.char_indices() { + match c { + '<' | '(' => depth += 1, + '>' | ')' => depth -= 1, + ',' if depth == 0 => { + out.push(s[start..i].trim()); + start = i + 1; + } + _ => {} + } + } + out.push(s[start..].trim()); + out.into_iter().filter(|p| !p.is_empty()).collect() +} + +/// The TypeScript for a Rust type in a command signature. Named types must be exported +/// bindings; they are collected into `named`. +fn ts_type(rust: &str, named: &mut std::collections::BTreeSet) -> String { + let t = rust.trim(); + let inner = |prefix: &str| { + t.strip_prefix(prefix) + .and_then(|r| r.strip_suffix('>')) + .map(str::to_string) + }; + if let Some(i) = inner("Option<") { + return format!("{} | null", ts_type(&i, named)); + } + if let Some(i) = inner("Vec<") { + return format!("Array<{}>", ts_type(&i, named)); + } + match t { + "String" | "&str" | "PathBuf" => "string".into(), + "bool" => "boolean".into(), + "()" => "void".into(), + "u8" | "u16" | "u32" | "u64" | "usize" | "i32" | "i64" | "f64" => "number".into(), + path => { + let name = path.rsplit("::").next().unwrap(); + assert!( + name.chars().all(|c| c.is_ascii_alphanumeric()) && !name.is_empty(), + "can't write {path:?} in TypeScript" + ); + let name = if name == "AppError" { + "AppErrorPayload" + } else { + name + }; + named.insert(name.to_string()); + name.to_string() + } + } +} + +/// Arguments the page doesn't pass: Tauri fills them in. +fn injected(rust_type: &str) -> bool { + ["AppHandle", "State<", "Window", "Webview"] + .iter() + .any(|t| rust_type.contains(t)) +} + +/// Reads `pub async fn (…) -> Result` from Rust source. +fn signature_in( + source: &str, + name: &str, + named: &mut std::collections::BTreeSet, +) -> Signature { + let start = [format!("pub async fn {name}("), format!("pub fn {name}(")] + .iter() + .find_map(|p| source.find(p.as_str()).map(|at| at + p.len())) + .unwrap_or_else(|| panic!("no declaration of command {name}")); + let rest = &source[start..]; + let mut depth = 1; + let close = rest + .char_indices() + .find(|&(_, c)| { + match c { + '(' => depth += 1, + ')' => depth -= 1, + _ => {} + } + depth == 0 + }) + .map(|(i, _)| i) + .expect("end of the argument list"); + let args = split_top(&rest[..close]) + .into_iter() + .filter_map(|param| { + let (arg, ty) = param.split_once(':').expect("name: Type"); + (!injected(ty)).then(|| (camel(arg.trim()), ts_type(ty, named))) + }) + .collect(); + let after = &rest[close + 1..]; + let ret = after[..after.find('{').expect("function body")] + .trim() + .strip_prefix("->") + .map(str::trim) + .unwrap_or("()"); + let ok = ret + .strip_prefix("Result<") + .and_then(|r| r.strip_suffix('>')) + .map(|r| split_top(r)[0].to_string()) + .unwrap_or_else(|| ret.to_string()); + Signature { + args, + result: ts_type(&ok, named), + } +} + +/// The source file of a module path like `mods::commands`. +fn module_source(module: &str) -> String { + let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let rel = module.replace("::", "/"); + [format!("{rel}.rs"), format!("{rel}/mod.rs")] + .iter() + .find_map(|f| std::fs::read_to_string(src.join(f)).ok()) + .unwrap_or_else(|| panic!("no source for module {module}")) +} + +fn signatures(named: &mut std::collections::BTreeSet) -> Vec<(String, Signature)> { + command_paths() + .iter() + .map(|path| { + let (module, name) = path.rsplit_once("::").expect("module::command"); + let sig = signature_in(&module_source(module), name, named); + (name.to_string(), sig) + }) + .collect() +} + +/// Tauri passes arguments under their camelCase names (`asset_url` as `assetUrl`). fn camel(snake: &str) -> String { let mut out = String::new(); let mut upper = false; - for c in snake.chars() { + for c in snake.trim_start_matches('_').chars() { if c == '_' { upper = true; } else if upper { @@ -60,7 +211,14 @@ fn camel(snake: &str) -> String { } fn commands_ts() -> String { + let mut named = std::collections::BTreeSet::new(); + let sigs = signatures(&mut named); + named.extend(crate::ipc::EVENTS.iter().map(|(_, _, t)| t.to_string())); let mut s = String::from(HEADER); + s.push('\n'); + for t in &named { + s.push_str(&format!("import type {{ {t} }} from \"./{t}\";\n")); + } s.push_str( "\n/** Every Tauri command, by the name `invoke` takes. */\nexport const COMMANDS = {\n", ); @@ -71,10 +229,32 @@ fn commands_ts() -> String { "} as const;\n\nexport type CommandName = (typeof COMMANDS)[keyof typeof COMMANDS];\n", ); s.push_str("\n/** Every event the backend emits. */\nexport const EVENTS = {\n"); - for (key, name) in crate::ipc::EVENTS { + for (key, name, _) in crate::ipc::EVENTS { s.push_str(&format!(" {key}: \"{name}\",\n")); } s.push_str("} as const;\n\nexport type EventName = (typeof EVENTS)[keyof typeof EVENTS];\n"); + + s.push_str( + "\n/** Every command's arguments and result, read from its declaration in Rust. */\n\ + export interface CommandSignatures {\n", + ); + for (name, sig) in &sigs { + let args = if sig.args.is_empty() { + "Record".to_string() + } else { + let fields: Vec = sig.args.iter().map(|(n, t)| format!("{n}: {t}")).collect(); + format!("{{ {} }}", fields.join("; ")) + }; + s.push_str(&format!( + " {name}: {{ args: {args}; result: {} }};\n", + sig.result + )); + } + s.push_str("}\n\n/** Every event's payload. */\nexport interface EventPayloads {\n"); + for (_, name, payload) in crate::ipc::EVENTS { + s.push_str(&format!(" \"{name}\": {payload};\n")); + } + s.push_str("}\n"); s } @@ -103,6 +283,7 @@ fn export_into(dir: &Path) { ModFailure, FailureKind, GameStateEvent, + InstallProgress, Release, InstalledVersion, ); @@ -122,7 +303,15 @@ fn export_into(dir: &Path) { } index.push_str("export * from \"./commands\";\n"); std::fs::write(dir.join("index.ts"), index).unwrap(); - std::fs::write(dir.join("commands.ts"), commands_ts()).unwrap(); + let commands = commands_ts(); + for line in commands.lines().filter(|l| l.starts_with("import type")) { + let module = line.rsplit("\"./").next().unwrap().trim_end_matches("\";"); + assert!( + modules.iter().any(|m| m == module), + "commands.ts names {module}, which no exported type is; add it to export_into" + ); + } + std::fs::write(dir.join("commands.ts"), commands).unwrap(); } fn read_all(dir: &Path) -> BTreeMap { @@ -189,4 +378,51 @@ fn every_command_is_listed() { assert!(names.iter().any(|n| n == expected), "{expected}"); } assert_eq!(camel("preview_copy_mod_profile"), "previewCopyModProfile"); + assert_eq!(camel("expected_sha256"), "expectedSha256"); + assert_eq!(camel("_tag"), "tag"); +} + +#[test] +fn signatures_are_read_from_the_declarations() { + let source = r#" + #[tauri::command] + pub async fn install_version( + app_handle: tauri::AppHandle, + tag: String, + expected_sha256: Option, + ) -> Result { + todo!() + } + pub async fn list(app: AppHandle, force_refresh: bool) -> Result, AppError> {} + pub async fn open(app: AppHandle, _tag: String) -> Result<(), AppError> {} + "#; + let mut named = std::collections::BTreeSet::new(); + let sig = signature_in(source, "install_version", &mut named); + assert_eq!( + sig, + Signature { + args: vec![ + ("tag".into(), "string".into()), + ("expectedSha256".into(), "string | null".into()) + ], + result: "InstalledVersion".into(), + } + ); + let sig = signature_in(source, "list", &mut named); + assert_eq!( + sig.args, + [("forceRefresh".to_string(), "boolean".to_string())] + ); + assert_eq!(sig.result, "Array"); + let sig = signature_in(source, "open", &mut named); + assert_eq!(sig.args, [("tag".to_string(), "string".to_string())]); + assert_eq!(sig.result, "void"); + assert_eq!(named.into_iter().collect::>(), ["InstalledVersion"]); + + // Every registered command has one. + let mut named = std::collections::BTreeSet::new(); + let all = signatures(&mut named); + assert_eq!(all.len(), command_names().len()); + let restore = all.iter().find(|(n, _)| n == "restore_mod").unwrap(); + assert_eq!(restore.1.result, "ChangeResult"); } diff --git a/src-tauri/src/install.rs b/src-tauri/src/install.rs index da64ba6..2cf63ce 100644 --- a/src-tauri/src/install.rs +++ b/src-tauri/src/install.rs @@ -62,6 +62,29 @@ pub enum InstallError { InstallInProgress(String), } +/// What a game version install is doing. +#[derive(Debug, Clone, Copy, Serialize, ts_rs::TS)] +#[serde(rename_all = "lowercase")] +pub enum InstallStage { + Downloading, + Verifying, + Extracting, + Finalizing, +} + +/// The `install-progress` event. +#[derive(Debug, Clone, Serialize, ts_rs::TS)] +#[serde(rename_all = "camelCase")] +pub struct InstallProgress { + pub tag: String, + pub downloaded: u64, + /// 0 when the server doesn't say. + pub total: u64, + pub percent: u32, + pub stage: InstallStage, + pub speed_mbps: f64, +} + #[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)] pub struct InstalledVersion { pub tag: String, @@ -234,14 +257,14 @@ async fn download_and_install( let downloaded = crate::download::to_file(&client, asset_url, temp_download, None, |p| { let _ = app_handle.emit( crate::ipc::INSTALL_PROGRESS, - serde_json::json!({ - "tag": tag_owned, - "downloaded": p.downloaded, - "total": p.total, - "percent": p.percent, - "stage": "downloading", - "speedMbps": p.speed_mbps - }), + InstallProgress { + tag: tag_owned.clone(), + downloaded: p.downloaded, + total: p.total, + percent: p.percent, + stage: InstallStage::Downloading, + speed_mbps: p.speed_mbps, + }, ); }) .await @@ -250,14 +273,14 @@ async fn download_and_install( let _ = app_handle.emit( crate::ipc::INSTALL_PROGRESS, - serde_json::json!({ - "tag": tag, - "downloaded": downloaded.bytes, - "total": total_size, - "percent": 100, - "stage": "verifying", - "speedMbps": 0.0 - }), + InstallProgress { + tag: tag.to_string(), + downloaded: downloaded.bytes, + total: total_size, + percent: 100, + stage: InstallStage::Verifying, + speed_mbps: 0.0, + }, ); // Hashed while it downloaded. @@ -273,14 +296,14 @@ async fn download_and_install( // Extract to temp directory let _ = app_handle.emit( crate::ipc::INSTALL_PROGRESS, - serde_json::json!({ - "tag": tag, - "downloaded": downloaded, - "total": total_size, - "percent": 100, - "stage": "extracting", - "speedMbps": 0.0 - }), + InstallProgress { + tag: tag.to_string(), + downloaded, + total: total_size, + percent: 100, + stage: InstallStage::Extracting, + speed_mbps: 0.0, + }, ); if temp_extract.exists() { @@ -339,14 +362,14 @@ async fn download_and_install( // Finalizing let _ = app_handle.emit( crate::ipc::INSTALL_PROGRESS, - serde_json::json!({ - "tag": tag, - "downloaded": downloaded, - "total": total_size, - "percent": 100, - "stage": "finalizing", - "speedMbps": 0.0 - }), + InstallProgress { + tag: tag.to_string(), + downloaded, + total: total_size, + percent: 100, + stage: InstallStage::Finalizing, + speed_mbps: 0.0, + }, ); // Atomic rename diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index c1f687b..e57e15b 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -1,15 +1,17 @@ -//! Names shared with the page. Command names come from `generate_handler!` in `lib.rs`; both -//! lists are written to `src/mods/bindings/commands.ts` by the `bindings` test. +//! Names shared with the page. Command names and signatures come from `generate_handler!` in +//! `lib.rs` and the commands' own declarations; they and the events below are written to +//! `src/mods/bindings/commands.ts` by the `bindings` test. /// `ModProgress`, while a mod downloads, is checked and is installed. pub const MOD_PROGRESS: &str = "mod-progress"; /// `GameStateEvent`, when a game starts or stops. pub const GAME_STATE: &str = "game-state"; -/// Progress of a game version install (an untyped object). +/// `InstallProgress`, while a game version downloads and installs. pub const INSTALL_PROGRESS: &str = "install-progress"; -pub const EVENTS: &[(&str, &str)] = &[ - ("modProgress", MOD_PROGRESS), - ("gameState", GAME_STATE), - ("installProgress", INSTALL_PROGRESS), +/// Every event: its key in `EVENTS`, its name, and the type of its payload. +pub const EVENTS: &[(&str, &str, &str)] = &[ + ("modProgress", MOD_PROGRESS, "ModProgress"), + ("gameState", GAME_STATE, "GameStateEvent"), + ("installProgress", INSTALL_PROGRESS, "InstallProgress"), ]; diff --git a/src-tauri/src/launch.rs b/src-tauri/src/launch.rs index b5a4fc6..2396465 100644 --- a/src-tauri/src/launch.rs +++ b/src-tauri/src/launch.rs @@ -103,11 +103,19 @@ fn extract_game_folder_path(line: &str) -> Option { Some(cleaned[start..start + end].trim().to_string()) } +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, ts_rs::TS)] +#[serde(rename_all = "lowercase")] +pub enum GameState { + Running, + Stopped, +} + +/// The `game-state` event. #[derive(Debug, Clone, serde::Serialize, ts_rs::TS)] #[serde(rename_all = "camelCase")] pub struct GameStateEvent { pub tag: String, - pub state: String, + pub state: GameState, pub exit_code: Option, /// The game only ends with an error of its own, a panic or a fault when something went /// wrong. On Unix a signal leaves no exit code at all, so the code alone can't tell. @@ -309,7 +317,7 @@ async fn start_game( crate::ipc::GAME_STATE, GameStateEvent { tag: tag.to_string(), - state: "running".to_string(), + state: GameState::Running, exit_code: None, crashed: false, mod_failure: None, @@ -338,7 +346,7 @@ async fn start_game( crate::ipc::GAME_STATE, GameStateEvent { tag, - state: "stopped".to_string(), + state: GameState::Stopped, exit_code: status.and_then(|s| s.code()), crashed, mod_failure, diff --git a/src/mods/api.ts b/src/mods/api.ts index 38c4781..d76865a 100644 --- a/src/mods/api.ts +++ b/src/mods/api.ts @@ -1,37 +1,65 @@ -// One typed function per mod command, so a renamed command or argument fails in one place. +// Every backend command goes through `call`, typed by the generated `CommandSignatures`: a renamed +// command or argument, or a changed result, fails to compile here instead of failing at runtime. // A rejected promise carries an AppErrorPayload; `asAppError` turns anything else into one. -import { invoke } from "@tauri-apps/api/core"; +import { invoke, type InvokeArgs } from "@tauri-apps/api/core"; +import { listen, type EventCallback, type UnlistenFn } from "@tauri-apps/api/event"; +import type { CommandSignatures, EventPayloads } from "./bindings/commands"; import { COMMANDS } from "./ipc"; -import type { - AppErrorPayload, - Catalog, - ChangeResult, - CopyPreview, - CopyResult, - FileInspection, - InstallPlan, - ProfileReport, -} from "./types"; +import type { AppErrorPayload } from "./types"; + +export type CommandArgs = CommandSignatures[K]["args"]; +export type CommandResult = CommandSignatures[K]["result"]; + +/** Calls a backend command with the arguments and result its Rust declaration has. */ +export function call( + cmd: K, + args: CommandSignatures[K]["args"], +): Promise { + return invoke(cmd, args as InvokeArgs); +} + +/** Listens to a backend event with its generated payload type. */ +export function listenTo( + event: E, + handler: EventCallback, +): Promise { + return listen(event, handler); +} export const modsApi = { - catalog: (forceRefresh: boolean) => invoke(COMMANDS.listModCatalog, { forceRefresh }), - profile: (tag: string) => invoke(COMMANDS.getModProfile, { tag }), - plan: (tag: string, sha256: string) => invoke(COMMANDS.planModInstall, { tag, sha256 }), - install: (tag: string, sha256: string) => invoke(COMMANDS.installMod, { tag, sha256 }), - inspectFile: (tag: string, path: string) => invoke(COMMANDS.inspectModFile, { tag, path }), + catalog: (forceRefresh: boolean) => call(COMMANDS.listModCatalog, { forceRefresh }), + profile: (tag: string) => call(COMMANDS.getModProfile, { tag }), + plan: (tag: string, sha256: string) => call(COMMANDS.planModInstall, { tag, sha256 }), + install: (tag: string, sha256: string) => call(COMMANDS.installMod, { tag, sha256 }), + inspectFile: (tag: string, path: string) => call(COMMANDS.inspectModFile, { tag, path }), installFile: (tag: string, path: string, sha256: string) => - invoke(COMMANDS.installModFile, { tag, path, sha256 }), + call(COMMANDS.installModFile, { tag, path, sha256 }), setEnabled: (tag: string, id: string, enabled: boolean) => - invoke(COMMANDS.setModEnabled, { tag, id, enabled }), - remove: (tag: string, id: string) => invoke(COMMANDS.removeMod, { tag, id }), - previewCopy: (from: string, to: string) => invoke(COMMANDS.previewCopyModProfile, { from, to }), - copyProfile: (from: string, to: string) => invoke(COMMANDS.copyModProfile, { from, to }), - sources: () => invoke(COMMANDS.getModSources), - addSource: (repo: string) => invoke(COMMANDS.addModSource, { repo }), - removeSource: (repo: string) => invoke(COMMANDS.removeModSource, { repo }), - icon: (url: string) => invoke(COMMANDS.getModIcon, { url }), - openPlayerModsFolder: () => invoke(COMMANDS.openPlayerModsFolder), + call(COMMANDS.setModEnabled, { tag, id, enabled }), + remove: (tag: string, id: string) => call(COMMANDS.removeMod, { tag, id }), + /** Undoes the most recent `remove` for this version; `ModNotInstalled` once it can't. */ + restore: (tag: string, id: string) => call(COMMANDS.restoreMod, { tag, id }), + previewCopy: (from: string, to: string) => call(COMMANDS.previewCopyModProfile, { from, to }), + copyProfile: (from: string, to: string) => call(COMMANDS.copyModProfile, { from, to }), + sources: () => call(COMMANDS.getModSources, {}), + addSource: (repo: string) => call(COMMANDS.addModSource, { repo }), + removeSource: (repo: string) => call(COMMANDS.removeModSource, { repo }), + icon: (url: string) => call(COMMANDS.getModIcon, { url }), + openPlayerModsFolder: () => call(COMMANDS.openPlayerModsFolder, {}), +}; + +/** The game version and launch commands. */ +export const launcherApi = { + versions: (forceRefresh: boolean) => call(COMMANDS.listVersions, { forceRefresh }), + installVersion: (tag: string, assetUrl: string, assetName: string, expectedSha256: string | null) => + call(COMMANDS.installVersion, { tag, assetUrl, assetName, expectedSha256 }), + installedVersions: () => call(COMMANDS.getInstalledVersions, {}), + removeVersion: (tag: string) => call(COMMANDS.removeVersion, { tag }), + launch: (tag: string) => call(COMMANDS.launchVersion, { tag }), + runningVersions: () => call(COMMANDS.getRunningVersions, {}), + openGameFolder: (tag: string) => call(COMMANDS.openGameFolder, { tag }), + openLog: (tag: string) => call(COMMANDS.openLog, { tag }), }; /** Whatever a command rejected with, as the payload the backend sends. */ diff --git a/src/mods/bindings/GameState.ts b/src/mods/bindings/GameState.ts new file mode 100644 index 0000000..9dea23d --- /dev/null +++ b/src/mods/bindings/GameState.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type GameState = "running" | "stopped"; diff --git a/src/mods/bindings/GameStateEvent.ts b/src/mods/bindings/GameStateEvent.ts index d189857..7135979 100644 --- a/src/mods/bindings/GameStateEvent.ts +++ b/src/mods/bindings/GameStateEvent.ts @@ -1,7 +1,11 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { GameState } from "./GameState"; import type { ModFailure } from "./ModFailure"; -export type GameStateEvent = { tag: string, state: string, exitCode: number | null, +/** + * The `game-state` event. + */ +export type GameStateEvent = { tag: string, state: GameState, exitCode: number | null, /** * The game only ends with an error of its own, a panic or a fault when something went * wrong. On Unix a signal leaves no exit code at all, so the code alone can't tell. diff --git a/src/mods/bindings/InstallProgress.ts b/src/mods/bindings/InstallProgress.ts new file mode 100644 index 0000000..2231a31 --- /dev/null +++ b/src/mods/bindings/InstallProgress.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { InstallStage } from "./InstallStage"; + +/** + * The `install-progress` event. + */ +export type InstallProgress = { tag: string, downloaded: number, +/** + * 0 when the server doesn't say. + */ +total: number, percent: number, stage: InstallStage, speedMbps: number, }; diff --git a/src/mods/bindings/InstallStage.ts b/src/mods/bindings/InstallStage.ts new file mode 100644 index 0000000..293e8a0 --- /dev/null +++ b/src/mods/bindings/InstallStage.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * What a game version install is doing. + */ +export type InstallStage = "downloading" | "verifying" | "extracting" | "finalizing"; diff --git a/src/mods/bindings/commands.ts b/src/mods/bindings/commands.ts index 56b40f5..0e3f482 100644 --- a/src/mods/bindings/commands.ts +++ b/src/mods/bindings/commands.ts @@ -1,5 +1,18 @@ // Generated from the Rust types by src-tauri/src/bindings.rs. Do not edit. +import type { Catalog } from "./Catalog"; +import type { ChangeResult } from "./ChangeResult"; +import type { CopyPreview } from "./CopyPreview"; +import type { CopyResult } from "./CopyResult"; +import type { FileInspection } from "./FileInspection"; +import type { GameStateEvent } from "./GameStateEvent"; +import type { InstallPlan } from "./InstallPlan"; +import type { InstallProgress } from "./InstallProgress"; +import type { InstalledVersion } from "./InstalledVersion"; +import type { ModProgress } from "./ModProgress"; +import type { ProfileReport } from "./ProfileReport"; +import type { Release } from "./Release"; + /** Every Tauri command, by the name `invoke` takes. */ export const COMMANDS = { listVersions: "list_versions", @@ -38,3 +51,38 @@ export const EVENTS = { } as const; export type EventName = (typeof EVENTS)[keyof typeof EVENTS]; + +/** Every command's arguments and result, read from its declaration in Rust. */ +export interface CommandSignatures { + list_versions: { args: { forceRefresh: boolean }; result: Array }; + install_version: { args: { tag: string; assetUrl: string; assetName: string; expectedSha256: string | null }; result: InstalledVersion }; + get_installed_versions: { args: Record; result: Array }; + remove_version: { args: { tag: string }; result: void }; + launch_version: { args: { tag: string }; result: void }; + get_running_versions: { args: Record; result: Array }; + open_game_folder: { args: { tag: string }; result: void }; + open_log: { args: { tag: string }; result: void }; + list_mod_catalog: { args: { forceRefresh: boolean }; result: Catalog }; + get_mod_sources: { args: Record; result: Array }; + add_mod_source: { args: { repo: string }; result: Array }; + remove_mod_source: { args: { repo: string }; result: Array }; + get_mod_profile: { args: { tag: string }; result: ProfileReport }; + plan_mod_install: { args: { tag: string; sha256: string }; result: InstallPlan }; + install_mod: { args: { tag: string; sha256: string }; result: ChangeResult }; + inspect_mod_file: { args: { tag: string; path: string }; result: FileInspection }; + install_mod_file: { args: { tag: string; path: string; sha256: string }; result: ChangeResult }; + set_mod_enabled: { args: { tag: string; id: string; enabled: boolean }; result: ChangeResult }; + remove_mod: { args: { tag: string; id: string }; result: ChangeResult }; + restore_mod: { args: { tag: string; id: string }; result: ChangeResult }; + preview_copy_mod_profile: { args: { from: string; to: string }; result: CopyPreview }; + copy_mod_profile: { args: { from: string; to: string }; result: CopyResult }; + get_mod_icon: { args: { url: string }; result: string }; + open_player_mods_folder: { args: Record; result: void }; +} + +/** Every event's payload. */ +export interface EventPayloads { + "mod-progress": ModProgress; + "game-state": GameStateEvent; + "install-progress": InstallProgress; +} diff --git a/src/mods/bindings/index.ts b/src/mods/bindings/index.ts index eb40a1d..eb9770c 100644 --- a/src/mods/bindings/index.ts +++ b/src/mods/bindings/index.ts @@ -17,9 +17,12 @@ export type * from "./ErrorCode"; export type * from "./FailureKind"; export type * from "./FileInspection"; export type * from "./Freshness"; +export type * from "./GameState"; export type * from "./GameStateEvent"; export type * from "./IndexStatus"; export type * from "./InstallPlan"; +export type * from "./InstallProgress"; +export type * from "./InstallStage"; export type * from "./InstalledMod"; export type * from "./InstalledVersion"; export type * from "./ModFailure"; From 158c2af63e64b0b9c918516311dcb110e03dc325 Mon Sep 17 00:00:00 2001 From: Zorro909 Date: Wed, 4 Nov 2026 21:40:07 +0000 Subject: [PATCH 025/156] Match crash diagnosis to what the engine logs The engine's fatal mod errors are logged by the weft binary at ERROR, as [weft], and an init panic reads ': init panicked'. The fixtures now use those lines, the mod of an init panic comes from that prefix, and only ERROR lines are read as failures. The --mods argument gets a test. --- src-tauri/src/launch.rs | 15 ++++++ src-tauri/src/mods/diagnose.rs | 89 +++++++++++++++++++++++----------- 2 files changed, 75 insertions(+), 29 deletions(-) diff --git a/src-tauri/src/launch.rs b/src-tauri/src/launch.rs index 2396465..da8f3d1 100644 --- a/src-tauri/src/launch.rs +++ b/src-tauri/src/launch.rs @@ -437,3 +437,18 @@ pub(crate) fn open_path(app_handle: &AppHandle, path: &Path) -> Result<(), Launc .open_path(path.to_string_lossy().as_ref(), None::<&str>)?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_mods_argument() { + assert!(game_args(None).is_empty(), "no folder, no --mods"); + let dir = Path::new("/data/profiles/v26.11.1/mods"); + assert_eq!( + game_args(Some(dir)), + ["--mods", "/data/profiles/v26.11.1/mods"] + ); + } +} diff --git a/src-tauri/src/mods/diagnose.rs b/src-tauri/src/mods/diagnose.rs index 52704bc..6c06f88 100644 --- a/src-tauri/src/mods/diagnose.rs +++ b/src-tauri/src/mods/diagnose.rs @@ -1,5 +1,7 @@ //! Names the mod that stopped the game from starting. Weft exits with code 1 and leaves the -//! reason in its log; these are the engine's own messages (`crates/engine/src/modloader.rs`). +//! reason in its log; these are the engine's own messages (`crates/engine/src/modloader.rs`), +//! which `crates/app/src/main.rs` logs with `log::error!`, so as `
      )} + {noIndex && !query && ( +

      + {t("mods.browseNoIndex")} +

      + )} {open && setOpen(null)} />} ); diff --git a/src/components/mods/catalogBanners.tsx b/src/components/mods/catalogBanners.tsx index 5595cdb..6d156a2 100644 --- a/src/components/mods/catalogBanners.tsx +++ b/src/components/mods/catalogBanners.tsx @@ -5,6 +5,7 @@ import type { Catalog, ErrorCode, SourceStatus } from "../../mods/types"; import type { BannerItem } from "./BannerStack"; import { showError } from "../../i18n/errors"; import { ErrorDetails } from "../ErrorText"; +import { httpStatus } from "../../mods/select"; type T = (key: MessageKey, ...args: string[]) => string; @@ -85,7 +86,10 @@ export function indexProblem(index: Catalog["index"], t: T, formatDay: (iso: str export function indexSummary(index: Catalog["index"], t: T, formatDay: (iso: string | null) => string): string | null { if (index.freshness === "fresh" && !index.error) return null; if (index.freshness === "unavailable" || !index.generated) { - return index.error && cause(index.error.code) === "network" && !refused(index.error.code) ? t("mods.indexUnreachableShort") : t("mods.indexUnavailableShort"); + if (index.error && cause(index.error.code) === "network" && !refused(index.error.code)) return t("mods.indexUnreachableShort"); + // The server answered: which status it said is the first thing to know. + const status = httpStatus(index.error); + return status ? t("mods.indexUnavailableStatusShort", status) : t("mods.indexUnavailableShort"); } const day = formatDay(index.signedAt ?? index.generated); if (index.freshness === "stale" && !index.error) return t("mods.indexStaleShort", day); diff --git a/src/dev/mockBackend.ts b/src/dev/mockBackend.ts index bb299ac..0d8ca7c 100644 --- a/src/dev/mockBackend.ts +++ b/src/dev/mockBackend.ts @@ -226,7 +226,7 @@ const catalog: Catalog = { freshness: "unavailable", generated: null, signedAt: null, - error: err("HttpStatus", "Unexpected HTTP status 404 for https://zorro909.github.io/weft-mods-index/v1/index.json"), + error: err("HttpStatus", "HTTP 404 (https://zorro909.github.io/weft-mods-index/v1/index.json)"), cacheError: null, } : indexFlag === "unreachable" diff --git a/src/i18n/en.json b/src/i18n/en.json index 5af75c1..80189d2 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -517,7 +517,9 @@ "homeModsOwn_one": "Mods for {0}: {1}, and {n} file from your own folder", "homeModsOwn_other": "Mods for {0}: {1}, and {n} files from your own folder", "homeModsOnlyOwn_one": "No mods for {0}, but {n} file from your own folder loads", - "homeModsOnlyOwn_other": "No mods for {0}, but {n} files from your own folder load" + "homeModsOnlyOwn_other": "No mods for {0}, but {n} files from your own folder load", + "indexUnavailableStatusShort": "No mod index (server said {0}): no Reviewed mods", + "browseNoIndex": "Reviewed mods appear here once the Weft mod index can be fetched." }, "sizeUnits": { "B": "B", diff --git a/src/i18n/es.json b/src/i18n/es.json index 6c110e5..40f2a26 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -551,7 +551,9 @@ "homeModsOwn_other": "Mods para {0}: {1} y {n} archivos de tu carpeta", "homeModsOnlyOwn_one": "Sin mods para {0}, pero se carga {n} archivo de tu carpeta", "homeModsOnlyOwn_many": "Sin mods para {0}, pero se cargan {n} archivos de tu carpeta", - "homeModsOnlyOwn_other": "Sin mods para {0}, pero se cargan {n} archivos de tu carpeta" + "homeModsOnlyOwn_other": "Sin mods para {0}, pero se cargan {n} archivos de tu carpeta", + "indexUnavailableStatusShort": "Sin índice de mods (servidor: {0}): sin mods revisados", + "browseNoIndex": "Los mods revisados aparecen aquí cuando se pueda descargar el índice de mods de Weft." }, "sizeUnits": { "B": "B", diff --git a/src/i18n/pt-BR.json b/src/i18n/pt-BR.json index 6856e9a..a71e3af 100644 --- a/src/i18n/pt-BR.json +++ b/src/i18n/pt-BR.json @@ -551,7 +551,9 @@ "homeModsOwn_other": "Mods para {0}: {1} e {n} arquivos da sua pasta", "homeModsOnlyOwn_one": "Sem mods para {0}, mas {n} arquivo da sua pasta carrega", "homeModsOnlyOwn_many": "Sem mods para {0}, mas {n} arquivos da sua pasta carregam", - "homeModsOnlyOwn_other": "Sem mods para {0}, mas {n} arquivos da sua pasta carregam" + "homeModsOnlyOwn_other": "Sem mods para {0}, mas {n} arquivos da sua pasta carregam", + "indexUnavailableStatusShort": "Sem índice de mods (servidor: {0}): sem mods revisados", + "browseNoIndex": "Os mods revisados aparecem aqui quando o índice de mods do Weft puder ser baixado." }, "sizeUnits": { "B": "B", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index 3f3d799..36a903d 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -585,7 +585,9 @@ "homeModsOnlyOwn_one": "Без модов для {0}, но загрузится {n} файл из своей папки", "homeModsOnlyOwn_few": "Без модов для {0}, но загрузятся {n} файла из своей папки", "homeModsOnlyOwn_many": "Без модов для {0}, но загрузятся {n} файлов из своей папки", - "homeModsOnlyOwn_other": "Без модов для {0}, но загрузятся {n} файла из своей папки" + "homeModsOnlyOwn_other": "Без модов для {0}, но загрузятся {n} файла из своей папки", + "indexUnavailableStatusShort": "Нет индекса (сервер: {0}): нет одобренных модов", + "browseNoIndex": "Одобренные моды появятся здесь, когда удастся загрузить индекс модов Weft." }, "sizeUnits": { "B": "Б", diff --git a/src/mods/select.test.ts b/src/mods/select.test.ts index e9d6b5a..4c737b1 100644 --- a/src/mods/select.test.ts +++ b/src/mods/select.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; import { + httpStatus, + buildBySha, compareVersions, copyDonors, fittingVersions, @@ -148,3 +150,22 @@ describe("networkDown", () => { expect(networkDown(catalog([], { sources: [offline] }), null)).toBe(false); }); }); + +describe("httpStatus", () => { + it("reads the status the backend puts first in an HttpStatus detail", () => { + expect(httpStatus({ code: "HttpStatus", detail: "HTTP 404 (https://example.org/index.json)" })).toBe("404"); + expect(httpStatus({ code: "HttpStatus", detail: null })).toBeNull(); + expect(httpStatus({ code: "Network", detail: "HTTP 404 (x)" })).toBeNull(); + expect(httpStatus(null)).toBeNull(); + }); +}); + +describe("buildBySha", () => { + it("finds a mod's catalogue build by hash, and nothing for another mod's", () => { + const v = build("0.2.0"); + const c = catalog([catalogMod("cogwork", [build("0.1.0"), v])]); + expect(buildBySha(c, "cogwork", v.sha256)).toBe(v); + expect(buildBySha(c, "hearth", v.sha256)).toBeUndefined(); + expect(buildBySha(null, "cogwork", v.sha256)).toBeUndefined(); + }); +}); diff --git a/src/mods/select.ts b/src/mods/select.ts index 805ee4c..463297e 100644 --- a/src/mods/select.ts +++ b/src/mods/select.ts @@ -145,6 +145,15 @@ export function buildBySha(catalog: Catalog | null, id: string, sha256: string): return undefined; } +/** + * The status a server answered with, for an `HttpStatus` error: its detail starts "HTTP 404" + * (src-tauri/src/mods/error.rs keeps it so). Null for any other error. + */ +export function httpStatus(error: { code: string; detail: string | null } | null | undefined): string | null { + if (error?.code !== "HttpStatus") return null; + return /^HTTP (\d{3})\b/.exec(error.detail ?? "")?.[1] ?? null; +} + export function shortSha(sha: string): string { return `${sha.slice(0, 8)}…${sha.slice(-4)}`; } diff --git a/src/style.css b/src/style.css index f046de8..147ca9d 100644 --- a/src/style.css +++ b/src/style.css @@ -576,6 +576,7 @@ button:disabled { cursor: not-allowed; } .source-error { grid-column: 1 / -1; margin-top: 8px; } .inline-error.with-action { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 12px; } .copy-list { margin-top: 12px; } +.browse-note { margin: 12px 0 0; color: var(--g5); } .copy-item { display: grid; gap: 2px; min-width: 0; } .copy-item-head { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; min-width: 0; } .copy-item .progress-container { width: 240px; } From eb91cd6717a07f63bd3dd7f256e3712b16d896d9 Mon Sep 17 00:00:00 2001 From: Zorro909 Date: Thu, 5 Nov 2026 06:45:47 +0000 Subject: [PATCH 116/156] A source's rate limit says until when, from GitHub's x-ratelimit-reset SourceRateLimited carries the reset; its detail is 'resets at ', which the page turns into 'until 14:32'. The message no longer takes a {0} the backend never sent (it read 'limiting requests for .'); the mock now sends what the backend does. --- e2e/flows.spec.ts | 3 ++- src-tauri/src/mods/error.rs | 21 +++++++++++++++++---- src-tauri/src/mods/service_tests.rs | 15 +++++++++++++-- src-tauri/src/mods/sources.rs | 22 +++++++++++++++++----- src/components/mods/catalogBanners.tsx | 7 +++++-- src/dev/mockBackend.ts | 8 +++++--- src/i18n/en.json | 8 +++++--- src/i18n/errors.test.ts | 16 +++++++++++++++- src/i18n/errors.ts | 17 ++++++++++++++++- src/i18n/es.json | 8 +++++--- src/i18n/index.ts | 5 +++++ src/i18n/pt-BR.json | 8 +++++--- src/i18n/ru.json | 8 +++++--- 13 files changed, 115 insertions(+), 31 deletions(-) diff --git a/e2e/flows.spec.ts b/e2e/flows.spec.ts index 79644ff..f8d530a 100644 --- a/e2e/flows.spec.ts +++ b/e2e/flows.spec.ts @@ -1062,7 +1062,8 @@ test.describe("degraded states", () => { await expect(index.locator(".index-badge")).toHaveText("Signed Sep 21 · may be out of date"); await expect(index.locator("button", { hasText: "Retry" })).toHaveCount(1); const source = page.locator(".source-row", { hasText: "lantern-dev/lanterns" }); - await expect(source).toContainText("GitHub is limiting requests, so lantern-dev/lanterns couldn't be refreshed."); + // GitHub said when the limit ends: that is what the player needs. + await expect(source).toContainText(/GitHub is limiting requests until \d{1,2}:\d{2}( [AP]M)?, so lantern-dev\/lanterns couldn't be refreshed\./); await expect(source.locator("button", { hasText: "Retry" })).toHaveCount(1); }); diff --git a/src-tauri/src/mods/error.rs b/src-tauri/src/mods/error.rs index cea2eab..92efa9b 100644 --- a/src-tauri/src/mods/error.rs +++ b/src-tauri/src/mods/error.rs @@ -17,9 +17,10 @@ pub enum ModsError { /// A mod download that GitHub refused for too many requests. #[error("Too many requests")] RateLimited, - /// GitHub's API refused to list a source's releases for too many requests. + /// GitHub's API refused to list a source's releases for too many requests. `reset`: when + /// GitHub said the limit ends (`x-ratelimit-reset`, Unix seconds), when it said. #[error("GitHub API rate limit exceeded")] - SourceRateLimited, + SourceRateLimited { reset: Option }, #[error("Not on the server any more: {0}")] DownloadNotFound(String), #[error("Too large: {0}")] @@ -127,7 +128,7 @@ impl ModsError { Io(_) => C::Io, Serde(_) => C::Serde, RateLimited => C::RateLimited, - SourceRateLimited => C::ModSourceRateLimited, + SourceRateLimited { .. } => C::ModSourceRateLimited, DownloadNotFound(_) => C::ModDownloadNotFound, TooLarge(_) => C::ModTooLarge, IndexSignatureInvalid(_) => C::IndexSignatureInvalid, @@ -168,7 +169,9 @@ impl From for AppError { Io(e) => Some(e.to_string()), Serde(e) => Some(e.to_string()), Http { status, url } => Some(format!("HTTP {status} ({url})")), - RateLimited | SourceRateLimited | IndexRollback | FileChanged | Cancelled => None, + // The page reads the time from here (src/mods/select.ts `rateLimitReset`). + SourceRateLimited { reset } => reset.map(|r| format!("resets at {r}")), + RateLimited | IndexRollback | FileChanged | Cancelled => None, ArchiveInvalid(p) => Some(p.to_string()), Sha256Mismatch { expected, actual } => { Some(format!("expected {expected}, got {actual}")) @@ -220,6 +223,16 @@ mod tests { assert_eq!(http(503), ErrorCode::Network); } + #[test] + fn a_rate_limit_says_when_it_ends() { + let detail = |reset| AppError::from(ModsError::SourceRateLimited { reset }).detail; + assert_eq!( + detail(Some(1_793_000_000)).as_deref(), + Some("resets at 1793000000") + ); + assert_eq!(detail(None), None); + } + #[test] fn an_error_status_is_said_first_in_the_detail() { // The page reads the status from here (src/mods/select.ts `httpStatus`): keep it first. diff --git a/src-tauri/src/mods/service_tests.rs b/src-tauri/src/mods/service_tests.rs index d9ee5b5..0a0a8df 100644 --- a/src-tauri/src/mods/service_tests.rs +++ b/src-tauri/src/mods/service_tests.rs @@ -1349,7 +1349,11 @@ mod sources { ok(br#"{ "full_name": "Someone/Lanterns" }"#.to_vec()) } "/repos/a/odd" => ok(br#"{ "full_name": "../../x" }"#.to_vec()), - "/repos/a/limited" => (403, Vec::new(), Vec::new()), + "/repos/a/limited" => ( + 403, + vec![("x-ratelimit-reset", "1793000000".into())], + Vec::new(), + ), _ => (404, Vec::new(), Vec::new()), }); let ctx = with_version(tmp.path(), TAG).with_github_api(&api); @@ -1364,7 +1368,14 @@ mod sources { let failures: Vec<(&str, Expect)> = vec![ ("a/gone", |e| matches!(e, ModsError::SourceNotFound(_))), - ("a/limited", |e| matches!(e, ModsError::SourceRateLimited)), + ("a/limited", |e| { + matches!( + e, + ModsError::SourceRateLimited { + reset: Some(1_793_000_000) + } + ) + }), ("a/odd", |e| matches!(e, ModsError::SourceInvalid(_))), ("not a repo", |e| matches!(e, ModsError::SourceInvalid(_))), ("https://gitlab.com/a/b", |e| { diff --git a/src-tauri/src/mods/sources.rs b/src-tauri/src/mods/sources.rs index fcaf10e..5f03e2f 100644 --- a/src-tauri/src/mods/sources.rs +++ b/src-tauri/src/mods/sources.rs @@ -3,7 +3,7 @@ //! catalogue says; what is checked is that the description and the zip agree with GitHub's own //! record of the release. -use reqwest::header::{ETAG, IF_NONE_MATCH, LINK, USER_AGENT}; +use reqwest::header::{ETAG, HeaderMap, IF_NONE_MATCH, LINK, USER_AGENT}; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -341,6 +341,15 @@ async fn get_mod_json(client: &reqwest::Client, url: &str) -> Option { } /// The `rel="next"` URL of a GitHub `Link` header, if it stays on the API. +/// GitHub refused for too many requests, and said when the limit ends, when it said. +fn rate_limited(headers: &HeaderMap) -> ModsError { + let reset = headers + .get("x-ratelimit-reset") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.trim().parse().ok()); + ModsError::SourceRateLimited { reset } +} + pub(crate) fn next_page(link: &str) -> Option { link.split(',').find_map(|part| { let (url, params) = part.split_once(';')?; @@ -423,7 +432,7 @@ async fn list_releases( 200 => {} 304 if page == 0 => return Ok(Listing::NotModified), 404 => return Err(ModsError::SourceNotFound(repo.to_string())), - 403 | 429 => return Err(ModsError::SourceRateLimited), + 403 | 429 => return Err(rate_limited(response.headers())), status => return Err(ModsError::Http { status, url }), } releases.extend(response.json::>().await?); @@ -617,7 +626,7 @@ pub async fn canonical_name( match response.status().as_u16() { 200 => {} 404 => return Err(ModsError::SourceNotFound(repo.to_string())), - 403 | 429 => return Err(ModsError::SourceRateLimited), + 403 | 429 => return Err(rate_limited(response.headers())), status => return Err(ModsError::Http { status, url }), } let found: GhRepo = response.json().await?; @@ -879,11 +888,14 @@ pub(crate) mod tests { checked: Some("2026-11-01T00:00:00Z".into()), no_mods: None, }; - let got = fall_back(Some(cache), ModsError::SourceRateLimited); + let got = fall_back(Some(cache), ModsError::SourceRateLimited { reset: None }); assert_eq!(got.freshness, Freshness::Cached); assert_eq!(got.mods.len(), 1); assert_eq!(got.checked.as_deref(), Some("2026-11-01T00:00:00Z")); - assert!(matches!(got.error, Some(ModsError::SourceRateLimited))); + assert!(matches!( + got.error, + Some(ModsError::SourceRateLimited { .. }) + )); let got = fall_back(None, ModsError::SourceNotFound("a/b".into())); assert_eq!(got.freshness, Freshness::Unavailable); diff --git a/src/components/mods/catalogBanners.tsx b/src/components/mods/catalogBanners.tsx index 6d156a2..6526a58 100644 --- a/src/components/mods/catalogBanners.tsx +++ b/src/components/mods/catalogBanners.tsx @@ -3,7 +3,8 @@ import { useI18n } from "../../i18n/useI18n"; import { useMods } from "../../hooks/useMods"; import type { Catalog, ErrorCode, SourceStatus } from "../../mods/types"; import type { BannerItem } from "./BannerStack"; -import { showError } from "../../i18n/errors"; +import { rateLimitReset, showError } from "../../i18n/errors"; +import { formatTime } from "../../i18n"; import { ErrorDetails } from "../ErrorText"; import { httpStatus } from "../../mods/select"; @@ -56,7 +57,9 @@ const SOURCE_CAUSE: Record, MessageKey> = { export function sourceProblem(s: SourceStatus, t: T, formatDay: (iso: string | null) => string): string { const shown = s.freshness === "unavailable" || !s.checked ? t("mods.sourceShowingNone") : t("mods.sourceShowingFrom", formatDay(s.checked)); - return `${t(SOURCE_CAUSE[cause(s.error?.code)], s.repo)} ${shown}`; + const until = rateLimitReset(s.error); + const why = until ? t("mods.sourceErrRateUntil", s.repo, formatTime(until)) : t(SOURCE_CAUSE[cause(s.error?.code)], s.repo); + return `${why} ${shown}`; } /** A new index arrived and was refused (bad signature, malformed, older than one accepted). */ diff --git a/src/dev/mockBackend.ts b/src/dev/mockBackend.ts index 0d8ca7c..9536b8f 100644 --- a/src/dev/mockBackend.ts +++ b/src/dev/mockBackend.ts @@ -211,6 +211,8 @@ const indexFlag = flag("index") ?? "fresh"; const sourceFlag = flag("source") ?? "ok"; const INDEX_DATE = indexFlag === "fresh" ? "2026-11-04T18:00:00Z" : "2026-11-02T12:00:00Z"; const err = (code: ErrorCode, detail: string | null = null): AppErrorPayload => ({ code, detail }); +/** GitHub's word on when a rate limit ends, as the backend passes it on: 20 minutes from now. */ +const resetsAt = () => `resets at ${Math.floor(Date.now() / 1000) + 20 * 60}`; const catalog: Catalog = { mods: allMods.filter((m) => !((indexFlag === "unavailable" || indexFlag === "unreachable") && m.origin.kind === "index")), @@ -248,7 +250,7 @@ const catalog: Catalog = { sourceFlag === "notfound" ? err("ModSourceNotFound", LANTERN_REPO) : sourceFlag === "ratelimited" - ? err("ModSourceRateLimited", LANTERN_REPO) + ? err("ModSourceRateLimited", resetsAt()) : sourceFlag === "offline" ? err("Network", "offline") : null, @@ -857,7 +859,7 @@ const handlers: Handlers = { const late = forceRefresh && raceFlag && !raced; if (late) raced = true; const answer = { ...structuredClone(catalog), revision: ++revision }; - if (late && raceFlag === "stale") answer.sources = answer.sources.map((x) => ({ ...x, freshness: "cached", error: err("ModSourceRateLimited", x.repo) })); + if (late && raceFlag === "stale") answer.sources = answer.sources.map((x) => ({ ...x, freshness: "cached", error: err("ModSourceRateLimited", resetsAt()) })); await sleep(late ? 2500 : forceRefresh ? 400 : 0); if (late && raceFlag === "error") fail("Network", "the slow refresh timed out"); if (failures.has("catalog")) fail("Network", "the catalogue request timed out"); @@ -879,7 +881,7 @@ const handlers: Handlers = { add_mod_source: async ({ repo: input }) => { await sleep(500); const repo = parseRepo(input) ?? fail("ModSourceInvalid", input); - if (sourceFlag === "ratelimited") fail("ModSourceRateLimited", repo); + if (sourceFlag === "ratelimited") fail("ModSourceRateLimited", resetsAt()); const canonical = KNOWN_REPOS.find((k) => sameRepo(k, repo)) ?? fail("ModSourceNotFound", repo); // A damaged list stays where it was set aside; this starts a new one. if (sourcesDamaged) { diff --git a/src/i18n/en.json b/src/i18n/en.json index 80189d2..f88c141 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -88,7 +88,7 @@ "modSourceInvalid": "Not a GitHub repo: {0}", "modSourceNotFound": "No such GitHub repo: {0}", "diskFull": "The disk is full.", - "modSourceRateLimited": "GitHub is limiting requests for {0}. Try again in a few minutes.", + "modSourceRateLimited": "GitHub is limiting requests. Try again in a few minutes.", "modDownloadNotFound": "The mod's file is gone from GitHub ({0}), so nothing was installed. Refresh and try again.", "modTooLarge": "The mod's file is larger than the launcher accepts, so nothing was installed.", "modIdReserved": "\"{0}\" is reserved for Weft itself and can't be a mod's id.", @@ -99,7 +99,8 @@ "modUrlRefused": "A download link didn't point where the mod's listing says it should, so the launcher refused it. Nothing was downloaded.", "modIconInvalid": "This mod's icon isn't a small PNG, so it isn't shown.", "details": "Details", - "modSourcesDamaged": "Your list of mod sources couldn't be read, so it was set aside and kept. Add your sources again; the old list is behind Details." + "modSourcesDamaged": "Your list of mod sources couldn't be read, so it was set aside and kept. Add your sources again; the old list is behind Details.", + "modSourceRateLimitedUntil": "GitHub is limiting requests until {0}. Try again then." }, "mods": { "title": "Mods", @@ -519,7 +520,8 @@ "homeModsOnlyOwn_one": "No mods for {0}, but {n} file from your own folder loads", "homeModsOnlyOwn_other": "No mods for {0}, but {n} files from your own folder load", "indexUnavailableStatusShort": "No mod index (server said {0}): no Reviewed mods", - "browseNoIndex": "Reviewed mods appear here once the Weft mod index can be fetched." + "browseNoIndex": "Reviewed mods appear here once the Weft mod index can be fetched.", + "sourceErrRateUntil": "GitHub is limiting requests until {1}, so {0} couldn't be refreshed." }, "sizeUnits": { "B": "B", diff --git a/src/i18n/errors.test.ts b/src/i18n/errors.test.ts index 01f81bb..0ae1a94 100644 --- a/src/i18n/errors.test.ts +++ b/src/i18n/errors.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { asAppError, showError } from "./errors"; +import { asAppError, rateLimitReset, showError } from "./errors"; describe("one parser for rejections", () => { it("keeps a known code and its detail", () => { @@ -22,3 +22,17 @@ describe("one parser for rejections", () => { expect(showError(null)).toEqual({ message: "", detail: null }); }); }); + +describe("a source's rate limit", () => { + it("says until when, when GitHub said, and never puts the detail in the sentence", () => { + const at = Date.UTC(2026, 10, 5, 14, 32) / 1000; + const limited = { code: "ModSourceRateLimited", detail: `resets at ${at}` } as const; + expect(rateLimitReset(limited)?.getTime()).toBe(at * 1000); + const shown = showError(limited); + expect(shown.message).toMatch(/^GitHub is limiting requests until .+\. Try again then\.$/); + expect(shown.message).not.toContain("resets at"); + expect(shown.detail).toBeNull(); + expect(showError({ code: "ModSourceRateLimited", detail: null }).message).toBe("GitHub is limiting requests. Try again in a few minutes."); + expect(rateLimitReset({ code: "RateLimited", detail: `resets at ${at}` })).toBeNull(); + }); +}); diff --git a/src/i18n/errors.ts b/src/i18n/errors.ts index ea917a5..a4ae499 100644 --- a/src/i18n/errors.ts +++ b/src/i18n/errors.ts @@ -1,4 +1,4 @@ -import { t, type MessageKey } from './index'; +import { formatTime, t, type MessageKey } from './index'; import type { ErrorCode, UiError } from '../mods/types'; // Every code the backend can send, and the message for it. A Record over the closed union, so a @@ -87,6 +87,11 @@ export interface Shown { export function showError(err: unknown): Shown { if (err === null || err === undefined) return { message: '', detail: null }; const e = asAppError(err); + if (e.code === 'ModSourceRateLimited') { + // When GitHub said the limit ends, that is what to say: "until 14:32". + const until = rateLimitReset(e); + return { message: until ? t('errors.modSourceRateLimitedUntil', formatTime(until)) : t('errors.modSourceRateLimited'), detail: null }; + } if (e.code !== 'Unknown') { const key = ERROR_KEY[e.code]; if (takesDetail(key)) return { message: t(key, e.detail ?? ''), detail: null }; @@ -124,6 +129,16 @@ export function translateError(err: unknown): string { return showError(err).message; } +/** + * When GitHub said a source's rate limit ends: the backend's detail for `ModSourceRateLimited` + * is "resets at " (src-tauri/src/mods/error.rs) when it said. Null otherwise. + */ +export function rateLimitReset(error: { code: string; detail: string | null } | null | undefined): Date | null { + if (error?.code !== 'ModSourceRateLimited') return null; + const m = /^resets at (\d+)$/.exec(error.detail ?? ''); + return m ? new Date(Number(m[1]) * 1000) : null; +} + function takesDetail(key: MessageKey): boolean { return t(key, '\u0000').includes('\u0000'); } diff --git a/src/i18n/es.json b/src/i18n/es.json index 40f2a26..6c16bb9 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -88,7 +88,7 @@ "modSourceInvalid": "No es un repositorio de GitHub: {0}", "modSourceNotFound": "No existe ese repositorio de GitHub: {0}", "diskFull": "El disco está lleno.", - "modSourceRateLimited": "GitHub está limitando las peticiones a {0}. Vuelve a intentarlo en unos minutos.", + "modSourceRateLimited": "GitHub está limitando las peticiones. Vuelve a intentarlo en unos minutos.", "modDownloadNotFound": "El archivo del mod ya no está en GitHub ({0}), así que no se instaló nada. Actualiza y vuelve a intentarlo.", "modTooLarge": "El archivo del mod es más grande de lo que acepta el launcher, así que no se instaló nada.", "modIdReserved": "\"{0}\" está reservado para el propio Weft y no puede ser el id de un mod.", @@ -99,7 +99,8 @@ "modUrlRefused": "Un enlace de descarga no apuntaba adonde dice la ficha del mod, así que el launcher lo rechazó. No se descargó nada.", "modIconInvalid": "El icono de este mod no es un PNG pequeño, así que no se muestra.", "details": "Detalles", - "modSourcesDamaged": "No se pudo leer tu lista de fuentes de mods, así que se apartó y se guardó. Vuelve a añadir tus fuentes; la lista antigua está en Detalles." + "modSourcesDamaged": "No se pudo leer tu lista de fuentes de mods, así que se apartó y se guardó. Vuelve a añadir tus fuentes; la lista antigua está en Detalles.", + "modSourceRateLimitedUntil": "GitHub está limitando las peticiones hasta las {0}. Vuelve a intentarlo entonces." }, "mods": { "title": "Mods", @@ -553,7 +554,8 @@ "homeModsOnlyOwn_many": "Sin mods para {0}, pero se cargan {n} archivos de tu carpeta", "homeModsOnlyOwn_other": "Sin mods para {0}, pero se cargan {n} archivos de tu carpeta", "indexUnavailableStatusShort": "Sin índice de mods (servidor: {0}): sin mods revisados", - "browseNoIndex": "Los mods revisados aparecen aquí cuando se pueda descargar el índice de mods de Weft." + "browseNoIndex": "Los mods revisados aparecen aquí cuando se pueda descargar el índice de mods de Weft.", + "sourceErrRateUntil": "GitHub está limitando las peticiones hasta las {1}, así que no se pudo actualizar {0}." }, "sizeUnits": { "B": "B", diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 7961bd7..aa47ab2 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -120,6 +120,11 @@ export function formatList(items: string[]): string { return new Intl.ListFormat(currentLocale, { style: 'long', type: 'conjunction' }).format(items); } +/** A time of day, as the language writes it: "14:32", "2:32 PM". */ +export function formatTime(d: Date): string { + return new Intl.DateTimeFormat(currentLocale, { hour: 'numeric', minute: '2-digit' }).format(d); +} + /** A day, short: "2 Nov" this year, "2 Nov 2025" before it. */ export function formatDay(iso: string | null | undefined, now: Date = new Date()): string { if (!iso) return ''; diff --git a/src/i18n/pt-BR.json b/src/i18n/pt-BR.json index a71e3af..ed58966 100644 --- a/src/i18n/pt-BR.json +++ b/src/i18n/pt-BR.json @@ -88,7 +88,7 @@ "modSourceInvalid": "Não é um repositório do GitHub: {0}", "modSourceNotFound": "Esse repositório do GitHub não existe: {0}", "diskFull": "O disco está cheio.", - "modSourceRateLimited": "O GitHub está limitando as requisições para {0}. Tente de novo em alguns minutos.", + "modSourceRateLimited": "O GitHub está limitando as requisições. Tente de novo em alguns minutos.", "modDownloadNotFound": "O arquivo do mod não está mais no GitHub ({0}), então nada foi instalado. Atualize e tente de novo.", "modTooLarge": "O arquivo do mod é maior do que o launcher aceita, então nada foi instalado.", "modIdReserved": "\"{0}\" é reservado ao próprio Weft e não pode ser o id de um mod.", @@ -99,7 +99,8 @@ "modUrlRefused": "Um link de download não apontava para onde a ficha do mod diz, então o launcher o recusou. Nada foi baixado.", "modIconInvalid": "O ícone deste mod não é um PNG pequeno, então ele não é exibido.", "details": "Detalhes", - "modSourcesDamaged": "Não foi possível ler sua lista de fontes de mods, então ela foi deixada de lado e guardada. Adicione suas fontes de novo; a lista antiga está em Detalhes." + "modSourcesDamaged": "Não foi possível ler sua lista de fontes de mods, então ela foi deixada de lado e guardada. Adicione suas fontes de novo; a lista antiga está em Detalhes.", + "modSourceRateLimitedUntil": "O GitHub está limitando as requisições até {0}. Tente de novo depois disso." }, "mods": { "title": "Mods", @@ -553,7 +554,8 @@ "homeModsOnlyOwn_many": "Sem mods para {0}, mas {n} arquivos da sua pasta carregam", "homeModsOnlyOwn_other": "Sem mods para {0}, mas {n} arquivos da sua pasta carregam", "indexUnavailableStatusShort": "Sem índice de mods (servidor: {0}): sem mods revisados", - "browseNoIndex": "Os mods revisados aparecem aqui quando o índice de mods do Weft puder ser baixado." + "browseNoIndex": "Os mods revisados aparecem aqui quando o índice de mods do Weft puder ser baixado.", + "sourceErrRateUntil": "O GitHub está limitando as requisições até {1}, então não foi possível atualizar {0}." }, "sizeUnits": { "B": "B", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index 36a903d..dac3abb 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -88,7 +88,7 @@ "modSourceInvalid": "Это не репозиторий GitHub: {0}", "modSourceNotFound": "Такого репозитория GitHub нет: {0}", "diskFull": "Диск заполнен.", - "modSourceRateLimited": "GitHub ограничивает запросы к {0}. Попробуйте через несколько минут.", + "modSourceRateLimited": "GitHub ограничивает запросы. Попробуйте через несколько минут.", "modDownloadNotFound": "Файла мода больше нет на GitHub ({0}), поэтому ничего не установлено. Обновите список и попробуйте снова.", "modTooLarge": "Файл мода больше, чем принимает лаунчер, поэтому ничего не установлено.", "modIdReserved": "«{0}» зарезервирован для самого Weft и не может быть id мода.", @@ -99,7 +99,8 @@ "modUrlRefused": "Ссылка для загрузки вела не туда, куда указано в описании мода, поэтому лаунчер её отклонил. Ничего не загружено.", "modIconInvalid": "Значок этого мода — не маленький PNG, поэтому он не показан.", "details": "Подробности", - "modSourcesDamaged": "Не удалось прочитать ваш список источников модов, поэтому он отложен и сохранён. Добавьте источники заново; старый список — в «Подробностях»." + "modSourcesDamaged": "Не удалось прочитать ваш список источников модов, поэтому он отложен и сохранён. Добавьте источники заново; старый список — в «Подробностях».", + "modSourceRateLimitedUntil": "GitHub ограничивает запросы до {0}. Попробуйте после этого." }, "mods": { "title": "Моды", @@ -587,7 +588,8 @@ "homeModsOnlyOwn_many": "Без модов для {0}, но загрузятся {n} файлов из своей папки", "homeModsOnlyOwn_other": "Без модов для {0}, но загрузятся {n} файла из своей папки", "indexUnavailableStatusShort": "Нет индекса (сервер: {0}): нет одобренных модов", - "browseNoIndex": "Одобренные моды появятся здесь, когда удастся загрузить индекс модов Weft." + "browseNoIndex": "Одобренные моды появятся здесь, когда удастся загрузить индекс модов Weft.", + "sourceErrRateUntil": "GitHub ограничивает запросы до {1}, поэтому {0} не удалось обновить." }, "sizeUnits": { "B": "Б", From 168dc746d94aa6c696467507d1dc8f5de6108bd2 Mon Sep 17 00:00:00 2001 From: Zorro909 Date: Thu, 5 Nov 2026 06:47:51 +0000 Subject: [PATCH 117/156] pt-BR, es and ru say that a revocation's reason is quoted in the index's English --- src/i18n/es.json | 6 +++--- src/i18n/pt-BR.json | 6 +++--- src/i18n/ru.json | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/i18n/es.json b/src/i18n/es.json index 6c16bb9..ef62265 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -130,7 +130,7 @@ "statusOff": "Desactivado. El juego no lo ve.", "statusWrongVersion": "Compilado para otra versión del juego: impide Jugar.", "statusWrongVersionTag": "Compilado para {0}: impide Jugar.", - "statusRevoked": "Desactivado, retirado del índice: «{0}»", + "statusRevoked": "Desactivado, retirado del índice (en inglés): «{0}»", "statusUnchecked": "No se puede comprobar antes de que arranque el juego.", "statusUncheckedFile": "De tu equipo ({0}). Se comprueba al arrancar el juego.", "on": "Activado", @@ -197,7 +197,7 @@ "ckNotForSystem": "No hay biblioteca para este sistema", "ckNotForSystemDetail": "Tiene una para: {0}", "ckNotAMod": "No es un mod de Weft", - "ckRevoked": "Retirado del índice: {0}", + "ckRevoked": "Retirado del índice. Motivo, en inglés: {0}", "warnFile": "Instala solo archivos de personas en las que confíes. Un mod puede leer, cambiar o borrar tus archivos y enviarlos a cualquier sitio.", "installAnyway": "Instalar de todos modos", "sourcesTitle": "Fuentes de mods", @@ -492,7 +492,7 @@ "turnOffAll_many": "Desactivar los {n}", "turnOffAll_other": "Desactivar los {n}", "failureLogLine": "Línea del registro", - "indexSays": "El índice dice: «{0}»", + "indexSays": "El índice dice, en inglés: «{0}»", "indexGaveNoReason": "El índice no indicó ningún motivo.", "detailsLabel": "{0}: detalles", "refusedUnread": "Weft no arrancó por sus mods, y no se pudieron volver a leer para decir cuáles: {0}", diff --git a/src/i18n/pt-BR.json b/src/i18n/pt-BR.json index ed58966..76f49f9 100644 --- a/src/i18n/pt-BR.json +++ b/src/i18n/pt-BR.json @@ -130,7 +130,7 @@ "statusOff": "Desligado. O jogo não o vê.", "statusWrongVersion": "Compilado para outra versão do jogo: impede Jogar.", "statusWrongVersionTag": "Compilado para {0}: impede Jogar.", - "statusRevoked": "Desligado, retirado do índice: “{0}”", + "statusRevoked": "Desligado, retirado do índice (em inglês): “{0}”", "statusUnchecked": "Não dá para verificar antes de o jogo iniciar.", "statusUncheckedFile": "Do seu computador ({0}). Verificado quando o jogo inicia.", "on": "Ligado", @@ -197,7 +197,7 @@ "ckNotForSystem": "Sem biblioteca para este sistema", "ckNotForSystemDetail": "Tem uma para: {0}", "ckNotAMod": "Não é um mod do Weft", - "ckRevoked": "Retirado do índice: {0}", + "ckRevoked": "Retirado do índice. Motivo, em inglês: {0}", "warnFile": "Só instale arquivos de pessoas em quem você confia. Um mod pode ler, mudar ou apagar seus arquivos e enviá-los para qualquer lugar.", "installAnyway": "Instalar mesmo assim", "sourcesTitle": "Fontes de mods", @@ -492,7 +492,7 @@ "turnOffAll_many": "Desligar os {n}", "turnOffAll_other": "Desligar os {n}", "failureLogLine": "Linha do log", - "indexSays": "O índice diz: “{0}”", + "indexSays": "O índice diz, em inglês: “{0}”", "indexGaveNoReason": "O índice não informou motivo.", "detailsLabel": "{0}: detalhes", "refusedUnread": "O Weft não iniciou por causa dos mods, e não foi possível lê-los de novo para dizer quais: {0}", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index dac3abb..c4d207e 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -133,7 +133,7 @@ "statusOff": "Выключен. Игра его не видит.", "statusWrongVersion": "Собран для другой версии игры: не даёт играть.", "statusWrongVersionTag": "Собран для {0}: не даёт играть.", - "statusRevoked": "Выключен, отозван из индекса: «{0}»", + "statusRevoked": "Выключен, отозван из индекса (англ.): «{0}»", "statusUnchecked": "Проверить можно только при запуске игры.", "statusUncheckedFile": "С вашего компьютера ({0}). Проверяется при запуске игры.", "on": "Вкл", @@ -200,7 +200,7 @@ "ckNotForSystem": "Нет библиотеки для этой системы", "ckNotForSystemDetail": "Есть только для: {0}", "ckNotAMod": "Это не мод Weft", - "ckRevoked": "Отозван из индекса: {0}", + "ckRevoked": "Отозван из индекса. Причина по-английски: {0}", "warnFile": "Устанавливайте файлы только от тех, кому доверяете. Мод может читать, менять и удалять ваши файлы и отправлять их куда угодно.", "installAnyway": "Всё равно установить", "sourcesTitle": "Источники модов", @@ -523,7 +523,7 @@ "turnOffAll_many": "Выключить все {n}", "turnOffAll_other": "Выключить все {n}", "failureLogLine": "Строка журнала", - "indexSays": "Индекс сообщает: «{0}»", + "indexSays": "Индекс сообщает по-английски: «{0}»", "indexGaveNoReason": "Индекс не указал причину.", "detailsLabel": "{0}: подробности", "refusedUnread": "Weft не запустился из-за модов, а прочитать их снова, чтобы сказать каких, не удалось: {0}", From f46fe22fd8b65678853986f6ed2e47d68b5ac4bb Mon Sep 17 00:00:00 2001 From: Zorro909 Date: Thu, 5 Nov 2026 06:54:20 +0000 Subject: [PATCH 118/156] Mock: a file takes about 0.75 s (a five-file copy about 4 s), fast=1 for screenshots, let-go listeners stop getting events, and the window title names the scenario The Tauri shim's unlisten read 'id' where the API sends 'eventId', so every listener that was let go kept receiving events and warned. The launch sleep no longer counts slow=1 twice. --- README.md | 1 + e2e/flows.spec.ts | 20 ++++++++++++++++++++ src/dev/mockBackend.ts | 18 ++++++++++++++---- src/dev/scenarios.ts | 1 + 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 2f4c604..7f9d781 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,7 @@ Pick a state with `?scenario=` and adjust it with flags, which combine: `?scenar | `prelaunch` | `revoke` | Play finds Cogwork revoked, as a fresh index would, before it starts the game. | | `long` | `1` | Adds very long names, ids and repos. | | `slow` | `1` | Everything takes four times as long: time enough to press Cancel. | +| `fast` | `1` | Everything takes a quarter as long, for screenshots of results. | | `profile` | `fail`, `failafterplay` | Reading a version's set fails (`Io`): always, or only the first read after Play is refused, so Retry gets through. | ## Releasing diff --git a/e2e/flows.spec.ts b/e2e/flows.spec.ts index f8d530a..d747a4c 100644 --- a/e2e/flows.spec.ts +++ b/e2e/flows.spec.ts @@ -812,6 +812,26 @@ test.describe("remove and undo", () => { }); }); +test.describe("mock", () => { + test("the window title names the scenario and its flags", async ({ page }) => { + await open(page, "scenario=crash&crash=exit"); + await expect(page).toHaveTitle("Weft (mock: crash, crash=exit)"); + }); + + test("an event never reaches a listener that was let go", async ({ page }) => { + const lost: string[] = []; + page.on("console", (m) => m.text().includes("Couldn't find callback") && lost.push(m.text())); + await open(page, "scenario=mods"); + await goToTab(page, MODS_TAB); + await browse(page); + await page.locator(".mod-card", { hasText: "Hearth" }).locator(".mod-card-foot .btn-primary").click(); + await page.locator(`${dialog} .dialog-actions .btn-primary`).click(); + await expect(page.locator(dialog)).toHaveCount(0, { timeout: 15_000 }); + await settled(page); + expect(lost).toEqual([]); + }); +}); + test.describe("contrast", () => { test("an off mod's name reads as well as small text: a solid colour, not faded", async ({ page }) => { await open(page, "scenario=chain"); diff --git a/src/dev/mockBackend.ts b/src/dev/mockBackend.ts index 9536b8f..6dcf219 100644 --- a/src/dev/mockBackend.ts +++ b/src/dev/mockBackend.ts @@ -40,7 +40,8 @@ const scenarioName = params.get("scenario") ?? "mods"; const scenario = SCENARIOS.find((s) => s.name === scenarioName) ?? SCENARIOS[0]; const flag = (name: string): string | null => params.get(name) ?? scenario.flags[name] ?? null; const failures = new Set((flag("fail") ?? "").split(",").filter(Boolean)); -const SLOW = flag("slow") === "1" ? 4 : 1; +/** `slow=1`: four times as long, time enough to press Cancel; `fast=1`: a quarter, for screenshots. */ +const SLOW = flag("slow") === "1" ? 4 : flag("fast") === "1" ? 0.25 : 1; const LONG = flag("long") === "1"; const NOW = "2026-11-04T18:00:00Z"; @@ -491,11 +492,11 @@ async function fetchBuild(op: Op, file: { sha256: string; id: string; index: num await progress(op, file, "downloading", p); if (p === 40 && failures.has("network")) fail("Network", "connection reset by peer"); if (p === 60 && isDep && failures.has("dep")) fail("ModDownloadNotFound", `${file.id}.zip`); - await sleep(140); + await sleep(50); } op.check(); await progress(op, file, "verifying", 100); - await sleep(450); + await sleep(200); if (failures.has("install")) fail("Sha256Mismatch", `expected ${file.sha256.slice(0, 12)}…, got 0b5e1c9d2a77…`); } @@ -734,7 +735,7 @@ async function launch(tag: string) { // `prepare_launch` applies what the catalogue says before anything starts. if (flag("prelaunch") === "revoke" && !catalog.revoked.length) revoke("cogwork"); // Then it reads the set and hashes what it hands the game: not instant. - await sleep(250 * SLOW); + await sleep(250); const r = report(tag); if (r.blocking.length) { if (profileFlag === "failafterplay") failNextRead = true; @@ -983,6 +984,9 @@ function mockSettled(): Promise { export function installMockBackend() { mockWindows("main"); + // Which scenario a window or screenshot shows, and the flags given beside it. + const extra = [...params].filter(([k]) => k !== "scenario").map(([k, v]) => `${k}=${v}`); + document.title = `Weft (mock: ${scenario.name}${extra.length ? `, ${extra.join(", ")}` : ""})`; (window as unknown as { mockPlayer: typeof mockPlayer }).mockPlayer = mockPlayer; (window as unknown as { mockSettled: typeof mockSettled }).mockSettled = mockSettled; mockIPC( @@ -1002,4 +1006,10 @@ export function installMockBackend() { }, { shouldMockEvents: true } ); + // The shim's unlisten looks for `id`, but @tauri-apps/api sends `eventId`: a listener that was + // let go stayed registered, and every later event warned "Couldn't find callback id". Pass it + // on under the name the shim reads. + const internals = (window as unknown as { __TAURI_INTERNALS__: { invoke: (cmd: string, args?: Record, o?: unknown) => Promise } }).__TAURI_INTERNALS__; + const invoke = internals.invoke; + internals.invoke = (cmd, args, o) => invoke(cmd, cmd === "plugin:event|unlisten" && args ? { ...args, id: args.eventId } : args, o); } diff --git a/src/dev/scenarios.ts b/src/dev/scenarios.ts index 6f42990..7bf64c8 100644 --- a/src/dev/scenarios.ts +++ b/src/dev/scenarios.ts @@ -121,6 +121,7 @@ export const FLAGS: Flag[] = [ { name: "prelaunch", values: ["revoke"], description: "Play finds Cogwork revoked, as a fresh index would, before it starts the game." }, { name: "long", values: ["1"], description: "Adds very long names, ids and repos." }, { name: "slow", values: ["1"], description: "Everything takes four times as long: time enough to press Cancel." }, + { name: "fast", values: ["1"], description: "Everything takes a quarter as long, for screenshots of results." }, { name: "profile", values: ["fail", "failafterplay"], From 1360f07e4241654000c5a55aea50fd09a32e2ffa Mon Sep 17 00:00:00 2001 From: Zorro909 Date: Thu, 5 Nov 2026 06:56:41 +0000 Subject: [PATCH 119/156] What has been said lives in one Speech per app tree, held by a provider, not in module state Announce's per-channel memory and the live region's said keys move into components/speech.ts, a plain class with tests; components and hooks say things through useAnnounce(). --- src/components/Announce.tsx | 10 +++--- src/components/ErrorText.tsx | 5 +-- src/components/LiveRegion.tsx | 43 +++++++++++++----------- src/components/VersionActions.tsx | 3 +- src/components/mods/InstalledList.tsx | 3 +- src/components/mods/ModFailureNotice.tsx | 6 ++-- src/components/mods/ModFixes.tsx | 3 +- src/components/mods/ModProgressBar.tsx | 5 +-- src/components/mods/PlanChanges.tsx | 3 +- src/components/speech.test.ts | 39 +++++++++++++++++++++ src/components/speech.ts | 42 +++++++++++++++++++++++ src/hooks/useMods.tsx | 5 +-- src/main.tsx | 17 ++++++---- src/pages/Mods.tsx | 5 +-- 14 files changed, 142 insertions(+), 47 deletions(-) create mode 100644 src/components/speech.test.ts create mode 100644 src/components/speech.ts diff --git a/src/components/Announce.tsx b/src/components/Announce.tsx index 119ce8a..d39eeca 100644 --- a/src/components/Announce.tsx +++ b/src/components/Announce.tsx @@ -1,6 +1,5 @@ import { useEffect, useState } from "react"; - -const said = new Map>(); +import { useSpeech } from "./LiveRegion"; /** * Whole sentences said one after another: a space between them, never a comma, so a screen @@ -20,17 +19,16 @@ export function sentences(parts: string[]): string { * it is said again if it comes back. */ export function Announce({ channel, items, prefix = "" }: { channel: string; items: { id: string; text: string }[]; prefix?: string }) { + const speech = useSpeech(); const [now, setNow] = useState(""); const ids = items.map((i) => i.id).join("\u0000"); useEffect(() => { - const before = said.get(channel) ?? new Set(); - const fresh = items.filter((i) => !before.has(i.id)); - said.set(channel, new Set(items.map((i) => i.id))); + const fresh = speech.fresh(channel, items); if (fresh.length) setNow(sentences([prefix, ...fresh.map((i) => i.text)])); else if (!items.length) setNow(""); // `items` is read through its ids: the same problems with new objects say nothing. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [channel, ids, prefix]); + }, [speech, channel, ids, prefix]); return ( {now} diff --git a/src/components/ErrorText.tsx b/src/components/ErrorText.tsx index 00d4cc8..cd5ba25 100644 --- a/src/components/ErrorText.tsx +++ b/src/components/ErrorText.tsx @@ -1,7 +1,7 @@ import { ReactNode, useEffect, useId } from "react"; import { useI18n } from "../i18n/useI18n"; import type { Shown } from "../i18n/errors"; -import { announce } from "./LiveRegion"; +import { useAnnounce } from "./LiveRegion"; interface ErrorTextProps { error: Shown; @@ -21,12 +21,13 @@ interface ErrorTextProps { * are any. Said once in the polite live region, not as an alert. */ export function ErrorText({ error, className = "inline-error", children, lead, speak = true, id: domId }: ErrorTextProps) { + const announce = useAnnounce(); const { t } = useI18n(); const id = useId(); const text = lead ? `${lead} ${error.message}` : error.message; useEffect(() => { if (speak && text) announce(`error:${id}:${text}`, text); - }, [speak, text, id]); + }, [speak, text, id, announce]); return (
      diff --git a/src/components/LiveRegion.tsx b/src/components/LiveRegion.tsx index 0ace991..32070b4 100644 --- a/src/components/LiveRegion.tsx +++ b/src/components/LiveRegion.tsx @@ -1,29 +1,33 @@ -import { useEffect, useState } from "react"; +import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react"; +import { Speech } from "./speech"; /** * The one place results are said to a screen reader: a polite live region that exists from the * start, so a message put into it is read even when the control that caused it has gone (a - * removed row, a closed dialog). Each message is said once per key. + * removed row, a closed dialog). Each message is said once per key. What has been said is held + * by the SpeechProvider, one per app tree (./speech). */ -type Listener = (text: string) => void; -const listeners = new Set(); -const said = new Set(); +const SpeechContext = createContext(null); -/** Says `text` politely, once per `key`. */ -export function announce(key: string, text: string) { - if (!text || said.has(key)) return; - said.add(key); - // Bounded: keys are per notice, per error and per blocker set, so a long session can't grow it. - if (said.size > 500) said.delete(said.values().next().value!); - listeners.forEach((l) => l(text)); +export function SpeechProvider({ children }: { children: ReactNode }) { + const [speech] = useState(() => new Speech()); + return {children}; } -/** Forgets a key, so the same thing coming back later is said again. */ -export function forget(key: string) { - said.delete(key); +export function useSpeech(): Speech { + const speech = useContext(SpeechContext); + if (!speech) throw new Error("useSpeech outside a SpeechProvider"); + return speech; +} + +/** Says `text` politely, once per `key`. */ +export function useAnnounce(): (key: string, text: string) => void { + const speech = useSpeech(); + return useCallback((key: string, text: string) => speech.say(key, text), [speech]); } export function LiveRegion() { + const speech = useSpeech(); const [text, setText] = useState(""); useEffect(() => { let clear: ReturnType | undefined; @@ -31,7 +35,7 @@ export function LiveRegion() { // Messages that arrive in the same frame (a result and an error it caused) are said // together, in order, rather than the last one replacing the others. let pending: string[] = []; - const l: Listener = (t) => { + const stop = speech.listen((t) => { pending.push(t); if (frame) return; // A message the same as the last one would not change the region; clear it first. @@ -43,14 +47,13 @@ export function LiveRegion() { }); clearTimeout(clear); clear = setTimeout(() => setText(""), 8000); - }; - listeners.add(l); + }); return () => { - listeners.delete(l); + stop(); cancelAnimationFrame(frame); clearTimeout(clear); }; - }, []); + }, [speech]); return (
      {text} diff --git a/src/components/VersionActions.tsx b/src/components/VersionActions.tsx index 291b568..fa98266 100644 --- a/src/components/VersionActions.tsx +++ b/src/components/VersionActions.tsx @@ -7,7 +7,7 @@ import { useI18n } from "../i18n/useI18n"; import { StableLabel } from "./StableLabel"; import { ErrorText } from "./ErrorText"; import { LaunchRefusal } from "./LaunchRefusal"; -import { announce } from "./LiveRegion"; +import { useAnnounce } from "./LiveRegion"; import { BlockerPanel, useReviewDialog } from "./mods/ModFixes"; import { ModFailureNotice } from "./mods/ModFailureNotice"; import { unreachable } from "../utils/never"; @@ -28,6 +28,7 @@ interface VersionActionsProps { } export function VersionActions({ release, installedVersion, onFixMods }: VersionActionsProps) { + const announce = useAnnounce(); const { t, tn } = useI18n(); const { progress, busy: busyTags, errors, install, remove } = useInstall(); const { profile } = useMods(); diff --git a/src/components/mods/InstalledList.tsx b/src/components/mods/InstalledList.tsx index 9bf842e..646c56d 100644 --- a/src/components/mods/InstalledList.tsx +++ b/src/components/mods/InstalledList.tsx @@ -8,7 +8,7 @@ import { depStates, modMeta, shadowOf, turnOnWarnings } from "../../mods/status" import type { Msg } from "../../i18n"; import { activeStep } from "../../mods/checks"; import type { Shown } from "../../i18n/errors"; -import { announce } from "../LiveRegion"; +import { useAnnounce } from "../LiveRegion"; import { StableLabel } from "../StableLabel"; import { Truncate } from "../Truncate"; import { ModIcon } from "./ModIcon"; @@ -33,6 +33,7 @@ interface RowProps { } function ModRow({ tag, mod, report, onError, onUpdate, canUpdate, updateWhy, locked, onRemoving, onRemoved }: RowProps) { + const announce = useAnnounce(); const { t, tm } = useI18n(); const { setEnabled, remove, isBusy, progressOf, nameIn, catalog } = useMods(); const hintId = useId(); diff --git a/src/components/mods/ModFailureNotice.tsx b/src/components/mods/ModFailureNotice.tsx index 4bfe7c8..8eaf552 100644 --- a/src/components/mods/ModFailureNotice.tsx +++ b/src/components/mods/ModFailureNotice.tsx @@ -5,7 +5,7 @@ import { useMods } from "../../hooks/useMods"; import { useGameState, type GameCrash } from "../../hooks/useGameState"; import { modsApi } from "../../mods/api"; import { crashFixes, failureTitle, shadowOf, shadowStillThere } from "../../mods/status"; -import { announce } from "../LiveRegion"; +import { useAnnounce } from "../LiveRegion"; import { ErrorDetails, ErrorText } from "../ErrorText"; import { FixButtons, useFix, type Review } from "./ModFixes"; @@ -28,6 +28,7 @@ interface ModFailureNoticeProps { * there. It stays until that mod is off, removed or replaced, or the player dismisses it. */ export function ModFailureNotice({ crash, review, onGoToMods }: ModFailureNoticeProps) { + const announce = useAnnounce(); const { t, tn, tm } = useI18n(); const { profile, nameIn, catalog, crashSnapshot, setEnabledMany } = useMods(); const { clearCrash } = useGameState(); @@ -149,6 +150,7 @@ export function ModFailureNotice({ crash, review, onGoToMods }: ModFailureNotice /** A crash is a result of Play: said once, politely. */ function CrashSaid({ id, text }: { id: string; text: string }) { - useEffect(() => announce(`crash:${id}`, text), [id, text]); + const announce = useAnnounce(); + useEffect(() => announce(`crash:${id}`, text), [id, text, announce]); return null; } diff --git a/src/components/mods/ModFixes.tsx b/src/components/mods/ModFixes.tsx index c726d8e..a5dad3a 100644 --- a/src/components/mods/ModFixes.tsx +++ b/src/components/mods/ModFixes.tsx @@ -7,7 +7,7 @@ import { catalogModFor } from "../../mods/select"; import { blockers, type Fix } from "../../mods/status"; import type { CatalogMod, InstalledMod } from "../../mods/types"; import { Announce } from "../Announce"; -import { announce } from "../LiveRegion"; +import { useAnnounce } from "../LiveRegion"; import { ErrorText } from "../ErrorText"; import { InstallDialog } from "./InstallDialog"; import { FileInstallDialog } from "./FileInstallDialog"; @@ -175,6 +175,7 @@ interface BlockerPanelProps { * Mods tab. Rendered also while nothing blocks, so a problem that comes back is said again. */ export function BlockerPanel({ tag, locked, review, id, inBanner = false }: BlockerPanelProps) { + const announce = useAnnounce(); const { t, tn, tm } = useI18n(); const { profile, nameIn, catalog, isBusy } = useMods(); const { apply, applyAll, error } = useFix(tag, review); diff --git a/src/components/mods/ModProgressBar.tsx b/src/components/mods/ModProgressBar.tsx index 121184b..bf3a73e 100644 --- a/src/components/mods/ModProgressBar.tsx +++ b/src/components/mods/ModProgressBar.tsx @@ -2,7 +2,7 @@ import { useEffect, useId, useRef, useState } from "react"; import { useI18n } from "../../i18n/useI18n"; import type { UiProgress, UiStage } from "../../mods/types"; import type { MessageKey } from "../../i18n"; -import { announce } from "../LiveRegion"; +import { useAnnounce } from "../LiveRegion"; import { dwellDelay, stepKey } from "../../mods/progress"; /** What is shown: "downloading 40%" has the percent in it, the others don't. */ @@ -54,6 +54,7 @@ interface ModProgressBarProps { * "File 2 of 3: Glowmoss, downloading 40%". The step is said once per change, not each percent. */ export function ModProgressBar({ progress, nameOf, onCancel, cancelling = false, compact = false }: ModProgressBarProps) { + const announce = useAnnounce(); const { t } = useI18n(); const shown = useDwell(progress); const stage = shown?.stage ?? "preparing"; @@ -72,7 +73,7 @@ export function ModProgressBar({ progress, nameOf, onCancel, cancelling = false, const bar = useId(); useEffect(() => { if (!compact) announce(`progress:${bar}:${spoken}`, spoken); - }, [bar, compact, spoken]); + }, [bar, compact, spoken, announce]); return (
      diff --git a/src/components/mods/PlanChanges.tsx b/src/components/mods/PlanChanges.tsx index 2d90fba..8bd2c40 100644 --- a/src/components/mods/PlanChanges.tsx +++ b/src/components/mods/PlanChanges.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { useI18n } from "../../i18n/useI18n"; import type { RichMsg } from "../../mods/checks"; -import { announce } from "../LiveRegion"; +import { useAnnounce } from "../LiveRegion"; import { RichText } from "./RichText"; /** What is different in the new plan, after the backend refused the one the player agreed to. */ @@ -23,6 +23,7 @@ export function PlanChanges({ changes }: { changes: RichMsg[] }) { /** A calm line in a dialog when the player cancelled: nothing changed, and that isn't an error. */ export function useOutcomeNote() { + const announce = useAnnounce(); const { t } = useI18n(); const [cancelledAt, setCancelledAt] = useState(null); return { diff --git a/src/components/speech.test.ts b/src/components/speech.test.ts new file mode 100644 index 0000000..357b91f --- /dev/null +++ b/src/components/speech.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { Speech } from "./speech"; + +describe("speech", () => { + it("says each key once, and again once it is forgotten", () => { + const s = new Speech(); + const heard: string[] = []; + const stop = s.listen((t) => heard.push(t)); + s.say("a", "Removed Cogwork."); + s.say("a", "Removed Cogwork."); + s.say("b", ""); + s.forget("a"); + s.say("a", "Removed Cogwork."); + stop(); + s.say("c", "Not heard."); + expect(heard).toEqual(["Removed Cogwork.", "Removed Cogwork."]); + }); + + it("finds what is new on a channel, and forgets what went away", () => { + const s = new Speech(); + const a = { id: "hearth" }; + const b = { id: "lanterns" }; + expect(s.fresh("v1", [a, b])).toEqual([a, b]); + // The same problems, re-rendered or on another tab: nothing new. + expect(s.fresh("v1", [a, b])).toEqual([]); + // One fixed: the other isn't said again. + expect(s.fresh("v1", [b])).toEqual([]); + // The fixed one comes back: new again. Channels are apart. + expect(s.fresh("v1", [a, b])).toEqual([a]); + expect(s.fresh("v2", [a])).toEqual([a]); + }); + + it("is one memory per app tree: a second tree says it all again", () => { + const one = new Speech(); + const two = new Speech(); + expect(one.fresh("v1", [{ id: "x" }])).toHaveLength(1); + expect(two.fresh("v1", [{ id: "x" }])).toHaveLength(1); + }); +}); diff --git a/src/components/speech.ts b/src/components/speech.ts new file mode 100644 index 0000000..f9a8246 --- /dev/null +++ b/src/components/speech.ts @@ -0,0 +1,42 @@ +// What has been said to a screen reader, without React: one per app tree, held by the +// SpeechProvider in ./LiveRegion. Pure, so its memory is tested apart from any page. + +type Listener = (text: string) => void; + +/** Keys kept before the oldest is let go: keys are per notice, per error and per blocker set. */ +const MEMORY = 500; + +export class Speech { + private said = new Set(); + private channels = new Map>(); + private listeners = new Set(); + + /** Says `text` politely, once per `key`. */ + say(key: string, text: string) { + if (!text || this.said.has(key)) return; + this.said.add(key); + if (this.said.size > MEMORY) this.said.delete(this.said.values().next().value!); + this.listeners.forEach((l) => l(text)); + } + + /** Forgets a key, so the same thing coming back later is said again. */ + forget(key: string) { + this.said.delete(key); + } + + /** The polite region hears what is said from here on; the returned function stops that. */ + listen(l: Listener): () => void { + this.listeners.add(l); + return () => this.listeners.delete(l); + } + + /** + * The items of `channel` that weren't on it the last time, which it now remembers: blockers + * that newly appear. An item that went away is forgotten, so it is new again if it comes back. + */ + fresh(channel: string, items: T[]): T[] { + const before = this.channels.get(channel) ?? new Set(); + this.channels.set(channel, new Set(items.map((i) => i.id))); + return items.filter((i) => !before.has(i.id)); + } +} diff --git a/src/hooks/useMods.tsx b/src/hooks/useMods.tsx index b4d35ca..59a0e31 100644 --- a/src/hooks/useMods.tsx +++ b/src/hooks/useMods.tsx @@ -26,7 +26,7 @@ import type { } from "../mods/types"; import { showError, type Shown } from "../i18n/errors"; import { tm } from "../i18n"; -import { announce } from "../components/LiveRegion"; +import { useAnnounce } from "../components/LiveRegion"; import { useInstall } from "./useInstall"; import { useGameState } from "./useGameState"; import { failed, type ActionResult } from "./result"; @@ -108,6 +108,7 @@ function without(record: Record, key: string): Record { } export function ModsProvider({ children }: { children: ReactNode }) { + const announce = useAnnounce(); const { installedVersions } = useInstall(); const { lastCrash, clearCrash } = useGameState(); const [profiles, setProfiles] = useState>({}); @@ -182,7 +183,7 @@ export function ModsProvider({ children }: { children: ReactNode }) { if (!text) return others; return [...others, { ...text, id, tag, undo }]; }); - }, []); + }, [announce]); const dismissNotice = useCallback((id: number) => setNotices((all) => all.filter((n) => n.id !== id)), []); diff --git a/src/main.tsx b/src/main.tsx index c31695e..a40f01f 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,6 +4,7 @@ import App from "./App"; import { GameStateProvider } from "./hooks/useGameState"; import { InstallProvider } from "./hooks/useInstall"; import { ModsProvider } from "./hooks/useMods"; +import { SpeechProvider } from "./components/LiveRegion"; import { getLocale, initI18n } from "./i18n"; import "./style.css"; @@ -39,13 +40,15 @@ async function start() { const render = () => ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - - - - - - - + + + + + + + + + , ); diff --git a/src/pages/Mods.tsx b/src/pages/Mods.tsx index 8c7ed63..f0b2a29 100644 --- a/src/pages/Mods.tsx +++ b/src/pages/Mods.tsx @@ -3,7 +3,7 @@ import { useI18n } from "../i18n/useI18n"; import { showError, type Shown } from "../i18n/errors"; import { bannerPlan } from "../mods/banners"; import { ErrorDetails } from "../components/ErrorText"; -import { announce } from "../components/LiveRegion"; +import { useAnnounce } from "../components/LiveRegion"; import { useInstall } from "../hooks/useInstall"; import { useGameState } from "../hooks/useGameState"; import { useMods, type Notice } from "../hooks/useMods"; @@ -35,6 +35,7 @@ interface ModsPageProps { } export function ModsPage({ selectedTag, onSelectTag, onGoToVersions }: ModsPageProps) { + const announce = useAnnounce(); const { t, tn, tm, formatList } = useI18n(); const { installedVersions, loaded } = useInstall(); const { isRunning, lastCrash } = useGameState(); @@ -76,7 +77,7 @@ export function ModsPage({ selectedTag, onSelectTag, onGoToVersions }: ModsPageP // A failed row action is said once, politely, like every result. useEffect(() => { if (rowError) announce(`row-error:${Date.now()}`, rowError.message); - }, [rowError]); + }, [rowError, announce]); if (!loaded) return
      ; From 8668f79339717298be0c9ac07f0ff98b61ac1584 Mon Sep 17 00:00:00 2001 From: Zorro909 Date: Thu, 5 Nov 2026 06:57:58 +0000 Subject: [PATCH 120/156] BlockerPanel splits into a pure pager (mods/blockerPager, tested), useBlockerPager, and the banner and list views --- src/components/mods/ModFixes.tsx | 168 +++++++++++++++++++------------ src/mods/blockerPager.test.ts | 22 ++++ src/mods/blockerPager.ts | 24 +++++ 3 files changed, 147 insertions(+), 67 deletions(-) create mode 100644 src/mods/blockerPager.test.ts create mode 100644 src/mods/blockerPager.ts diff --git a/src/components/mods/ModFixes.tsx b/src/components/mods/ModFixes.tsx index a5dad3a..6d50b97 100644 --- a/src/components/mods/ModFixes.tsx +++ b/src/components/mods/ModFixes.tsx @@ -1,4 +1,5 @@ -import { useEffect, useState } from "react"; +import { useEffect, useState, type ReactNode } from "react"; +import { pagerIndex, pagerStep, type PagerAt } from "../../mods/blockerPager"; import { useI18n } from "../../i18n/useI18n"; import { showError, type Shown } from "../../i18n/errors"; import { useMods } from "../../hooks/useMods"; @@ -170,33 +171,52 @@ interface BlockerPanelProps { inBanner?: boolean; } +type Blockers = ReturnType; + /** * Each mod that stops this version, why, and the one-click fixes. The same on Home and on the * Mods tab. Rendered also while nothing blocks, so a problem that comes back is said again. */ export function BlockerPanel({ tag, locked, review, id, inBanner = false }: BlockerPanelProps) { - const announce = useAnnounce(); - const { t, tn, tm } = useI18n(); - const { profile, nameIn, catalog, isBusy } = useMods(); - const { apply, applyAll, error } = useFix(tag, review); + const { tn, tm } = useI18n(); + const { profile, nameIn, catalog } = useMods(); + const fix = useFix(tag, review); const list = blockers(profile(tag), nameIn(tag), catalog); - const [page, setPage] = useState<{ id: string | null; index: number }>({ id: null, index: 0 }); const title = tn("mods.homeBlocked", list.length, tag); - const sentences = (b: (typeof list)[number]) => b.reasons.map(tm); // Assertive, and only for what wasn't there a moment ago: a new mod, or a new reason for one. const said = ( ({ id: `${b.mod.id}|${b.reasons.map((r) => r.key).join(",")}`, text: [...sentences(b), ...(b.quote ? [tm(b.quote)] : [])].join(" ") }))} + items={list.map((b) => ({ id: `${b.mod.id}|${b.reasons.map((r) => r.key).join(",")}`, text: [...b.reasons.map(tm), ...(b.quote ? [tm(b.quote)] : [])].join(" ") }))} prefix={title} /> ); if (!list.length) return said; + const view = { tag, list, title, id, locked, review, fix }; + return ( + <> + {inBanner ? : } + + ); +} - // One translated sentence per reason, each naming the mod (shown in its colour), never cut. - const reasonLines = (b: (typeof list)[number], withQuote: boolean) => ( +interface BlockersView { + tag: string; + list: Blockers; + title: string; + id?: string; + locked: boolean; + review: Review; + fix: ReturnType; + said: ReactNode; +} + +/** One translated sentence per reason, each naming the mod (shown in its colour), never cut. */ +function ReasonLines({ blocker: b, withQuote }: { blocker: Blockers[number]; withQuote: boolean }) { + const { tm } = useI18n(); + return ( - {sentences(b).map((text, i) => ( + {b.reasons.map(tm).map((text, i) => ( @@ -204,63 +224,77 @@ export function BlockerPanel({ tag, locked, review, id, inBanner = false }: Bloc {withQuote && b.quote && {tm(b.quote)}} ); +} - if (inBanner) { - // The mod on screen stays on screen while the list changes around it; a fixed one gives its - // place to the next. - const at = list.findIndex((b) => b.mod.id === page.id); - const index = at >= 0 ? at : Math.min(page.index, list.length - 1); - const shown = list[index]; - const go = (step: number) => { - const next = (index + step + list.length) % list.length; - setPage({ id: list[next].mod.id, index: next }); - announce(`blocker-page:${tag}:${next}:${Date.now()}`, t("mods.blockerPage", String(next + 1), String(list.length))); - }; - const many = list.length > 1; - // When every mod that blocks can simply be turned off, one press does them all. - const offs = list.flatMap((b) => b.fixes.filter((f) => f.kind === "turnOff").slice(0, 1)); - const allOff = many && offs.length === list.length; - return ( -
      - {said} -
      - - {reasonLines(shown, false)} - {allOff && ( - - )} +/** The banner's page among the blocking mods: which one shows, and paging, said politely. */ +export function useBlockerPager(tag: string, list: Blockers) { + const announce = useAnnounce(); + const { t } = useI18n(); + const [at, setAt] = useState({ id: null, index: 0 }); + const index = pagerIndex( + list.map((b) => b.mod.id), + at + ); + const go = (step: number) => { + const next = pagerStep(list.length, index, step); + setAt({ id: list[next].mod.id, index: next }); + announce(`blocker-page:${tag}:${next}:${Date.now()}`, t("mods.blockerPage", String(next + 1), String(list.length))); + }; + return { index, shown: list[index], go, many: list.length > 1 }; +} + +/** On the Mods tab: one blocking mod at a time, with its fixes and a pager. */ +function BannerBlockers({ tag, list, title, id, locked, review, fix, said }: BlockersView) { + const { t, tn } = useI18n(); + const { isBusy } = useMods(); + const { index, shown, go, many } = useBlockerPager(tag, list); + // When every mod that blocks can simply be turned off, one press does them all. + const offs = list.flatMap((b) => b.fixes.filter((f) => f.kind === "turnOff").slice(0, 1)); + const allOff = many && offs.length === list.length; + return ( +
      + {said} +
      + + + {allOff && ( + + )} + + + + - - - - - {many && ( - - - - {t("mods.blockerPage", String(index + 1), String(list.length))} - - + {many && ( + + + + {t("mods.blockerPage", String(index + 1), String(list.length))} - )} - -
      - {error && } -
      - ); - } + +
      + )} + +
      + {fix.error && } +
      + ); +} +/** On Home and Versions: every blocking mod, each with its reasons and fixes. */ +function ListedBlockers({ tag, list, title, id, locked, review, fix, said }: BlockersView) { + const { t, tn } = useI18n(); return (
      {said} @@ -271,14 +305,14 @@ export function BlockerPanel({ tag, locked, review, id, inBanner = false }: Bloc
        {list.map((b) => (
      • - {reasonLines(b, true)} + - +
      • ))}
      - {error && } + {fix.error && }
      ); } diff --git a/src/mods/blockerPager.test.ts b/src/mods/blockerPager.test.ts new file mode 100644 index 0000000..22f7199 --- /dev/null +++ b/src/mods/blockerPager.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { pagerIndex, pagerStep } from "./blockerPager"; + +describe("blocker pager", () => { + it("keeps the mod on screen while the list changes around it", () => { + expect(pagerIndex(["a", "b", "c"], { id: "b", index: 1 })).toBe(1); + // One before it is fixed: it moves up and stays shown. + expect(pagerIndex(["b", "c"], { id: "b", index: 1 })).toBe(0); + }); + + it("gives a fixed mod's place to the next, or the last", () => { + expect(pagerIndex(["a", "c"], { id: "b", index: 1 })).toBe(1); + expect(pagerIndex(["a"], { id: "c", index: 2 })).toBe(0); + expect(pagerIndex([], { id: "c", index: 2 })).toBe(0); + expect(pagerIndex(["a", "b"], { id: null, index: 0 })).toBe(0); + }); + + it("steps round the ends", () => { + expect(pagerStep(3, 2, 1)).toBe(0); + expect(pagerStep(3, 0, -1)).toBe(2); + }); +}); diff --git a/src/mods/blockerPager.ts b/src/mods/blockerPager.ts new file mode 100644 index 0000000..74271b7 --- /dev/null +++ b/src/mods/blockerPager.ts @@ -0,0 +1,24 @@ +// Which blocking mod the Mods tab's banner shows, as the list changes around it. No React: +// components/mods/ModFixes `useBlockerPager` holds the page. + +export interface PagerAt { + /** The mod on screen, by id; null before the player paged. */ + id: string | null; + /** Where it was in the list: the place a fixed mod gives to the next. */ + index: number; +} + +/** + * The index to show in `ids` (the blocking mods, in order): the mod on screen stays on screen + * while others come and go; a fixed one gives its place to the next, or the last. + */ +export function pagerIndex(ids: string[], at: PagerAt): number { + if (!ids.length) return 0; + const found = at.id === null ? -1 : ids.indexOf(at.id); + return found >= 0 ? found : Math.min(at.index, ids.length - 1); +} + +/** One step back or on from `index`, round the ends. */ +export function pagerStep(length: number, index: number, step: number): number { + return (index + step + length) % length; +} From 12db4d5d733dca6ea5484d8f1e4df6e37a74cd26 Mon Sep 17 00:00:00 2001 From: Zorro909 Date: Thu, 5 Nov 2026 06:59:35 +0000 Subject: [PATCH 121/156] The list of sources, and adding and removing one, live in useCatalog and return ActionResult; SourcesDialog only renders One change to the list at a time: a second while one runs is 'skipped', not sent. --- src/components/mods/SourcesDialog.tsx | 76 +++++++++------------------ src/hooks/useCatalog.ts | 61 ++++++++++++++++++++- src/hooks/useMods.tsx | 22 +++++++- 3 files changed, 105 insertions(+), 54 deletions(-) diff --git a/src/components/mods/SourcesDialog.tsx b/src/components/mods/SourcesDialog.tsx index 4fcce25..a11fdb4 100644 --- a/src/components/mods/SourcesDialog.tsx +++ b/src/components/mods/SourcesDialog.tsx @@ -1,10 +1,9 @@ -import { useCallback, useEffect, useId, useRef, useState } from "react"; +import { useEffect, useId, useState } from "react"; import { openUrl } from "@tauri-apps/plugin-opener"; import { useI18n } from "../../i18n/useI18n"; import { showError, type Shown } from "../../i18n/errors"; import { ErrorDetails, ErrorText } from "../ErrorText"; import { useMods } from "../../hooks/useMods"; -import { asAppError, modsApi } from "../../mods/api"; import { parseRepo, sameRepo } from "../../mods/select"; import { Dialog } from "../Dialog"; import { Truncate } from "../Truncate"; @@ -61,77 +60,50 @@ function sourceMeta(s: SourceStatus | undefined, checking: boolean, t: (k: Messa export function SourcesDialog({ onClose }: { onClose: () => void }) { const { t, tn, formatDay } = useI18n(); - const { catalog, catalogError, catalogLoading, refreshCatalog, refreshSource, sourceRefreshing } = useMods(); - const [repos, setRepos] = useState(null); - const [loadError, setLoadError] = useState(null); + const mods = useMods(); + const { catalog, catalogError, catalogLoading, refreshCatalog, refreshSource, sourceRefreshing, sourceList: repos, sourceChange } = mods; + const loadError = mods.sourceListError ? showError(mods.sourceListError) : null; // The list couldn't be read and was kept aside: adding a source starts a new one. - const [damaged, setDamaged] = useState(false); + const damaged = mods.sourceListError?.code === "ModSourcesDamaged"; const [input, setInput] = useState(""); const [error, setError] = useState(null); - const [adding, setAdding] = useState(false); - const [removing, setRemoving] = useState(null); const [confirming, setConfirming] = useState(null); const [rowError, setRowError] = useState<{ repo: string; error: Shown } | null>(null); const [changed, setChanged] = useState(false); - const busy = useRef(false); const inputId = useId(); const errorId = useId(); + const adding = sourceChange?.kind === "add"; + const removing = sourceChange?.kind === "remove" ? sourceChange.repo : null; - const load = useCallback(() => { - setLoadError(null); - modsApi - .sources() - .then((r) => { - setRepos(r); - setDamaged(false); - }) - .catch((err) => { - setLoadError(showError(err)); - setDamaged(asAppError(err).code === "ModSourcesDamaged"); - }); - }, []); - useEffect(load, [load]); + const load = mods.loadSources; + useEffect(() => { + load(); + }, [load]); const add = async () => { - if (busy.current || !input.trim()) return; + if (sourceChange || !input.trim()) return; const repo = parseRepo(input); if (!repo) { setError(showError({ code: "ModSourceInvalid", detail: input.trim() })); return; } - busy.current = true; - setAdding(true); setError(null); - try { - setRepos(await modsApi.addSource(repo)); - setLoadError(null); - setDamaged(false); - setInput(""); - // Asked at once, alone, so the dialog says what the new source has before it closes. - retrySource(repo); - } catch (err) { - setError(showError(err)); - } finally { - busy.current = false; - setAdding(false); - } + const r = await mods.addSource(repo); + if (r.status === "failed") return setError(r.shown); + if (r.status !== "done") return; + setInput(""); + // Asked at once, alone, so the dialog says what the new source has before it closes. + retrySource(repo); }; const remove = async (repo: string) => { - if (busy.current) return; - busy.current = true; - setRemoving(repo); + if (sourceChange) return; setRowError(null); - try { - setRepos(await modsApi.removeSource(repo)); - setConfirming(null); - setChanged(true); - } catch (err) { - setRowError({ repo, error: showError(err) }); - } finally { - busy.current = false; - setRemoving(null); - } + const r = await mods.removeSource(repo); + if (r.status === "failed") return setRowError({ repo, error: r.shown }); + if (r.status !== "done") return; + setConfirming(null); + setChanged(true); }; /** One source asked again; a failure to even ask is said on its row. */ diff --git a/src/hooks/useCatalog.ts b/src/hooks/useCatalog.ts index 6e100af..520eb98 100644 --- a/src/hooks/useCatalog.ts +++ b/src/hooks/useCatalog.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { asAppError, listenTo, modsApi } from "../mods/api"; import { EVENTS } from "../mods/ipc"; import type { Catalog, UiError } from "../mods/types"; +import { failed, type ActionResult } from "./result"; export interface CatalogState { catalog: Catalog | null; @@ -16,6 +17,17 @@ export interface CatalogState { sourceRefreshing: (repo: string) => boolean; /** The sources a whole refresh still waits on, while what else it brought already shows. */ pendingSources: string[]; + + /** The player's list of sources, once read; null before, or when it couldn't be. */ + sourceList: string[] | null; + /** Why the list couldn't be read (`ModSourcesDamaged`: it was kept aside). */ + sourceListError: UiError | null; + loadSources: () => Promise; + /** Adds a source; one change to the list at a time ("skipped" while another runs). */ + addSource: (repo: string) => Promise>; + removeSource: (repo: string) => Promise>; + /** The change to the list that is running. */ + sourceChange: { kind: "add" | "remove"; repo: string } | null; } /** @@ -125,5 +137,52 @@ export function useCatalog(afterRefresh: () => Promise): CatalogState { .finally(() => refreshCatalog()); }, [offer, refreshCatalog]); - return { catalog, catalogLoading, catalogError, refreshCatalog, refreshSource, sourceRefreshing, pendingSources: pending }; + // The list of sources: read, added to and taken from here, one change at a time, so the + // dialog that shows it only renders. + const [sourceList, setSourceList] = useState(null); + const [sourceListError, setSourceListError] = useState(null); + const [sourceChange, setSourceChange] = useState(null); + const changing = useRef(false); + const loadSources = useCallback(async () => { + setSourceListError(null); + try { + setSourceList(await modsApi.sources()); + } catch (err) { + setSourceListError(asAppError(err)); + } + }, []); + const changeSources = useCallback(async (kind: "add" | "remove", repo: string): Promise> => { + if (changing.current) return { status: "skipped" }; + changing.current = true; + setSourceChange({ kind, repo }); + try { + const list = await (kind === "add" ? modsApi.addSource(repo) : modsApi.removeSource(repo)); + setSourceList(list); + setSourceListError(null); + return { status: "done", result: list }; + } catch (err) { + return failed(err); + } finally { + changing.current = false; + setSourceChange(null); + } + }, []); + const addSource = useCallback((repo: string) => changeSources("add", repo), [changeSources]); + const removeSource = useCallback((repo: string) => changeSources("remove", repo), [changeSources]); + + return { + catalog, + catalogLoading, + catalogError, + refreshCatalog, + refreshSource, + sourceRefreshing, + pendingSources: pending, + sourceList, + sourceListError, + loadSources, + addSource, + removeSource, + sourceChange, + }; } diff --git a/src/hooks/useMods.tsx b/src/hooks/useMods.tsx index 59a0e31..cb91629 100644 --- a/src/hooks/useMods.tsx +++ b/src/hooks/useMods.tsx @@ -30,7 +30,7 @@ import { useAnnounce } from "../components/LiveRegion"; import { useInstall } from "./useInstall"; import { useGameState } from "./useGameState"; import { failed, type ActionResult } from "./result"; -import { useCatalog } from "./useCatalog"; +import { useCatalog, type CatalogState } from "./useCatalog"; import { useOperations, type FirstFile } from "./useOperations"; import { useProfileCoordinator } from "./useProfileCoordinator"; @@ -55,6 +55,13 @@ interface ModsContextValue { sourceRefreshing: (repo: string) => boolean; /** The sources a whole refresh still waits on, while what else it brought already shows. */ pendingSources: string[]; + /** The player's list of sources and changes to it (hooks/useCatalog). */ + sourceList: CatalogState["sourceList"]; + sourceListError: CatalogState["sourceListError"]; + loadSources: CatalogState["loadSources"]; + addSource: CatalogState["addSource"]; + removeSource: CatalogState["removeSource"]; + sourceChange: CatalogState["sourceChange"]; profile: (tag: string | null | undefined) => ProfileReport | undefined; profileError: (tag: string) => Shown | null; @@ -150,6 +157,7 @@ export function ModsProvider({ children }: { children: ReactNode }) { // revocations and updates come with it. const catalogState = useCatalog(() => Promise.all(tagsRef.current.map((tag) => loadProfile(tag, true)))); const { catalog, catalogLoading, catalogError, refreshCatalog, refreshSource, sourceRefreshing, pendingSources } = catalogState; + const { sourceList, sourceListError, loadSources, addSource, removeSource, sourceChange } = catalogState; // A crash notice stays until its problem is fixed: the blamed mod is off, gone or replaced, or // the dependency it lacked is on and usable. @@ -349,6 +357,12 @@ export function ModsProvider({ children }: { children: ReactNode }) { refreshSource, sourceRefreshing, pendingSources, + sourceList, + sourceListError, + loadSources, + addSource, + removeSource, + sourceChange, profile, profileError, loadProfile, @@ -381,6 +395,12 @@ export function ModsProvider({ children }: { children: ReactNode }) { refreshSource, sourceRefreshing, pendingSources, + sourceList, + sourceListError, + loadSources, + addSource, + removeSource, + sourceChange, profile, profileError, loadProfile, From 6b8efa38cbecb1c7f0988fc81e4aec298e9ecb60 Mon Sep 17 00:00:00 2001 From: Zorro909 Date: Thu, 5 Nov 2026 07:00:55 +0000 Subject: [PATCH 122/156] useModsCatalog, useModProfiles and useModOperations: typed views of the mods state, for components that need one part They narrow what a component can reach; one context still holds the state, since most consumers use two or three parts and separate contexts would spare few renders. --- src/App.tsx | 4 ++-- src/components/VersionActions.tsx | 4 ++-- src/components/mods/ModFixes.tsx | 6 ++--- src/components/mods/SourcesDialog.tsx | 4 ++-- src/components/mods/catalogBanners.tsx | 4 ++-- src/hooks/useLaunch.ts | 4 ++-- src/hooks/useMods.tsx | 31 ++++++++++++++++++++++++++ src/pages/Versions.tsx | 4 ++-- 8 files changed, 46 insertions(+), 15 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index f4e5eb2..0fffbf6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,7 +11,7 @@ import { Home } from "./pages/Home"; import { VersionsPage } from "./pages/Versions"; import { NotesPage } from "./pages/Notes"; import { ModsPage } from "./pages/Mods"; -import { useMods } from "./hooks/useMods"; +import { useModProfiles } from "./hooks/useMods"; import { getLatestTag, getNewTag } from "./utils/version"; import { launcherApi } from "./mods/api"; @@ -44,7 +44,7 @@ function lastPlayed(): string | null { function App() { const { t, ready } = useI18n(); const { installedVersions } = useInstall(); - const { profile } = useMods(); + const { profile } = useModProfiles(); const [releases, setReleases] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); diff --git a/src/components/VersionActions.tsx b/src/components/VersionActions.tsx index fa98266..f266abf 100644 --- a/src/components/VersionActions.tsx +++ b/src/components/VersionActions.tsx @@ -1,6 +1,6 @@ import { useInstall, InstalledVersion, InstallProgress } from "../hooks/useInstall"; import { useGameState } from "../hooks/useGameState"; -import { useMods } from "../hooks/useMods"; +import { useModProfiles } from "../hooks/useMods"; import { useLaunch } from "../hooks/useLaunch"; import { useConfirmPress } from "../hooks/useConfirmPress"; import { useI18n } from "../i18n/useI18n"; @@ -31,7 +31,7 @@ export function VersionActions({ release, installedVersion, onFixMods }: Version const announce = useAnnounce(); const { t, tn } = useI18n(); const { progress, busy: busyTags, errors, install, remove } = useInstall(); - const { profile } = useMods(); + const { profile } = useModProfiles(); const report = installedVersion ? profile(release.tag) : undefined; const modCount = report?.mods.length ?? 0; const blocked = (report?.blocking.length ?? 0) > 0; diff --git a/src/components/mods/ModFixes.tsx b/src/components/mods/ModFixes.tsx index 6d50b97..4651403 100644 --- a/src/components/mods/ModFixes.tsx +++ b/src/components/mods/ModFixes.tsx @@ -2,7 +2,7 @@ import { useEffect, useState, type ReactNode } from "react"; import { pagerIndex, pagerStep, type PagerAt } from "../../mods/blockerPager"; import { useI18n } from "../../i18n/useI18n"; import { showError, type Shown } from "../../i18n/errors"; -import { useMods } from "../../hooks/useMods"; +import { useModProfiles, useMods, useModsCatalog } from "../../hooks/useMods"; import { modsApi } from "../../mods/api"; import { catalogModFor } from "../../mods/select"; import { blockers, type Fix } from "../../mods/status"; @@ -26,7 +26,7 @@ export interface ReviewRequest { * install goes through the review, also one click away from a problem. */ export function useReviewDialog(tag: string | null) { - const { catalog } = useMods(); + const { catalog } = useModsCatalog(); const { t } = useI18n(); const [request, setRequest] = useState(null); /** A zip from the player's computer, in the From file dialog: what replaces a file mod. */ @@ -58,7 +58,7 @@ export type Review = ReturnType; * to the window: only they can remove a file there, and they may take more than one trip. */ function useLookAgainOnReturn(tag: string) { - const { loadProfile } = useMods(); + const { loadProfile } = useModProfiles(); const [armed, setArmed] = useState(false); useEffect(() => { if (!armed) return; diff --git a/src/components/mods/SourcesDialog.tsx b/src/components/mods/SourcesDialog.tsx index a11fdb4..549a82c 100644 --- a/src/components/mods/SourcesDialog.tsx +++ b/src/components/mods/SourcesDialog.tsx @@ -3,7 +3,7 @@ import { openUrl } from "@tauri-apps/plugin-opener"; import { useI18n } from "../../i18n/useI18n"; import { showError, type Shown } from "../../i18n/errors"; import { ErrorDetails, ErrorText } from "../ErrorText"; -import { useMods } from "../../hooks/useMods"; +import { useModsCatalog } from "../../hooks/useMods"; import { parseRepo, sameRepo } from "../../mods/select"; import { Dialog } from "../Dialog"; import { Truncate } from "../Truncate"; @@ -60,7 +60,7 @@ function sourceMeta(s: SourceStatus | undefined, checking: boolean, t: (k: Messa export function SourcesDialog({ onClose }: { onClose: () => void }) { const { t, tn, formatDay } = useI18n(); - const mods = useMods(); + const mods = useModsCatalog(); const { catalog, catalogError, catalogLoading, refreshCatalog, refreshSource, sourceRefreshing, sourceList: repos, sourceChange } = mods; const loadError = mods.sourceListError ? showError(mods.sourceListError) : null; // The list couldn't be read and was kept aside: adding a source starts a new one. diff --git a/src/components/mods/catalogBanners.tsx b/src/components/mods/catalogBanners.tsx index 6526a58..0878316 100644 --- a/src/components/mods/catalogBanners.tsx +++ b/src/components/mods/catalogBanners.tsx @@ -1,6 +1,6 @@ import type { MessageKey } from "../../i18n"; import { useI18n } from "../../i18n/useI18n"; -import { useMods } from "../../hooks/useMods"; +import { useModsCatalog } from "../../hooks/useMods"; import type { Catalog, ErrorCode, SourceStatus } from "../../mods/types"; import type { BannerItem } from "./BannerStack"; import { rateLimitReset, showError } from "../../i18n/errors"; @@ -108,7 +108,7 @@ export function indexSummary(index: Catalog["index"], t: T, formatDay: (iso: str */ export function useCatalogBanners(onManageSources: () => void): Record { const { t, formatDay } = useI18n(); - const { catalog, catalogLoading, catalogError, refreshCatalog, refreshSource, sourceRefreshing } = useMods(); + const { catalog, catalogLoading, catalogError, refreshCatalog, refreshSource, sourceRefreshing } = useModsCatalog(); if (!catalog) return {}; const retry = ( )} @@ -360,7 +359,7 @@ export function ModsPage({ selectedTag, onSelectTag, onGoToVersions }: ModsPageP {t("mods.browseMods")} {donors.length > 0 && ( - )} @@ -375,7 +374,7 @@ export function ModsPage({ selectedTag, onSelectTag, onGoToVersions }: ModsPageP updatesUnavailable={!catalog && mods.catalogError ? t("mods.updatesNeedCatalog") : null} locked={running} focusMod={focusMod} - onFocused={() => setFocusMod(null)} + onFocused={() => go({ type: "focused" })} onEmptied={settleFocus} /> ) @@ -386,9 +385,9 @@ export function ModsPage({ selectedTag, onSelectTag, onGoToVersions }: ModsPageP
      - {filePath && setFilePath(null)} />} - {showSources && setShowSources(false)} />} - {showCopy && setShowCopy(false)} />} + {dialog?.kind === "file" && } + {dialog?.kind === "sources" && } + {dialog?.kind === "copy" && } {review.dialog} ); From 24c73d47c3c7cac5dc18369e84ff583eefa46c0f Mon Sep 17 00:00:00 2001 From: Zorro909 Date: Thu, 5 Nov 2026 07:04:55 +0000 Subject: [PATCH 124/156] Every backend command rejects with a CommandError: the payload's code and detail on an Error, normalised once where the call is made A string, a plain Error or a code this build doesn't know becomes 'Unknown' with what there was (asAppError, tested), so no caller is handed an unknown shape. --- src/mods/api.test.ts | 43 +++++++++++++++++++++++++++++++++++++++++++ src/mods/api.ts | 24 +++++++++++++++++++++--- 2 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 src/mods/api.test.ts diff --git a/src/mods/api.test.ts b/src/mods/api.test.ts new file mode 100644 index 0000000..f552852 --- /dev/null +++ b/src/mods/api.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from "vitest"; + +// A plain function, not a spy: a spy keeps a handle on each promise it returns, and a rejected +// one then counts as unhandled after the test. +const backend = vi.hoisted(() => ({ answer: (): Promise => Promise.resolve(null) })); +vi.mock("@tauri-apps/api/core", () => ({ invoke: () => backend.answer() })); +vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn() })); + +const { CommandError, asAppError, call } = await import("./api"); +const { showError } = await import("../i18n/errors"); +const { COMMANDS } = await import("./ipc"); + +describe("a command that fails", () => { + it("rejects with the backend's payload as a CommandError", async () => { + backend.answer = () => Promise.reject({ code: "DiskFull", detail: "No space left on device" }); + const err = await call("get_mod_profile", { tag: "v1" }).catch((e: unknown) => e); + expect(err).toBeInstanceOf(CommandError); + const e = err as InstanceType; + expect([e.code, e.detail, e.command]).toEqual(["DiskFull", "No space left on device", "get_mod_profile"]); + // What the page does with it is the same as with the payload itself. + expect(asAppError(err)).toEqual({ code: "DiskFull", detail: "No space left on device" }); + expect(showError(err).message).toBe(showError({ code: "DiskFull", detail: null }).message); + }); + + it("is Unknown, with what there was, for a string, an Error or a code this build doesn't know", async () => { + for (const [raw, detail] of [ + ["plugin failed", "plugin failed"], + [new Error("boom"), "boom"], + [{ code: "SomethingNewer", detail: "x" }, "SomethingNewer: x"], + ] as const) { + backend.answer = () => Promise.reject(raw); + const err = await call("get_mod_profile", { tag: "v1" }).catch((e: unknown) => e); + expect(err).toBeInstanceOf(CommandError); + const e = err as InstanceType; + expect([e.code, e.detail]).toEqual(["Unknown", detail]); + } + }); + + it("passes results through", async () => { + backend.answer = () => Promise.resolve(["a/b"]); + await expect(call(COMMANDS.getModSources, {})).resolves.toEqual(["a/b"]); + }); +}); diff --git a/src/mods/api.ts b/src/mods/api.ts index 4b27311..888c1e4 100644 --- a/src/mods/api.ts +++ b/src/mods/api.ts @@ -1,11 +1,27 @@ // Every backend command goes through `call`, typed by the generated `CommandSignatures`: a renamed // command or argument, or a changed result, fails to compile here instead of failing at runtime. -// A rejected promise carries an AppErrorPayload; `asAppError` says "Unknown" for anything else. +// A command that fails rejects with a CommandError, always: the backend's AppErrorPayload as it +// was, or "Unknown" with what there was for anything else (`asAppError`), so no caller is handed +// a bare string or an unknown shape. import { invoke, type InvokeArgs } from "@tauri-apps/api/core"; import { listen, type EventCallback, type UnlistenFn } from "@tauri-apps/api/event"; import type { CommandSignatures, EventPayloads } from "./bindings/commands"; import { COMMANDS } from "./ipc"; +import { asAppError } from "../i18n/errors"; +import type { ErrorCode, UiError } from "./types"; + +/** How every backend command fails: an Error, with the code and detail of the payload. */ +export class CommandError extends Error implements UiError { + readonly code: ErrorCode | "Unknown"; + readonly detail: string | null; + constructor(readonly command: string, e: UiError) { + super(e.detail ? `${command}: ${e.code}: ${e.detail}` : `${command}: ${e.code}`); + this.name = "CommandError"; + this.code = e.code; + this.detail = e.detail; + } +} export type CommandArgs = CommandSignatures[K]["args"]; export type CommandResult = CommandSignatures[K]["result"]; @@ -15,7 +31,9 @@ export function call( cmd: K, args: CommandSignatures[K]["args"], ): Promise { - return invoke(cmd, args as InvokeArgs); + return invoke(cmd, args as InvokeArgs).catch((err: unknown) => { + throw err instanceof CommandError ? err : new CommandError(cmd, asAppError(err)); + }); } /** Listens to a backend event with its generated payload type. */ @@ -78,4 +96,4 @@ export const launcherApi = { }; /** Whatever a command rejected with, as a `UiError`: one parser, in `i18n/errors`. */ -export { asAppError } from "../i18n/errors"; +export { asAppError }; From 09690c7808df05999cfbe7bc60162125892f6370 Mon Sep 17 00:00:00 2001 From: Zorro909 Date: Thu, 5 Nov 2026 07:06:36 +0000 Subject: [PATCH 125/156] A refused download link says which rule it broke: UrlProblem is an enum, and the page words its three kinds (host, form, asset) ModUrlRefused's detail leads with the rule's stable name ('notHttps '), which a Rust test pins; the link stays behind Details. No new error code. --- src-tauri/src/install.rs | 6 ++- src-tauri/src/mods/error.rs | 26 +++++++++-- src-tauri/src/mods/index/format.rs | 75 +++++++++++++++++++++++++----- src-tauri/src/mods/install.rs | 5 +- src/i18n/en.json | 5 +- src/i18n/errors.test.ts | 16 ++++++- src/i18n/errors.ts | 31 ++++++++++++ src/i18n/es.json | 5 +- src/i18n/pt-BR.json | 5 +- src/i18n/ru.json | 5 +- 10 files changed, 156 insertions(+), 23 deletions(-) diff --git a/src-tauri/src/install.rs b/src-tauri/src/install.rs index 7c38912..ce084c4 100644 --- a/src-tauri/src/install.rs +++ b/src-tauri/src/install.rs @@ -188,7 +188,11 @@ pub fn sweep_leftovers(app_handle: &tauri::AppHandle) { /// Why `url` is not the release asset `asset_name` of game version `tag`, if it isn't: the mods' /// rule ([`crate::mods::index::release_asset_problem`]) for the game's own repo. A URL that only /// starts right, like `…/releases/download/../../../other/repo/…`, is refused. -pub fn version_url_problem(tag: &str, asset_name: &str, url: &str) -> Option<&'static str> { +pub fn version_url_problem( + tag: &str, + asset_name: &str, + url: &str, +) -> Option { crate::mods::index::release_asset_problem(WEFT_REPO, tag, asset_name, url) } diff --git a/src-tauri/src/mods/error.rs b/src-tauri/src/mods/error.rs index 92efa9b..d663d6a 100644 --- a/src-tauri/src/mods/error.rs +++ b/src-tauri/src/mods/error.rs @@ -79,8 +79,11 @@ pub enum ModsError { #[error("Cancelled")] Cancelled, /// A build's download URL isn't its GitHub release asset. - #[error("Refused download URL: {0}")] - UrlRefused(String), + #[error("Refused download URL {url}: {why}")] + UrlRefused { + url: String, + why: super::index::UrlProblem, + }, /// A mod icon that isn't a PNG. #[error("Not a PNG icon: {0}")] IconInvalid(String), @@ -154,7 +157,7 @@ impl ModsError { ChangedMeanwhile(_) => C::ModChangedMeanwhile, LockInvalid(_) => C::ModLockInvalid, Cancelled => C::ModCancelled, - UrlRefused(_) => C::ModUrlRefused, + UrlRefused { .. } => C::ModUrlRefused, IconInvalid(_) => C::ModIconInvalid, } } @@ -195,8 +198,9 @@ impl From for AppError { | VersionNotInstalled(d) | GameRunning(d) | ChangedMeanwhile(d) - | UrlRefused(d) | IconInvalid(d) => Some(d), + // The rule first, which the page says in words (src/i18n/errors.ts `urlRule`). + UrlRefused { url, why } => Some(format!("{} {url}", why.rule())), LockInvalid(d) => Some(format!("mods.lock.json: {d}")), }; AppError::new(code, detail) @@ -223,6 +227,20 @@ mod tests { assert_eq!(http(503), ErrorCode::Network); } + #[test] + fn a_refused_url_says_the_rule_first() { + use crate::mods::index::UrlProblem; + let e = AppError::from(ModsError::UrlRefused { + url: "http://github.com/a/b/releases/download/t/x.zip".into(), + why: UrlProblem::NotHttps, + }); + assert_eq!(e.code, ErrorCode::ModUrlRefused); + assert_eq!( + e.detail.as_deref(), + Some("notHttps http://github.com/a/b/releases/download/t/x.zip") + ); + } + #[test] fn a_rate_limit_says_when_it_ends() { let detail = |reset| AppError::from(ModsError::SourceRateLimited { reset }).detail; diff --git a/src-tauri/src/mods/index/format.rs b/src-tauri/src/mods/index/format.rs index 8b96731..abd85c0 100644 --- a/src-tauri/src/mods/index/format.rs +++ b/src-tauri/src/mods/index/format.rs @@ -320,9 +320,60 @@ pub fn same_repo(a: &str, b: &str) -> bool { a.eq_ignore_ascii_case(b) } +/// Which rule a download URL broke: the page says it in the player's language (the detail of +/// `ModUrlRefused` starts with [`UrlProblem::rule`]). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UrlProblem { + Backslash, + PercentEncoded, + NotAUrl, + NotCanonical, + NotHttps, + NotGithub, + QueryOrFragment, + NoPath, + BadRepo, + NotReleaseAsset, +} + +impl UrlProblem { + /// The rule's stable name, which the page reads (src/i18n/errors.ts `urlRule`). + pub fn rule(self) -> &'static str { + match self { + Self::Backslash => "backslash", + Self::PercentEncoded => "percentEncoded", + Self::NotAUrl => "notAUrl", + Self::NotCanonical => "notCanonical", + Self::NotHttps => "notHttps", + Self::NotGithub => "notGithub", + Self::QueryOrFragment => "queryOrFragment", + Self::NoPath => "noPath", + Self::BadRepo => "badRepo", + Self::NotReleaseAsset => "notReleaseAsset", + } + } +} + +impl std::fmt::Display for UrlProblem { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Backslash => "contains a backslash", + Self::PercentEncoded => "percent-encoded", + Self::NotAUrl => "not a URL", + Self::NotCanonical => "not in canonical form", + Self::NotHttps => "not https", + Self::NotGithub => "not github.com", + Self::QueryOrFragment => "has a query or fragment", + Self::NoPath => "no path", + Self::BadRepo => "bad repo", + Self::NotReleaseAsset => "not this repo's release asset", + }) + } +} + /// Why `url` is not the release asset `.zip` of `tag` in `repo`, if it isn't. See /// [`release_asset_problem`]. -pub fn asset_url_problem(id: &str, repo: &str, tag: &str, url: &str) -> Option<&'static str> { +pub fn asset_url_problem(id: &str, repo: &str, tag: &str, url: &str) -> Option { release_asset_problem(repo, tag, &format!("{id}.zip"), url) } @@ -330,40 +381,40 @@ pub fn asset_url_problem(id: &str, repo: &str, tag: &str, url: &str) -> Option<& /// `https://github.com///releases/download//` exactly as written: no /// other host, port, user, query or fragment, no `\`, no percent-encoding, no `.` or `..`. /// Game versions (`install.rs`) and mods both go through it. -pub fn release_asset_problem(repo: &str, tag: &str, file: &str, url: &str) -> Option<&'static str> { +pub fn release_asset_problem(repo: &str, tag: &str, file: &str, url: &str) -> Option { if url.contains('\\') { - return Some("contains a backslash"); + return Some(UrlProblem::Backslash); } if url.contains('%') { - return Some("percent-encoded"); + return Some(UrlProblem::PercentEncoded); } let Ok(parsed) = url::Url::parse(url) else { - return Some("not a URL"); + return Some(UrlProblem::NotAUrl); }; // The parser resolves `.` and `..` and fixes up odd spellings; a URL that changes when // parsed is not the one that was reviewed. if parsed.as_str() != url { - return Some("not in canonical form"); + return Some(UrlProblem::NotCanonical); } if parsed.scheme() != "https" { - return Some("not https"); + return Some(UrlProblem::NotHttps); } if parsed.host_str() != Some("github.com") || parsed.port().is_some() || !parsed.username().is_empty() || parsed.password().is_some() { - return Some("not github.com"); + return Some(UrlProblem::NotGithub); } if parsed.query().is_some() || parsed.fragment().is_some() { - return Some("has a query or fragment"); + return Some(UrlProblem::QueryOrFragment); } let Some(segments) = parsed.path_segments() else { - return Some("no path"); + return Some(UrlProblem::NoPath); }; let segments: Vec<&str> = segments.collect(); let Some((owner, name)) = repo.split_once('/') else { - return Some("bad repo"); + return Some(UrlProblem::BadRepo); }; match segments.as_slice() { [o, r, "releases", "download", t, n] @@ -375,7 +426,7 @@ pub fn release_asset_problem(repo: &str, tag: &str, file: &str, url: &str) -> Op { None } - _ => Some("not this repo's release asset"), + _ => Some(UrlProblem::NotReleaseAsset), } } diff --git a/src-tauri/src/mods/install.rs b/src-tauri/src/mods/install.rs index 3b6d75e..ee5a9b6 100644 --- a/src-tauri/src/mods/install.rs +++ b/src-tauri/src/mods/install.rs @@ -64,7 +64,10 @@ pub async fn fetch_to_store( return Ok(()); } if check_url && let Some(why) = asset_url_problem(&m.id, &v.source_repo, &v.tag, &v.url) { - return Err(ModsError::UrlRefused(format!("{}: {why}", v.url))); + return Err(ModsError::UrlRefused { + url: v.url.clone(), + why, + }); } let tmp = store::temp_path(paths, &v.sha256)?; progress(ProgressStage::Downloading, 0); diff --git a/src/i18n/en.json b/src/i18n/en.json index f88c141..34d6ce7 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -100,7 +100,10 @@ "modIconInvalid": "This mod's icon isn't a small PNG, so it isn't shown.", "details": "Details", "modSourcesDamaged": "Your list of mod sources couldn't be read, so it was set aside and kept. Add your sources again; the old list is behind Details.", - "modSourceRateLimitedUntil": "GitHub is limiting requests until {0}. Try again then." + "modSourceRateLimitedUntil": "GitHub is limiting requests until {0}. Try again then.", + "modUrlRefusedHost": "A download link pointed away from github.com, or not over https, so the launcher refused it. Nothing was downloaded.", + "modUrlRefusedForm": "A download link wasn't written the one plain way a GitHub download link is, so the launcher refused it. Nothing was downloaded.", + "modUrlRefusedAsset": "A download link pointed at a file other than this mod's release on its GitHub repo, so the launcher refused it. Nothing was downloaded." }, "mods": { "title": "Mods", diff --git a/src/i18n/errors.test.ts b/src/i18n/errors.test.ts index 0ae1a94..3e62ea7 100644 --- a/src/i18n/errors.test.ts +++ b/src/i18n/errors.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { asAppError, rateLimitReset, showError } from "./errors"; +import { asAppError, rateLimitReset, showError, urlRule } from "./errors"; describe("one parser for rejections", () => { it("keeps a known code and its detail", () => { @@ -36,3 +36,17 @@ describe("a source's rate limit", () => { expect(rateLimitReset({ code: "RateLimited", detail: `resets at ${at}` })).toBeNull(); }); }); + +describe("a refused download link", () => { + it("says which rule it broke, with the link behind Details", () => { + const url = "http://github.com/a/b/releases/download/t/x.zip"; + expect(urlRule(`notHttps ${url}`)).toEqual({ kind: "host", url }); + const shown = showError({ code: "ModUrlRefused", detail: `notReleaseAsset ${url}` }); + expect(shown.message).toBe("A download link pointed at a file other than this mod's release on its GitHub repo, so the launcher refused it. Nothing was downloaded."); + expect(shown.detail).toBe(url); + // A rule this build doesn't know: the general sentence, and the whole detail. + const other = showError({ code: "ModUrlRefused", detail: `somethingNew ${url}` }); + expect(other.message).toMatch(/^A download link didn't point where/); + expect(other.detail).toBe(`somethingNew ${url}`); + }); +}); diff --git a/src/i18n/errors.ts b/src/i18n/errors.ts index a4ae499..b85c8c7 100644 --- a/src/i18n/errors.ts +++ b/src/i18n/errors.ts @@ -87,6 +87,11 @@ export interface Shown { export function showError(err: unknown): Shown { if (err === null || err === undefined) return { message: '', detail: null }; const e = asAppError(err); + if (e.code === 'ModUrlRefused') { + // Which rule the link broke, in words; the link itself behind Details. + const refusal = urlRule(e.detail); + if (refusal) return { message: t(URL_RULE_MESSAGE[refusal.kind]), detail: refusal.url || null }; + } if (e.code === 'ModSourceRateLimited') { // When GitHub said the limit ends, that is what to say: "until 14:32". const until = rateLimitReset(e); @@ -139,6 +144,32 @@ export function rateLimitReset(error: { code: string; detail: string | null } | return m ? new Date(Number(m[1]) * 1000) : null; } +/** The rules a download link can break (src-tauri/src/mods/index/format.rs `UrlProblem`), by what the player can tell from them. */ +const URL_RULES: Record = { + notHttps: 'host', + notGithub: 'host', + backslash: 'form', + percentEncoded: 'form', + notAUrl: 'form', + notCanonical: 'form', + queryOrFragment: 'form', + noPath: 'form', + badRepo: 'asset', + notReleaseAsset: 'asset', +}; +const URL_RULE_MESSAGE: Record<'host' | 'form' | 'asset', MessageKey> = { + host: 'errors.modUrlRefusedHost', + form: 'errors.modUrlRefusedForm', + asset: 'errors.modUrlRefusedAsset', +}; + +/** The rule a refused download link broke and the link, from `ModUrlRefused`'s detail (" "). */ +export function urlRule(detail: string | null): { kind: 'host' | 'form' | 'asset'; url: string } | null { + const m = /^([A-Za-z]+) (.*)$/s.exec(detail ?? ''); + const kind = m ? URL_RULES[m[1]] : undefined; + return m && kind ? { kind, url: m[2] } : null; +} + function takesDetail(key: MessageKey): boolean { return t(key, '\u0000').includes('\u0000'); } diff --git a/src/i18n/es.json b/src/i18n/es.json index ef62265..7c79655 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -100,7 +100,10 @@ "modIconInvalid": "El icono de este mod no es un PNG pequeño, así que no se muestra.", "details": "Detalles", "modSourcesDamaged": "No se pudo leer tu lista de fuentes de mods, así que se apartó y se guardó. Vuelve a añadir tus fuentes; la lista antigua está en Detalles.", - "modSourceRateLimitedUntil": "GitHub está limitando las peticiones hasta las {0}. Vuelve a intentarlo entonces." + "modSourceRateLimitedUntil": "GitHub está limitando las peticiones hasta las {0}. Vuelve a intentarlo entonces.", + "modUrlRefusedHost": "Un enlace de descarga apuntaba fuera de github.com, o no usaba https, así que el launcher lo rechazó. No se descargó nada.", + "modUrlRefusedForm": "Un enlace de descarga no estaba escrito de la forma sencilla de un enlace de descarga de GitHub, así que el launcher lo rechazó. No se descargó nada.", + "modUrlRefusedAsset": "Un enlace de descarga apuntaba a un archivo que no es la versión de este mod en su repositorio de GitHub, así que el launcher lo rechazó. No se descargó nada." }, "mods": { "title": "Mods", diff --git a/src/i18n/pt-BR.json b/src/i18n/pt-BR.json index 76f49f9..5e71bdd 100644 --- a/src/i18n/pt-BR.json +++ b/src/i18n/pt-BR.json @@ -100,7 +100,10 @@ "modIconInvalid": "O ícone deste mod não é um PNG pequeno, então ele não é exibido.", "details": "Detalhes", "modSourcesDamaged": "Não foi possível ler sua lista de fontes de mods, então ela foi deixada de lado e guardada. Adicione suas fontes de novo; a lista antiga está em Detalhes.", - "modSourceRateLimitedUntil": "O GitHub está limitando as requisições até {0}. Tente de novo depois disso." + "modSourceRateLimitedUntil": "O GitHub está limitando as requisições até {0}. Tente de novo depois disso.", + "modUrlRefusedHost": "Um link de download apontava para fora do github.com, ou não usava https, então o launcher o recusou. Nada foi baixado.", + "modUrlRefusedForm": "Um link de download não estava escrito da forma simples de um link de download do GitHub, então o launcher o recusou. Nada foi baixado.", + "modUrlRefusedAsset": "Um link de download apontava para um arquivo que não é a versão deste mod no repositório do GitHub, então o launcher o recusou. Nada foi baixado." }, "mods": { "title": "Mods", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index c4d207e..4e0c7a6 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -100,7 +100,10 @@ "modIconInvalid": "Значок этого мода — не маленький PNG, поэтому он не показан.", "details": "Подробности", "modSourcesDamaged": "Не удалось прочитать ваш список источников модов, поэтому он отложен и сохранён. Добавьте источники заново; старый список — в «Подробностях».", - "modSourceRateLimitedUntil": "GitHub ограничивает запросы до {0}. Попробуйте после этого." + "modSourceRateLimitedUntil": "GitHub ограничивает запросы до {0}. Попробуйте после этого.", + "modUrlRefusedHost": "Ссылка для загрузки вела не на github.com или не по https, поэтому лаунчер её отклонил. Ничего не загружено.", + "modUrlRefusedForm": "Ссылка для загрузки записана не так, как пишется обычная ссылка загрузки GitHub, поэтому лаунчер её отклонил. Ничего не загружено.", + "modUrlRefusedAsset": "Ссылка для загрузки вела не на релиз этого мода в его репозитории GitHub, поэтому лаунчер её отклонил. Ничего не загружено." }, "mods": { "title": "Моды", From c71f2324fa18bf8d1afd28922548804a99838f20 Mon Sep 17 00:00:00 2001 From: Zorro909 Date: Thu, 5 Nov 2026 07:10:52 +0000 Subject: [PATCH 126/156] =?UTF-8?q?A=20mod=20whose=20stored=20copy=20is=20?= =?UTF-8?q?gone=20says=20so=20(new=20code=20ModStoreMissing)=20and=20offer?= =?UTF-8?q?s=20'Install=20X=20again=E2=80=A6',=20which=20brings=20it=20bac?= =?UTF-8?q?k?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reconcile said 'That mod isn't installed any more' for a store object that had gone. Installing the same build downloads it again (admit_stored finds nothing); a service test and an e2e flow (prelaunch=storemissing) cover it. --- README.md | 2 +- e2e/flows.spec.ts | 19 ++++++++++++++++++ src-tauri/src/error.rs | 1 + src-tauri/src/mods/error.rs | 6 ++++++ src-tauri/src/mods/service_tests.rs | 31 +++++++++++++++++++++++++++++ src-tauri/src/mods/store.rs | 5 ++++- src/components/LaunchRefusal.tsx | 26 +++++++++++++++++++++--- src/components/VersionActions.tsx | 2 +- src/dev/mockBackend.ts | 11 +++++++++- src/dev/scenarios.ts | 6 +++++- src/hooks/useLaunch.ts | 11 +++++++--- src/i18n/en.json | 6 ++++-- src/i18n/errors.ts | 1 + src/i18n/es.json | 6 ++++-- src/i18n/pt-BR.json | 6 ++++-- src/i18n/ru.json | 6 ++++-- src/mods/bindings/ErrorCode.ts | 2 +- src/pages/Home.tsx | 2 +- 18 files changed, 128 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 7f9d781..98d3feb 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ Pick a state with `?scenario=` and adjust it with flags, which combine: `?scenar | `revocations` | `messy` | Revocations come with uppercase hashes, no reason or time, and one entry that isn't a hash: they still revoke, and only that one is dropped. | | `chain` | `1` | Adds Chimneys and Junctions, which need mods that need others. | | `crash` | `wrongVersion`, `loadFailed`, `notAMod`, `wrongName`, `missingDep`, `initPanicked`, `exit` | Play starts the game and it stops with this failure; `exit` is a crash that names no mod. | -| `prelaunch` | `revoke` | Play finds Cogwork revoked, as a fresh index would, before it starts the game. | +| `prelaunch` | `revoke`, `storemissing` | Play finds Cogwork revoked, as a fresh index would, before it starts the game; or finds the store's copy of Structures gone (`ModStoreMissing`) until it is installed again. | | `long` | `1` | Adds very long names, ids and repos. | | `slow` | `1` | Everything takes four times as long: time enough to press Cancel. | | `fast` | `1` | Everything takes a quarter as long, for screenshots of results. | diff --git a/e2e/flows.spec.ts b/e2e/flows.spec.ts index d747a4c..9e15964 100644 --- a/e2e/flows.spec.ts +++ b/e2e/flows.spec.ts @@ -65,6 +65,25 @@ test.describe("launch safety", () => { }); } + test("a mod whose stored copy is gone offers installing it again, and then Play starts", async ({ page }) => { + await open(page, "scenario=crash&crash=&prelaunch=storemissing"); + await settled(page); + await page.locator(".btn-play").click(); + const refusal = page.locator(".launch-error"); + await expect(refusal).toContainText("The launcher's stored copy of structures is gone"); + const again = refusal.locator("button[data-fix=reinstall]"); + await expect(again).toHaveText("Install Structures again…"); + await again.click(); + const d = page.locator(dialog); + await expect(d.locator(".dialog-title")).toContainText("Structures"); + await d.locator(".dialog-actions .btn-primary").click(); + await expect(d).toHaveCount(0, { timeout: 15_000 }); + await page.locator(".btn-play").click(); + await expect(page.locator(".launch-error")).toHaveCount(0); + // Started: the badge says the version is playing. + await expect(page.locator(".home-page")).toContainText("PLAYING", { timeout: 10_000 }); + }); + test("a crash of a mod from a file offers another file, in the From file dialog", async ({ page }) => { await open(page, "scenario=crash&crash=wrongVersion"); await page.locator(".btn-play").click(); diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index 4c6f220..5569ec0 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -71,6 +71,7 @@ error_codes! { ModArchiveInvalid, ModFileChanged, ModNotInstalled, + ModStoreMissing, ModsBlocked, ModSourceInvalid, ModSourceNotFound, diff --git a/src-tauri/src/mods/error.rs b/src-tauri/src/mods/error.rs index d663d6a..a4d1d13 100644 --- a/src-tauri/src/mods/error.rs +++ b/src-tauri/src/mods/error.rs @@ -49,6 +49,10 @@ pub enum ModsError { FileChanged, #[error("Mod not installed: {0}")] NotInstalled(String), + /// The store's copy of an installed mod is gone (removed by hand, or a disk restored from an + /// older backup): installing the same build again brings it back. The mod's id. + #[error("Missing from the store: {0}")] + StoreMissing(String), /// The display names of the mods that would stop the game. #[error("Mods stop the game from starting: {0}")] Blocked(String), @@ -146,6 +150,7 @@ impl ModsError { Sha256Mismatch { .. } => C::Sha256Mismatch, FileChanged => C::ModFileChanged, NotInstalled(_) => C::ModNotInstalled, + StoreMissing(_) => C::ModStoreMissing, Blocked(_) => C::ModsBlocked, SourceInvalid(_) => C::ModSourceInvalid, SourceNotFound(_) => C::ModSourceNotFound, @@ -189,6 +194,7 @@ impl From for AppError { | NotForPlatform(d) | Revoked(d) | NotInstalled(d) + | StoreMissing(d) | Blocked(d) | SourceInvalid(d) | SourceNotFound(d) diff --git a/src-tauri/src/mods/service_tests.rs b/src-tauri/src/mods/service_tests.rs index 0a0a8df..5a05185 100644 --- a/src-tauri/src/mods/service_tests.rs +++ b/src-tauri/src/mods/service_tests.rs @@ -1731,6 +1731,37 @@ mod saves { assert_eq!(handed_to_game(&ctx), ["showcase.zip"]); } + #[test] + fn a_store_copy_that_is_gone_is_said_as_such_and_installing_the_build_again_brings_it_back() { + let tmp = tempfile::tempdir().unwrap(); + let (ctx, world) = setup(&tmp); + run(install(&ctx, &world.sha("showcase"))).unwrap(); + std::fs::remove_file(ctx.paths.store_object(&world.sha("showcase"))).unwrap(); + // The game's copy goes too, so the launch has to copy it again from the store. + let _ = std::fs::remove_file(ctx.paths.mods_dir(TAG).unwrap().join("showcase.zip")); + let Err(err) = run(service::prepare_launch(&ctx, TAG)) else { + panic!("a launch with a store copy gone went ahead"); + }; + assert!( + matches!(err, ModsError::StoreMissing(ref id) if id == "showcase"), + "{err:?}" + ); + assert_eq!( + crate::error::AppError::from(err).code, + crate::error::ErrorCode::ModStoreMissing + ); + // The same build installed again: downloaded, and the launch goes ahead. + run(install(&ctx, &world.sha("showcase"))).unwrap(); + assert!(ctx.paths.store_object(&world.sha("showcase")).exists()); + assert!( + run(service::prepare_launch(&ctx, TAG)) + .unwrap() + .release() + .is_some() + ); + assert_eq!(handed_to_game(&ctx), ["showcase.zip"]); + } + #[test] fn a_removal_whose_undo_cant_be_kept_still_stands_without_an_undo() { let tmp = tempfile::tempdir().unwrap(); diff --git a/src-tauri/src/mods/store.rs b/src-tauri/src/mods/store.rs index dce29c4..3589200 100644 --- a/src-tauri/src/mods/store.rs +++ b/src-tauri/src/mods/store.rs @@ -146,7 +146,10 @@ pub fn reconcile(paths: &Paths, tag: &str, lock: &Lock) -> Result<(), ModsError> } let _ = std::fs::remove_file(&target); let stored = paths.store_object(&m.sha256); - let actual = sha256_file(&stored).map_err(|_| ModsError::NotInstalled(m.id.clone()))?; + if !stored.exists() { + return Err(ModsError::StoreMissing(m.id.clone())); + } + let actual = sha256_file(&stored)?; if actual != m.sha256 { return Err(ModsError::Sha256Mismatch { expected: m.sha256.clone(), diff --git a/src/components/LaunchRefusal.tsx b/src/components/LaunchRefusal.tsx index 444a4dd..32129fb 100644 --- a/src/components/LaunchRefusal.tsx +++ b/src/components/LaunchRefusal.tsx @@ -1,15 +1,35 @@ import { useI18n } from "../i18n/useI18n"; import type { Launch } from "../hooks/useLaunch"; +import { useModProfiles } from "../hooks/useMods"; import { ErrorText } from "./ErrorText"; +import type { Review } from "./mods/ModFixes"; /** * A refused start of `tag`, where the blocker panel can't say it: a refusal for another reason as * it is, or a refusal because of mods that couldn't be read again, with a way to check again. */ -export function LaunchRefusal({ tag, launch }: { tag: string; launch: Launch }) { +export function LaunchRefusal({ tag, launch, review }: { tag: string; launch: Launch; review: Review }) { const { t } = useI18n(); - const { shownError, unread } = launch.refusalFor(tag); - if (shownError) return ; + const { profile } = useModProfiles(); + const { shownError, unread, storeMissing } = launch.refusalFor(tag); + // A mod whose stored copy is gone: the same build installed again brings it back, through its + // install dialog; a file from the player's computer, through From file. + const missing = storeMissing ? profile(tag)?.mods.find((m) => m.id === storeMissing) : undefined; + if (shownError) + return ( + + {missing && review.canUpdate(missing) && ( + + )} + {missing && !review.canUpdate(missing) && missing.origin.kind === "file" && ( + + )} + + ); if (!unread) return null; const why = unread.unread ?? unread.shown; return ( diff --git a/src/components/VersionActions.tsx b/src/components/VersionActions.tsx index f266abf..c7fecbd 100644 --- a/src/components/VersionActions.tsx +++ b/src/components/VersionActions.tsx @@ -128,7 +128,7 @@ export function VersionActions({ release, installedVersion, onFixMods }: Version
      )} - + {error && } {crash && (
      diff --git a/src/dev/mockBackend.ts b/src/dev/mockBackend.ts index 6dcf219..69d5543 100644 --- a/src/dev/mockBackend.ts +++ b/src/dev/mockBackend.ts @@ -558,11 +558,16 @@ async function runInstall( const planOf = (tag: string, sha256: string) => R.planInstall(catalog, lockOf(tag), tag, sha256, fitOf(tag), running.has(tag)); +/** `prelaunch=storemissing`: mods whose store copy is gone, until installed again. */ +const storeMissing = new Set(flag("prelaunch") === "storemissing" ? ["structures"] : []); + async function installMod(tag: string, sha256: string, planToken: string, operationId: string): Promise { somethingChangesMeanwhile(); const plan = planOf(tag, sha256); if (plan.problem) throw plan.problem; - return operation(operationId, (op) => runInstall(op, tag, [findBuild(sha256)], () => planOf(tag, sha256).planToken, planToken)); + const done = await operation(operationId, (op) => runInstall(op, tag, [findBuild(sha256)], () => planOf(tag, sha256).planToken, planToken)); + storeMissing.delete(plan.target.id); + return done; } function previewCopy(from: string, to: string) { @@ -734,6 +739,10 @@ async function launch(tag: string) { if (running.size) fail("AnotherGameRunning", [...running][0]); // `prepare_launch` applies what the catalogue says before anything starts. if (flag("prelaunch") === "revoke" && !catalog.revoked.length) revoke("cogwork"); + // The store's copy of Structures is gone until it is installed again. + if (flag("prelaunch") === "storemissing" && storeMissing.has("structures") && lockOf(tag).some((e) => e.id === "structures" && e.enabled)) { + fail("ModStoreMissing", "structures"); + } // Then it reads the set and hashes what it hands the game: not instant. await sleep(250); const r = report(tag); diff --git a/src/dev/scenarios.ts b/src/dev/scenarios.ts index 7bf64c8..9e3509c 100644 --- a/src/dev/scenarios.ts +++ b/src/dev/scenarios.ts @@ -118,7 +118,11 @@ export const FLAGS: Flag[] = [ values: ["wrongVersion", "loadFailed", "notAMod", "wrongName", "missingDep", "initPanicked", "exit"], description: "Play starts the game and it stops with this failure; `exit` is a crash that names no mod.", }, - { name: "prelaunch", values: ["revoke"], description: "Play finds Cogwork revoked, as a fresh index would, before it starts the game." }, + { + name: "prelaunch", + values: ["revoke", "storemissing"], + description: "Play finds Cogwork revoked, as a fresh index would, before it starts the game; or finds the store's copy of Structures gone (`ModStoreMissing`) until it is installed again.", + }, { name: "long", values: ["1"], description: "Adds very long names, ids and repos." }, { name: "slow", values: ["1"], description: "Everything takes four times as long: time enough to press Cancel." }, { name: "fast", values: ["1"], description: "Everything takes a quarter as long, for screenshots of results." }, diff --git a/src/hooks/useLaunch.ts b/src/hooks/useLaunch.ts index 5f96a74..2a26664 100644 --- a/src/hooks/useLaunch.ts +++ b/src/hooks/useLaunch.ts @@ -21,6 +21,8 @@ export interface LaunchError { export interface Refusal { /** A refusal for any other reason: shown as it is. */ shownError: Shown | null; + /** The mod whose stored copy is gone (`ModStoreMissing`): installing it again is the fix. */ + storeMissing: string | null; /** A refusal because of mods that couldn't be read again: shown with "Check mods again". */ unread: LaunchError | null; /** The last start was refused because of mods, and that still holds. */ @@ -95,11 +97,14 @@ export function useLaunch() { const refusalFor = useCallback( (tag: string | null): Refusal => { const refusal = tag && launchError?.tag === tag ? launchError : null; - if (!refusal) return { shownError: null, unread: null, refusedForMods: false }; - if (refusal.error.code !== "ModsBlocked") return { shownError: refusal.shown, unread: null, refusedForMods: false }; + if (!refusal) return { shownError: null, unread: null, refusedForMods: false, storeMissing: null }; + if (refusal.error.code !== "ModsBlocked") { + const storeMissing = refusal.error.code === "ModStoreMissing" ? refusal.error.detail : null; + return { shownError: refusal.shown, unread: null, refusedForMods: false, storeMissing }; + } // Said by the blocker panel, with its fixes, unless the set couldn't be read or shows nothing. const blocks = (profile(tag)?.blocking.length ?? 0) > 0; - return { shownError: null, unread: refusal.unread || !blocks ? refusal : null, refusedForMods: true }; + return { shownError: null, unread: refusal.unread || !blocks ? refusal : null, refusedForMods: true, storeMissing: null }; }, [launchError, profile] ); diff --git a/src/i18n/en.json b/src/i18n/en.json index 34d6ce7..24e59d7 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -103,7 +103,8 @@ "modSourceRateLimitedUntil": "GitHub is limiting requests until {0}. Try again then.", "modUrlRefusedHost": "A download link pointed away from github.com, or not over https, so the launcher refused it. Nothing was downloaded.", "modUrlRefusedForm": "A download link wasn't written the one plain way a GitHub download link is, so the launcher refused it. Nothing was downloaded.", - "modUrlRefusedAsset": "A download link pointed at a file other than this mod's release on its GitHub repo, so the launcher refused it. Nothing was downloaded." + "modUrlRefusedAsset": "A download link pointed at a file other than this mod's release on its GitHub repo, so the launcher refused it. Nothing was downloaded.", + "modStoreMissing": "The launcher's stored copy of {0} is gone, so it can't be handed to the game. Install it again to play with it." }, "mods": { "title": "Mods", @@ -524,7 +525,8 @@ "homeModsOnlyOwn_other": "No mods for {0}, but {n} files from your own folder load", "indexUnavailableStatusShort": "No mod index (server said {0}): no Reviewed mods", "browseNoIndex": "Reviewed mods appear here once the Weft mod index can be fetched.", - "sourceErrRateUntil": "GitHub is limiting requests until {1}, so {0} couldn't be refreshed." + "sourceErrRateUntil": "GitHub is limiting requests until {1}, so {0} couldn't be refreshed.", + "reinstall": "Install {0} again…" }, "sizeUnits": { "B": "B", diff --git a/src/i18n/errors.ts b/src/i18n/errors.ts index b85c8c7..6431445 100644 --- a/src/i18n/errors.ts +++ b/src/i18n/errors.ts @@ -46,6 +46,7 @@ export const ERROR_KEY: Record = { ModArchiveInvalid: 'errors.modArchiveInvalid', ModFileChanged: 'errors.modFileChanged', ModNotInstalled: 'errors.modNotInstalled', + ModStoreMissing: 'errors.modStoreMissing', ModsBlocked: 'errors.modsBlocked', ModSourceInvalid: 'errors.modSourceInvalid', ModSourceNotFound: 'errors.modSourceNotFound', diff --git a/src/i18n/es.json b/src/i18n/es.json index 7c79655..e9fe836 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -103,7 +103,8 @@ "modSourceRateLimitedUntil": "GitHub está limitando las peticiones hasta las {0}. Vuelve a intentarlo entonces.", "modUrlRefusedHost": "Un enlace de descarga apuntaba fuera de github.com, o no usaba https, así que el launcher lo rechazó. No se descargó nada.", "modUrlRefusedForm": "Un enlace de descarga no estaba escrito de la forma sencilla de un enlace de descarga de GitHub, así que el launcher lo rechazó. No se descargó nada.", - "modUrlRefusedAsset": "Un enlace de descarga apuntaba a un archivo que no es la versión de este mod en su repositorio de GitHub, así que el launcher lo rechazó. No se descargó nada." + "modUrlRefusedAsset": "Un enlace de descarga apuntaba a un archivo que no es la versión de este mod en su repositorio de GitHub, así que el launcher lo rechazó. No se descargó nada.", + "modStoreMissing": "La copia de {0} que guardaba el launcher ya no está, así que no se puede pasar al juego. Vuelve a instalarlo para jugar con él." }, "mods": { "title": "Mods", @@ -558,7 +559,8 @@ "homeModsOnlyOwn_other": "Sin mods para {0}, pero se cargan {n} archivos de tu carpeta", "indexUnavailableStatusShort": "Sin índice de mods (servidor: {0}): sin mods revisados", "browseNoIndex": "Los mods revisados aparecen aquí cuando se pueda descargar el índice de mods de Weft.", - "sourceErrRateUntil": "GitHub está limitando las peticiones hasta las {1}, así que no se pudo actualizar {0}." + "sourceErrRateUntil": "GitHub está limitando las peticiones hasta las {1}, así que no se pudo actualizar {0}.", + "reinstall": "Instalar {0} de nuevo…" }, "sizeUnits": { "B": "B", diff --git a/src/i18n/pt-BR.json b/src/i18n/pt-BR.json index 5e71bdd..8af2f68 100644 --- a/src/i18n/pt-BR.json +++ b/src/i18n/pt-BR.json @@ -103,7 +103,8 @@ "modSourceRateLimitedUntil": "O GitHub está limitando as requisições até {0}. Tente de novo depois disso.", "modUrlRefusedHost": "Um link de download apontava para fora do github.com, ou não usava https, então o launcher o recusou. Nada foi baixado.", "modUrlRefusedForm": "Um link de download não estava escrito da forma simples de um link de download do GitHub, então o launcher o recusou. Nada foi baixado.", - "modUrlRefusedAsset": "Um link de download apontava para um arquivo que não é a versão deste mod no repositório do GitHub, então o launcher o recusou. Nada foi baixado." + "modUrlRefusedAsset": "Um link de download apontava para um arquivo que não é a versão deste mod no repositório do GitHub, então o launcher o recusou. Nada foi baixado.", + "modStoreMissing": "A cópia de {0} guardada pelo launcher sumiu, então ela não pode ser entregue ao jogo. Instale de novo para jogar com ele." }, "mods": { "title": "Mods", @@ -558,7 +559,8 @@ "homeModsOnlyOwn_other": "Sem mods para {0}, mas {n} arquivos da sua pasta carregam", "indexUnavailableStatusShort": "Sem índice de mods (servidor: {0}): sem mods revisados", "browseNoIndex": "Os mods revisados aparecem aqui quando o índice de mods do Weft puder ser baixado.", - "sourceErrRateUntil": "O GitHub está limitando as requisições até {1}, então não foi possível atualizar {0}." + "sourceErrRateUntil": "O GitHub está limitando as requisições até {1}, então não foi possível atualizar {0}.", + "reinstall": "Instalar {0} de novo…" }, "sizeUnits": { "B": "B", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index 4e0c7a6..a9c09e9 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -103,7 +103,8 @@ "modSourceRateLimitedUntil": "GitHub ограничивает запросы до {0}. Попробуйте после этого.", "modUrlRefusedHost": "Ссылка для загрузки вела не на github.com или не по https, поэтому лаунчер её отклонил. Ничего не загружено.", "modUrlRefusedForm": "Ссылка для загрузки записана не так, как пишется обычная ссылка загрузки GitHub, поэтому лаунчер её отклонил. Ничего не загружено.", - "modUrlRefusedAsset": "Ссылка для загрузки вела не на релиз этого мода в его репозитории GitHub, поэтому лаунчер её отклонил. Ничего не загружено." + "modUrlRefusedAsset": "Ссылка для загрузки вела не на релиз этого мода в его репозитории GitHub, поэтому лаунчер её отклонил. Ничего не загружено.", + "modStoreMissing": "Сохранённая лаунчером копия {0} пропала, поэтому её нельзя передать игре. Установите мод снова, чтобы играть с ним." }, "mods": { "title": "Моды", @@ -592,7 +593,8 @@ "homeModsOnlyOwn_other": "Без модов для {0}, но загрузятся {n} файла из своей папки", "indexUnavailableStatusShort": "Нет индекса (сервер: {0}): нет одобренных модов", "browseNoIndex": "Одобренные моды появятся здесь, когда удастся загрузить индекс модов Weft.", - "sourceErrRateUntil": "GitHub ограничивает запросы до {1}, поэтому {0} не удалось обновить." + "sourceErrRateUntil": "GitHub ограничивает запросы до {1}, поэтому {0} не удалось обновить.", + "reinstall": "Установить {0} снова…" }, "sizeUnits": { "B": "Б", diff --git a/src/mods/bindings/ErrorCode.ts b/src/mods/bindings/ErrorCode.ts index 705b7cc..239750c 100644 --- a/src/mods/bindings/ErrorCode.ts +++ b/src/mods/bindings/ErrorCode.ts @@ -4,4 +4,4 @@ * Every code an [`AppError`] can carry, across the whole app. On the wire a code is its * variant name; `src/i18n/errors.ts` turns it into a translation key. */ -export type ErrorCode = "Network" | "HttpStatus" | "Io" | "DiskFull" | "Serde" | "RateLimited" | "InvalidName" | "InvalidHeader" | "CacheCorrupted" | "AssetNotFound" | "MissingDigest" | "Forbidden" | "OfflineNoCache" | "Zip" | "CreateDir" | "Sha256Mismatch" | "HashMissing" | "AlreadyInstalled" | "NotInstalled" | "DownloadFailed" | "ExtractFailed" | "GameRunning" | "VersionInUse" | "InstallInProgress" | "AlreadyRunning" | "AnotherGameRunning" | "ExecutableNotFound" | "ProcessSpawnFailed" | "LogDirCreateFailed" | "GameFolderNotFound" | "OpenerError" | "IndexSignatureInvalid" | "IndexRollback" | "IndexInvalid" | "AbiUnavailable" | "ModNotInCatalog" | "ModIncompatible" | "ModNotForPlatform" | "ModRevoked" | "ModArchiveInvalid" | "ModFileChanged" | "ModNotInstalled" | "ModsBlocked" | "ModSourceInvalid" | "ModSourceNotFound" | "ModSourcesDamaged" | "ModSourceRateLimited" | "ModDownloadNotFound" | "ModTooLarge" | "ModIdReserved" | "ModChangedMeanwhile" | "ModCancelled" | "ModLockInvalid" | "ModUrlRefused" | "ModIconInvalid"; +export type ErrorCode = "Network" | "HttpStatus" | "Io" | "DiskFull" | "Serde" | "RateLimited" | "InvalidName" | "InvalidHeader" | "CacheCorrupted" | "AssetNotFound" | "MissingDigest" | "Forbidden" | "OfflineNoCache" | "Zip" | "CreateDir" | "Sha256Mismatch" | "HashMissing" | "AlreadyInstalled" | "NotInstalled" | "DownloadFailed" | "ExtractFailed" | "GameRunning" | "VersionInUse" | "InstallInProgress" | "AlreadyRunning" | "AnotherGameRunning" | "ExecutableNotFound" | "ProcessSpawnFailed" | "LogDirCreateFailed" | "GameFolderNotFound" | "OpenerError" | "IndexSignatureInvalid" | "IndexRollback" | "IndexInvalid" | "AbiUnavailable" | "ModNotInCatalog" | "ModIncompatible" | "ModNotForPlatform" | "ModRevoked" | "ModArchiveInvalid" | "ModFileChanged" | "ModNotInstalled" | "ModStoreMissing" | "ModsBlocked" | "ModSourceInvalid" | "ModSourceNotFound" | "ModSourcesDamaged" | "ModSourceRateLimited" | "ModDownloadNotFound" | "ModTooLarge" | "ModIdReserved" | "ModChangedMeanwhile" | "ModCancelled" | "ModLockInvalid" | "ModUrlRefused" | "ModIconInvalid"; diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index f924379..85ffdd5 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -140,7 +140,7 @@ export function Home({ onGoToVersions, onGoToMods, latestTag, selectedTag, onSel
      {/* Why Play is off, and the fixes, right under it. */} {effectiveTag && } - {effectiveTag && } + {effectiveTag && } {shownReadError && effectiveTag && (
      ); } + +/** + * Makes everything outside an open dialog inert: not clickable, not focusable, not in the + * accessibility tree, beyond what `aria-modal` asks of a screen reader. The polite live region + * stays, so a result is still said while the dialog is open. Returns what it changed. + */ +function inertOutside(dialog: HTMLElement | null): HTMLElement[] { + const made: HTMLElement[] = []; + for (let n = dialog; n && n.parentElement && n !== document.body; n = n.parentElement) { + for (const sibling of n.parentElement.children) { + if (sibling === n || !(sibling instanceof HTMLElement) || sibling.inert) continue; + if (sibling.matches("[aria-live], script") || sibling.querySelector("[aria-live]")) continue; + sibling.inert = true; + made.push(sibling); + } + } + return made; +} From 052a3393d37f4e1c72f0f6b5e13e72db07068223 Mon Sep 17 00:00:00 2001 From: Zorro909 Date: Thu, 5 Nov 2026 07:16:36 +0000 Subject: [PATCH 131/156] A very long installed name has the row's width, with the switch and Remove on the line under it: it wraps once, not into a narrow column --- e2e/scenarios.spec.ts | 11 +++++++++++ src/components/mods/InstalledList.tsx | 8 +++++++- src/style.css | 3 +++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/e2e/scenarios.spec.ts b/e2e/scenarios.spec.ts index bc09ef1..f6e3771 100644 --- a/e2e/scenarios.spec.ts +++ b/e2e/scenarios.spec.ts @@ -39,6 +39,17 @@ const openNotes = async (page: Page) => { }; const CHECKS: Record = { + long: { + installed: async (page) => { + // A very long name has the row's width, so it wraps once, not into a narrow column. + const rows = page.locator(".mod-row.long-name"); + expect(await rows.count()).toBeGreaterThan(0); + for (const name of await rows.locator(".mod-name").all()) { + const lines = await name.evaluate((e) => Math.round(e.getBoundingClientRect().height / parseFloat(getComputedStyle(e).lineHeight))); + expect(lines, "lines of a long name").toBeLessThanOrEqual(2); + } + }, + }, mods: { home: async (page, en) => { await expect(page.locator(".btn-play")).toBeDisabled(); diff --git a/src/components/mods/InstalledList.tsx b/src/components/mods/InstalledList.tsx index 646c56d..ca9757b 100644 --- a/src/components/mods/InstalledList.tsx +++ b/src/components/mods/InstalledList.tsx @@ -32,6 +32,9 @@ interface RowProps { onRemoved: () => void; } +/** Characters of a name that fit beside a row's switch and Remove in the smallest window. */ +const LONG_NAME = 28; + function ModRow({ tag, mod, report, onError, onUpdate, canUpdate, updateWhy, locked, onRemoving, onRemoved }: RowProps) { const announce = useAnnounce(); const { t, tm } = useI18n(); @@ -48,6 +51,9 @@ function ModRow({ tag, mod, report, onError, onUpdate, canUpdate, updateWhy, loc const disabled = working || locked; // A reason is read whole: it gets the row's width under the buttons, as many lines as it needs. const wide = meta.bad || meta.shadowed; + // A name longer than fits beside the switch and Remove takes the row's width, and they move to + // the line under it, so it wraps once rather than into a narrow column. + const longName = mod.name.length > LONG_NAME; const removal = useConfirmPress({ onAsk: () => announce(`remove-ask:${mod.id}:${Date.now()}`, t("mods.removeConfirmHint", mod.name)), @@ -90,7 +96,7 @@ function ModRow({ tag, mod, report, onError, onUpdate, canUpdate, updateWhy, loc }, [askOn]); const revoked = mod.status === "revoked"; - const rowClass = ["mod-row framed", mod.enabled ? "" : "is-off", meta.bad ? "has-problem" : "", wide ? "wide" : "", askOn ? "asking" : ""].join(" "); + const rowClass = ["mod-row framed", mod.enabled ? "" : "is-off", meta.bad ? "has-problem" : "", wide ? "wide" : "", longName ? "long-name" : "", askOn ? "asking" : ""].join(" "); return (
    • diff --git a/src/style.css b/src/style.css index 147ca9d..ebdebf5 100644 --- a/src/style.css +++ b/src/style.css @@ -426,6 +426,9 @@ button:disabled { cursor: not-allowed; } align-items: center; gap: 0 8px; padding: 4px 4px 4px 8px; } .mod-row.wide { grid-template-areas: "icon title actions" "icon meta meta"; } +/* A very long name has the row's width; the switch and Remove go to the line under it. */ +.mod-row.long-name { grid-template-areas: "icon title title" "icon meta actions"; } +.mod-row.long-name.wide { grid-template-areas: "icon title title" "icon meta meta" "icon . actions"; } /* Asked before a switch is turned on that would stop Play: the reason and the choice, under the row. */ .mod-ask { grid-column: 2 / -1; display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 4px 12px; margin-top: 4px; padding: 4px 8px; background: var(--ink); box-shadow: inset var(--px) 0 0 var(--r2); line-height: 24px; } .mod-ask-text { flex: 1 1 320px; min-width: 0; overflow-wrap: anywhere; color: var(--g5); } From 4f07ecc78387e1cbf4d79cc5b4b054ed5510deef Mon Sep 17 00:00:00 2001 From: Zorro909 Date: Thu, 5 Nov 2026 07:17:48 +0000 Subject: [PATCH 132/156] The Mods list and Browse grid sit on a near-opaque backing: the field's bright seams no longer show between rows and cards --- src/style.css | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/style.css b/src/style.css index ebdebf5..79e0bee 100644 --- a/src/style.css +++ b/src/style.css @@ -325,6 +325,8 @@ button:disabled { cursor: not-allowed; } .mods-page .icon-btn { width: 40px; padding: 0; display: grid; place-items: center; } .mods-panel { flex: 1 1 auto; min-height: 0; scroll-padding-bottom: calc(16px + var(--toast-room, 0px)); overflow-y: auto; overscroll-behavior: contain; padding-bottom: 16px; scrollbar-gutter: stable; margin-right: -8px; padding-right: 8px; } .mods-panel:focus { outline: none; } +/* Behind the rows and cards, a near-opaque backing: the field's bright seams don't show between them and compete with the text. */ +.mods-panel { background: #14161ef2; } /* Browse's filters stay on top as the cards scroll: a card brought into view by focus stops below them, not half under them. (Banners and toasts are outside the scroller.) */ .mods-panel:has(.filters) { scroll-padding-top: 64px; } From abb8fdc29cf5a740825c9b8d258023c62014164c Mon Sep 17 00:00:00 2001 From: Zorro909 Date: Thu, 5 Nov 2026 07:19:56 +0000 Subject: [PATCH 133/156] Home's instance card is short: Open folder sits on its title line (in view under a crash notice at 565px) and the details share one wrapping line; a crash's opened log line scrolls past four lines --- e2e/flows.spec.ts | 3 +++ src/pages/Home.tsx | 38 +++++++++++++++++++++++--------------- src/style.css | 8 ++++++++ 3 files changed, 34 insertions(+), 15 deletions(-) diff --git a/e2e/flows.spec.ts b/e2e/flows.spec.ts index cc56657..85b60b6 100644 --- a/e2e/flows.spec.ts +++ b/e2e/flows.spec.ts @@ -62,6 +62,9 @@ test.describe("launch safety", () => { await expect(notice.locator("button", { hasText: "Dismiss" })).toHaveCount(1); await expect(notice.locator("button").first()).toHaveText(c.fix); await expectWhole(page, `${c.kind} crash`); + // The game folder's button stays in view under the notice, at 565px. + const folder = await page.locator(".btn-open-folder").boundingBox(); + expect(folder!.y + folder!.height, "Open folder in view").toBeLessThanOrEqual(565); }); } diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 85ffdd5..b6fbe1f 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -42,6 +42,8 @@ export function Home({ onGoToVersions, onGoToMods, latestTag, selectedTag, onSel const launch = useLaunch(); const [rereading, setRereading] = useState(false); const blockersId = useId(); + const folderLabelId = useId(); + const folderButtonId = useId(); const sortedVersions = useMemo(() => [...installedVersions].sort((a, b) => compareTags(b.tag, a.tag)), [installedVersions]); @@ -164,27 +166,33 @@ export function Home({ onGoToVersions, onGoToMods, latestTag, selectedTag, onSel
      -
      + {/* The folder's button on the title's line: in view under a crash notice at 565px. */} +

      {t("instanceCardTitle")}

      + {selectedVersion && ( + <> + + {t("gameFolderTitle")} + + + + )}
      {selectedVersion ? ( - <> -
      - {t("versionLabel")} - {selectedVersion.tag} -
      -
      - {t("installedAtLabel")} - {new Date(selectedVersion.installed_at).toLocaleString(locale)} + // One line that wraps, so the card stays short. +
      +
      +
      {t("versionLabel")}
      +
      {selectedVersion.tag}
      -
      - {t("gameFolderTitle")} - +
      +
      {t("installedAtLabel")}
      +
      {new Date(selectedVersion.installed_at).toLocaleString(locale)}
      - +
      ) : (

      {t("selectVersionHint")}

      )} diff --git a/src/style.css b/src/style.css index 79e0bee..80b6721 100644 --- a/src/style.css +++ b/src/style.css @@ -245,6 +245,12 @@ button:disabled { cursor: not-allowed; } .detail-row { display: grid; grid-template-columns: 160px minmax(0, 1fr); gap: 24px; align-items: baseline; } .detail-row + .detail-row { margin-top: 4px; } .detail-label { color: var(--g5); } +/* Home's instance card: the folder's button on the title's line, the details on one line that wraps. */ +.instance-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; } +.instance-head .card-title { margin-bottom: 0; } +.instance-line { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 32px; margin: 0; } +.instance-pair { display: flex; align-items: center; gap: 12px; min-width: 0; } +.instance-pair dd { margin: 0; min-width: 0; overflow-wrap: anywhere; } .btn-open-folder { justify-self: start; margin-top: 4px; } .select-version-hint { color: var(--g5); } @@ -598,6 +604,8 @@ button:disabled { cursor: not-allowed; } /* Looking at the log and putting the notice away: links after the fixes, apart from them. */ .mod-failure-quiet { display: flex; align-items: center; gap: 8px; margin-left: auto; color: var(--g5); } .mods-page .mod-failure { max-width: none; margin: 0 0 8px; } +/* The log's own line, opened: at most four lines, scrolling inside, so the notice stays bounded. */ +.mod-failure .error-details[open] code { max-height: 96px; overflow-y: auto; overscroll-behavior: contain; } .mods-line .truncate { min-width: 0; max-width: 520px; } .blockers { display: grid; gap: 8px; justify-content: stretch; } /* Home: why Play is off, as big as the title over it, and the fixes right under Play. */ From 0bacfe3f46716682baa7292b08cbb8a9d803dfe5 Mon Sep 17 00:00:00 2001 From: Zorro909 Date: Thu, 5 Nov 2026 17:21:45 +0000 Subject: [PATCH 134/156] The Mods page's Installed and Browse tabs keep their size when selected: the count is plain dark-amber text on the selected tab, not a padded amber block --- e2e/flows.spec.ts | 19 +++++++++++++++++++ src/style.css | 6 ++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/e2e/flows.spec.ts b/e2e/flows.spec.ts index 85b60b6..d03e831 100644 --- a/e2e/flows.spec.ts +++ b/e2e/flows.spec.ts @@ -867,6 +867,25 @@ test.describe("contrast", () => { expect(await lowContrast(page)).toEqual([]); }); + test("the Installed and Browse tabs keep their place and size when selected; the count is text, not a block", async ({ page }) => { + await open(page, "scenario=mods"); + await goToTab(page, MODS_TAB); + const boxes = () => + page.locator(".switch [role=tab]").evaluateAll((tabs) => + tabs.map((t) => { + const r = t.getBoundingClientRect(); + return [r.x, r.y, r.width, r.height]; + }), + ); + const installed = await boxes(); + const count = page.locator(".switch [role=tab]").first().locator(".count"); + expect(await count.evaluate((e) => getComputedStyle(e).backgroundColor)).toBe("rgba(0, 0, 0, 0)"); + expect(await lowContrast(page, 4.5, 4.5, ".switch")).toEqual([]); + await browse(page); + expect(await boxes()).toEqual(installed); + expect(await lowContrast(page, 4.5, 4.5, ".switch")).toEqual([]); + }); + test("the contrast check composites opacity and measures large text", async ({ page }) => { await open(page, "scenario=mods"); await goToTab(page, MODS_TAB); diff --git a/src/style.css b/src/style.css index 80b6721..28786d6 100644 --- a/src/style.css +++ b/src/style.css @@ -341,8 +341,10 @@ button:disabled { cursor: not-allowed; } .switch button { height: 32px; padding: 2px 12px; background: var(--g2); box-shadow: inset 0 calc(-1 * var(--px)) 0 var(--g1); } .switch button:hover:not(.on) { background: var(--g3); } .switch button.on { background: var(--pale); color: var(--ink); box-shadow: inset 0 calc(-1 * var(--px)) 0 var(--g5); } -.switch .count { margin-left: 8px; color: var(--a2); } -.switch button.on .count { color: var(--ink); background: var(--a2); padding: 0 6px; } +/* The count is only coloured text, selected or not: the same box both ways, so neither tab moves + when the selection changes. On the pale selected tab it is a darker amber (4.7:1). */ +.switch .count { margin-left: 8px; color: var(--a2); font-variant-numeric: tabular-nums; } +.switch button.on .count { color: #8f5418; } .banners { display: grid; grid-template-columns: minmax(0, 1fr); gap: 8px; margin-bottom: 8px; } .banners:empty { display: none; } From a79693308f2b965b7ac7bfa553278bc419372937 Mon Sep 17 00:00:00 2001 From: Zorro909 Date: Thu, 5 Nov 2026 17:31:53 +0000 Subject: [PATCH 135/156] The selected Mods view is a dark tab with an amber underline, its count in bright amber (10.5:1): amber on the pale tab could not be both amber and readable --- src/style.css | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/style.css b/src/style.css index 28786d6..218ffef 100644 --- a/src/style.css +++ b/src/style.css @@ -340,11 +340,13 @@ button:disabled { cursor: not-allowed; } .mods-panel .filters { position: sticky; top: 0; z-index: 2; padding-bottom: 8px; margin-bottom: 0; background: linear-gradient(var(--seam) 0 calc(100% - 8px), transparent 0); } .switch button { height: 32px; padding: 2px 12px; background: var(--g2); box-shadow: inset 0 calc(-1 * var(--px)) 0 var(--g1); } .switch button:hover:not(.on) { background: var(--g3); } -.switch button.on { background: var(--pale); color: var(--ink); box-shadow: inset 0 calc(-1 * var(--px)) 0 var(--g5); } +/* The selected view is the dark tab with the amber underline. Amber on a pale tab can't be both + amber and readable; on the dark one the count stays bright amber (10.5:1). */ +.switch button.on { background: var(--deep); color: var(--pale); box-shadow: inset 0 calc(-1 * var(--px)) 0 var(--a2); } /* The count is only coloured text, selected or not: the same box both ways, so neither tab moves - when the selection changes. On the pale selected tab it is a darker amber (4.7:1). */ + when the selection changes. */ .switch .count { margin-left: 8px; color: var(--a2); font-variant-numeric: tabular-nums; } -.switch button.on .count { color: #8f5418; } +.switch button.on .count { color: var(--a3); } .banners { display: grid; grid-template-columns: minmax(0, 1fr); gap: 8px; margin-bottom: 8px; } .banners:empty { display: none; } From ec214b458a1da049563e3e3869db4ebb9c79dcc1 Mon Sep 17 00:00:00 2001 From: Zorro909 Date: Fri, 6 Nov 2026 23:34:21 +0000 Subject: [PATCH 136/156] INDEX_FORMAT states each rule once: field values in the table, validity in the rules, runtime behaviour grouped; size limits are a table; the publishing walkthrough and noMods table move to AUTHORING --- docs/mod-manager/INDEX_FORMAT.md | 196 ++++++++++++++++--------------- 1 file changed, 99 insertions(+), 97 deletions(-) diff --git a/docs/mod-manager/INDEX_FORMAT.md b/docs/mod-manager/INDEX_FORMAT.md index a517ef6..1f40b0a 100644 --- a/docs/mod-manager/INDEX_FORMAT.md +++ b/docs/mod-manager/INDEX_FORMAT.md @@ -1,8 +1,8 @@ # Mod index format, v1 -This file is the contract between Weft Launcher and [`Zorro909/weft-mods-index`](https://github.com/Zorro909/weft-mods-index). The launcher accepts exactly this format. A change that breaks it needs a new `schema` number and a new URL path (`/v2/`). +This file is the contract between Weft Launcher and [`Zorro909/weft-mods-index`](https://github.com/Zorro909/weft-mods-index). The launcher accepts exactly this format. A change that breaks it needs a new `schema` number and a new URL path (`/v2/`). Mod authors: the publishing guide is [AUTHORING.md](AUTHORING.md). -The JSON examples below are complete and valid: a test in the launcher (`src-tauri/src/mods/doc_tests.rs`) parses each one with the launcher's own code and fails if any entry would be dropped. +The examples below are complete and valid; `src-tauri/src/mods/doc_tests.rs` checks them against the launcher's parser and the schemas. ## Where it lives @@ -11,16 +11,19 @@ The JSON examples below are complete and valid: a test in the launcher (`src-tau | Index | `https://zorro909.github.io/weft-mods-index/v1/index.json` | | Signature | `https://zorro909.github.io/weft-mods-index/v1/index.json.minisig` | -- The signature is a minisign signature of the exact bytes of `index.json`, **prehashed** (`minisign -S -H`, the default of current minisign). Legacy, non-prehashed signatures are refused. +- The signature is a **prehashed** minisign signature of the exact bytes of `index.json` (`minisign -S -H`, the default of current minisign). Legacy signatures are refused. - The launcher embeds the public key (`keys/index.pub` in the index repo; `INDEX_PUBLIC_KEY` in `src-tauri/src/mods/index/signature.rs`). -- An index whose signature doesn't verify is rejected. The launcher then keeps using its last verified copy. -- The signature's trusted comment should carry `timestamp:`, as `minisign -S` writes by default (for example `timestamp:1793819114 file:index.json`). The key signs the trusted comment too, so this is when the index was signed; the launcher reports it as `signedAt`. Without it, the launcher judges the index's age by `generated` alone. -- The index repository re-signs the index weekly, even when nothing changed, so that its signing time stays recent (see [Stale](#launcher-behaviour)). -- **Key rotation.** The launcher trusts exactly one key, built in. To rotate it: ship a launcher release that embeds the new key, keep signing with the old key until that release is the one players run, then switch the index to the new key. An index signed with a key the launcher doesn't have is refused like any bad signature, and the last verified copy stays in use; the rollback floor is kept across the switch. +- An index whose signature doesn't verify is refused, and the launcher keeps its last verified copy. +- The trusted comment carries `timestamp:`, as `minisign -S` writes by default (`timestamp:1793819114 file:index.json`). The launcher reports it as `signedAt`. If it is missing, staleness and rollback use `generated` alone. +- The index repository re-signs the index weekly, even when nothing changed, so it never goes [stale](#freshness-and-cache). + +### Key rotation + +The launcher trusts exactly one built-in key. To rotate it: ship a launcher release that embeds the new key, keep signing with the old key until players run that release, then switch the index to the new key. An index signed with an unknown key is refused like any bad signature. ## `index.json` -JSON Schemas for both files, for an editor or a publishing CI: [`schema/index.schema.json`](schema/index.schema.json) and [`schema/mod.schema.json`](schema/mod.schema.json). They say what a file must hold for every entry in it to be used; the launcher's tests hold the examples below to them, and each `pattern` to the check the launcher runs. Rules that compare one field with another, or with GitHub, are only in the text. +JSON Schemas: [`schema/index.schema.json`](schema/index.schema.json) and [`schema/mod.schema.json`](schema/mod.schema.json). Rules that compare fields with each other or with GitHub are only in this file. ```json { @@ -74,61 +77,64 @@ JSON Schemas for both files, for an editor or a publishing CI: [`schema/index.sc ### Fields -"Default" is what the launcher uses when the field is missing. A field without a default is required: an entry that lacks it is skipped (or, for `schema` and `generated`, the whole index is refused). +"Default" is what the launcher uses when the field is missing. A field marked required has none. -| Field | Default | Meaning | +| Field | Default | Value | |---|---|---| -| `schema` | required | Always `1`. | -| `generated` | required | RFC 3339 time the index was built. With the signing time, it decides whether an index is newer (see [Launcher behaviour](#launcher-behaviour)). | -| `weft[]` | `[]` | The ABI hash per Weft release, so the launcher doesn't have to compute it. Each entry needs `tag`, `commit` and `abi`. | +| `schema` | required | `1`. | +| `generated` | required | RFC 3339 time the index was built. | +| `weft[]` | `[]` | ABI hash per Weft release, so the launcher doesn't compute it: `tag`, `commit` (full hash) and `abi`, all required. | | `mods[]` | `[]` | The mods. | -| `mods[].id` | required | `[a-z0-9_]{1,64}`, equal to the crate name and the zip stem. `base` is reserved. | +| `mods[].id` | required | `[a-z0-9_]{1,64}`, not `base`. Equal to the crate name and the zip stem. | | `mods[].name` | required | Display name. | | `mods[].summary` | `""` | One line. The launcher shows the first line, cut to 140 characters. | | `mods[].authors` | `[]` | Display names. | -| `mods[].license`, `homepage`, `icon` | `null` | `homepage` is an absolute `https://` URL, or it is dropped. `icon` is an `https://` URL on `github.com`, `raw.githubusercontent.com`, `user-images.githubusercontent.com` or a `.github.io` Pages site, with no port or user name, or it is dropped: the launcher never asks another host for an icon, which would tell that host the player's address. Redirects are followed only to GitHub hosts. The icon is a PNG of at most 512 KB, or it isn't shown. The same rule holds for a source's `.mod.json`. | -| `versions[]` | `[]` | Newest first (the launcher sorts again anyway). A mod without a valid version is left out. | -| `version` | required | Semver: `MAJOR.MINOR.PATCH`, optionally `-prerelease` and `+build`, e.g. `0.2.1-rc.1`. | +| `mods[].license` | `null` | License, for display. | +| `mods[].homepage` | `null` | Absolute `https://` URL; anything else is dropped. | +| `mods[].icon` | `null` | PNG on a GitHub host; see [Icons](#icons). | +| `versions[]` | `[]` | Newest first (the launcher sorts again). A mod without a valid version is left out. | +| `version` | required | Semver: `MAJOR.MINOR.PATCH`, optional `-prerelease` and `+build`. | | `channel` | required | `release`, `prerelease` or `experimental`. | | `tag` | required | The GitHub release tag of this build. | -| `published` | `""` | RFC 3339, when given. | -| `source` | required | `repo` (`owner/name`) and `commit` (the full hash) the build was made from. Both are required. | -| `weft` | required | `abi` (required, 16 hex), and the Weft `tag` (default `null`) and `commit` (default `""`, else a full hash) it was built against. | -| `deps[]` | `[]` | `{ "id": … }` of every mod it needs. `base` ships with Weft and is never installed. | -| `platforms[]` | `[]` | Which libraries the zip has (table below). An unknown platform string is ignored. | +| `published` | `""` | RFC 3339 when not empty. | +| `source` | required | `repo` (`owner/name`) and `commit` (full hash) the build was made from, both required. | +| `weft` | required | `abi` (required), plus the Weft `tag` (default `null`) and `commit` (default `""`, else a full hash) it was built against. | +| `deps[]` | `[]` | `{ "id": … }` of every mod it needs, each a valid mod id. `base` ships with Weft. | +| `platforms[]` | `[]` | Which libraries the zip has ([Platforms](#platforms)). Unknown strings are ignored. | | `asset` | required | `name`, `url`, `size` and `sha256` of the zip, all required. | -| `provenance` | `null` | The attestation the index CI checked with `gh attestation verify`. `kind` is required; `repo`, `workflow`, `ref` and `verified_at` default to `null`. See [Provenance](#provenance) for what the launcher shows. | -| `reviewed` | required in the index | `by` and `at`: who reviewed exactly these bytes, and when. Not read from sources. | -| `revoked[]` | `[]` | Builds that must never be used again. Must be a list: anything else refuses the whole index. | +| `provenance` | `null` | The attestation the index CI checked. `kind` is required; `repo`, `workflow`, `ref` and `verified_at` default to `null`. See [Provenance](#provenance). | +| `reviewed` | required in the index | `by` (not empty) and `at`: who reviewed exactly these bytes, and when. Never read from sources. | +| `revoked[]` | `[]` | Builds that must never be used again. | | `revoked[].sha256` | required | The build's hash, in any case. | -| `revoked[].reason` | none (`null`) | Why, for the player. Also none when it is empty or not a string; the launcher then says "No reason given" in the player's language. | -| `revoked[].at` | `null` | When. Also `null` when it is not a string. | +| `revoked[].reason` | none (`null`) | Why, for the player. Empty or not a string also means none; the launcher then says "No reason given". | +| `revoked[].at` | `null` | When. Not a string also means `null`. | + +Unknown fields are ignored. ### Rules the launcher enforces -A version that breaks any of these is left out. The rest of the index is still used. +The whole index is refused, and the last verified copy kept, when `schema` isn't `1`, `generated` isn't RFC 3339, or `weft`, `mods` or `revoked` isn't a list. -- `id` matches `[a-z0-9_]{1,64}` and is not `base`. -- `version` is semver (see the table). -- `source.repo` is `owner/name`, each part 1–100 of `A–Z a–z 0–9 - _ .`, and neither `.` nor `..`. -- `source.commit` is a full commit hash: 40 lowercase hex characters, or 64 in a SHA-256 repository. So is `weft.commit` when it isn't empty, and every `weft[].commit` (an entry with any other commit is dropped). -- `published` is RFC 3339 when it isn't empty. +Otherwise a mod, version or `weft[]` entry that fails to parse or breaks a rule is skipped on its own: + +- `asset.sha256`, commits and `abi` are lowercase hex. A full commit is 40 characters, or 64 in a SHA-256 repository. `asset.sha256` is 64. `abi` is 16: the FNV-1a-64 hash of `crates/abi/src/lib.rs` at that commit. - `tag`, `weft.tag` and `weft[].tag` are 1–100 of `A–Z a–z 0–9 . - _`, and don't start with `.`. +- `source.repo` is `owner/name`, each part 1–100 of `A–Z a–z 0–9 - _ .`, and neither `.` nor `..`. - `asset.name` is `.zip`. -- `asset.url` is exactly `https://github.com///releases/download//.zip`, where `/` is `source.repo` (compared without case, as GitHub does) and `` is `tag`. See [the URL rule](#the-asset-url-rule). -- `asset.sha256` is 64 lowercase hex characters. -- `weft.abi` and `weft[].abi` are 16 lowercase hex characters: the FNV-1a-64 hash of `crates/abi/src/lib.rs` at that commit. -- Every `deps[].id` is a valid mod id. -- `reviewed` is present, with a non-empty `by`. In the index, Reviewed means someone reviewed it. -- **Revocations fail closed.** A `revoked[]` entry is dropped only when its `sha256` can't be a SHA-256 at all (64 hex characters, in any case, after trimming), and that is logged. Everything else about it may be wrong or missing and it still revokes: the hash is lowercased and compared without case, and `reason` and `at` take their defaults. -- Unknown fields are ignored. Missing fields take the defaults in the table. -- A mod whose `sha256` appears in `revoked` is never installed. If it is already installed, it is turned off, and nothing that needs it can turn it back on. The launcher records the revocation, with its reason, in the version's `mods.lock.json`, so the mod stays revoked even if the index can't be loaded later, until the build is replaced or removed. +- `asset.url` follows [the asset URL rule](#the-asset-url-rule). +- In the index, `reviewed` is required: Reviewed means someone reviewed it. + +**Revocations fail closed.** A `revoked[]` entry is dropped only when its `sha256` can't be a SHA-256 at all (64 hex characters, any case, after trimming), and that is logged. Every other problem leaves it in force: the hash is lowercased and compared without case, and `reason` and `at` take their defaults. A revoked build is never installed; an installed one is turned off and recorded in the version's `mods.lock.json`, where it stays revoked until it is replaced or removed. + +Not enforced: that `summary` is one short line, that `versions[]` is sorted, and that `published` is in the past. -Not enforced: that `summary` is one short line (it is cut instead), that `versions[]` is sorted, and that `published` is in the past. +### Icons + +The same rule holds for `.mod.json`. An `icon` is kept only when it is an `https://` URL on `github.com`, `raw.githubusercontent.com`, `user-images.githubusercontent.com` or a `.github.io` site, with no port or user name: the launcher never asks another host, which would learn the player's address. It must be a PNG of at most 512 KB, or it isn't shown. ## `.mod.json`, for Unreviewed sources -A GitHub repo the player adds as a source publishes `.mod.json` next to `.zip` in each release. It holds the mod-level fields plus one version's fields at the top level, with the same defaults as in the index; `schema` is required too: +A GitHub repo the player adds as a source publishes `.mod.json` next to `.zip` in each release. It holds the mod-level fields plus one version's fields at the top level, with the same defaults; `schema` is required too: ```json { @@ -158,90 +164,86 @@ A GitHub repo the player adds as a source publishes `.mod.json` next to `.mod.json` only when all of these agree with GitHub's own record of the same release (`sources.rs`, `accept`): +The launcher reads the repo's releases (drafts are ignored) and accepts a `.mod.json` only when it agrees with GitHub's record of the same release (`sources.rs`, `accept`): 1. `schema` is `1`. -2. The release also has an asset `.zip`, `id` equals its stem, and `id` is a mod id (`[a-z0-9_]{1,64}`, not `base`). +2. The release also has an asset `.zip`, `id` equals its stem, and `id` is a valid mod id. 3. `tag` equals the release's tag. 4. `source.repo` equals the source repo (compared without case). -5. `asset.name` equals the zip's name. -6. `asset.url` equals the zip's `browser_download_url`. -7. `asset.size` equals the zip's size. -8. GitHub reports a `digest` for the zip, and `asset.sha256` equals it. A release without a digest is skipped. -9. Every [rule an index entry must follow](#rules-the-launcher-enforces) except `reviewed`. +5. `asset.name`, `asset.url` and `asset.size` equal the zip's name, `browser_download_url` and size. +6. GitHub reports a `digest` for the zip, and `asset.sha256` equals it. +7. Every [rule the launcher enforces](#rules-the-launcher-enforces) except `reviewed`. Then: -- If GitHub marks the release a prerelease, a `channel` of `release` becomes `prerelease`. -- The `published` time comes from GitHub when it has one. -- `reviewed` is never taken from a source: such builds are always **Unreviewed**. -- `provenance` is passed on as the publisher's claim (`buildRecord: "claimed"`), never as a record. Only provenance recorded in the signed index can be one (see [Provenance](#provenance)). -- The downloaded bytes must hash to `asset.sha256`, as for the index. -- Draft releases are ignored. -A release may carry several mods: each `.mod.json` with its own `.zip` is read on its own. +- If GitHub marks the release a prerelease, a `channel` of `release` becomes `prerelease`. +- `published` comes from GitHub when it has one. +- `reviewed` is never read: source builds are always **Unreviewed**. +- `provenance` is the publisher's claim (`buildRecord: "claimed"`), never a record. -Some fields of a `.mod.json` are only the publisher's word, and the launcher can't check them: `weft.abi` (the build is matched on it; the game has the last word when it loads the library), `source.commit`, `deps`, and `provenance`. The UI shows them as claims. +The launcher can't check `weft.abi`, `source.commit`, `deps` or `provenance` of a source; the UI shows them as claims. The game has the last word on the ABI when it loads the library. -When several releases publish the same mod: +A release may carry several mods: each `.mod.json` with its `.zip` is read on its own. Releases are read newest first: -- They are read newest release first, as GitHub lists them. The mod's name, summary, icon and other mod-level fields come from the newest release that passes. -- A version string, or a zip `sha256`, that a newer release already published is skipped in older releases. +- The mod-level fields (name, summary, icon …) come from the newest release that passes. +- A version string or zip `sha256` that a newer release already published is skipped in older releases. -A source mod is its own catalogue entry (`source::`), even when the index has a mod with the same `id` (`index:`). Dependencies are looked up in the index first, then in the same source. A source build whose `sha256` is also in the index shows once, as Reviewed. +A source mod is listed apart from an index mod with the same `id`. Dependencies resolve in the index first, then in the same source. A source build whose `sha256` is also in the index shows once, as Reviewed. ## Provenance -The launcher never verifies an attestation itself. The index CI runs `gh attestation verify` when it adds a version, and records what it checked in `provenance`; the signature over the index is what makes that record the index's word. The launcher shows it as the index's record, in those words ("The signed index records: built by GitHub Actions in `repo` @ `commit`, checked by `reviewed.by` on `verified_at`"), and only when the record is whole: +The launcher never verifies an attestation. The index CI runs `gh attestation verify` when it adds a version and records the result in `provenance`; the index signature makes that record the index's word. The launcher shows it as the index's record, and only when it is complete: | `buildRecord` | When | Shown as | |---|---|---| -| `recorded` | In the signed index, `kind` is `github-attestation`, `repo` is `owner/name`, `workflow` is `.github/workflows/.yml` (or `.yaml`), and `verified_at` is RFC 3339. `source.commit` is a full hash already (a rule above). `ref` is optional. | ✓, worded as the index's record | +| `recorded` | In the signed index, `kind` is `github-attestation`, `repo` is `owner/name`, `workflow` is `.github/workflows/.yml` (or `.yaml`), and `verified_at` is RFC 3339. `ref` is optional. | ✓, worded as the index's record | | `incomplete` | In the signed index, but any of the above is missing or malformed, or `kind` is another one. | ? (unknown): "an incomplete build record" | | `claimed` | From a source's `.mod.json`, whatever it says. | ? (unknown): "the repo says" | | `none` | No `provenance`. | ? (unknown): "No build record" | -An incomplete record doesn't drop the version: the review still stands, and only the build row is unknown. The rule is `catalog::build_record` in the backend, and the shared cases in `src/dev/fixtures/rules.json` (`buildRecords`) hold the dev mock to it. +An incomplete record doesn't drop the version: the review stands, and only the build row is unknown (`catalog::build_record`). ## Publishing from your own repo -The path for mod authors, from a first release to the signed index, is [AUTHORING.md](AUTHORING.md). +The steps are in [AUTHORING.md](AUTHORING.md). Why a source shows no mods: [AUTHORING, When a source shows no mods](AUTHORING.md#when-a-source-shows-no-mods). -A player can add any GitHub repo as a source (Mods → Sources). Its mods show up as **Unreviewed**. To publish one: +## Launcher behaviour -1. Build the mod and zip it as `.zip`, where `` is the mod id (`[a-z0-9_]{1,64}`, not `base`). -2. Write `.mod.json` as [above](#idmodjson-for-unreviewed-sources). `tag` is the release's tag, `source.repo` is your repo, and `asset` describes the zip exactly: its name, its `browser_download_url`, its size and its SHA-256. -3. Make a GitHub release (not a draft) with both files as assets. GitHub records a SHA-256 digest for each asset; the launcher compares `asset.sha256` with it, and skips a zip GitHub has no digest for. -4. Bump the version, and make a new release, for every new build. If two releases share a version or a zip hash, the newest release wins and the older release's copy is skipped. +### Size limits -When a source shows no mods, the Sources dialog says why. It names the reason that got furthest across all releases (`SourceStatus.noMods`): +| What | Limit | +|---|---| +| `index.json`, `index.json.minisig` | 8 MB each | +| `.mod.json` | 64 KB; a longer one is ignored | +| Icon | 512 KB, PNG | +| Mod zip | 512 MB, and at most `asset.size` + 1 KB | +| Zip entries | 20,000 | +| Zip unpacked | 1 GB | +| Compression ratio | 200:1 for any entry over 1 MB | -| Reason | Means | -| --- | --- | -| `noReleases` | The repo has no published releases. | -| `noModFiles` | No release has an `.zip` together with its `.mod.json`. | -| `unreadable` | An `.mod.json` couldn't be read as JSON, or isn't a description. | -| `noDigest` | GitHub has no SHA-256 digest for the zip, so nothing could be checked. | -| `disagrees` | The description doesn't match GitHub's record of the release: tag, repo, asset name, URL, size or hash, or the id isn't the zip's. | -| `invalid` | It matches, but breaks a [rule every entry follows](#rules-the-launcher-enforces). | +Before a zip is stored, the launcher inflates every entry once, without writing it to disk. It refuses the zip if an entry's real size or checksum differs from its header, if an entry won't inflate, or if it is encrypted. -## Launcher behaviour +### Downloads + +- Redirects are followed only over `https`, to `github.com`, `githubusercontent.com`, `github.io` and their subdomains. +- Every file is hashed when it enters the store and must equal `asset.sha256`. Its zip contents and its library for this system are checked then too, even when the store already has that hash. +- A zip may hold only `.dll`, `lib.so`, `lib.dylib` and files under `assets/`. Other folders, `..`, absolute paths, `\` in names and symbolic links are refused. The library for the player's system must be there. +- A download checks [the asset URL rule](#the-asset-url-rule) again right before it starts. + +### Freshness and cache + +- **Rollback refusal.** An index's age is the pair (`generated`, signing time), compared by `generated` first; with equal `generated`, the later signing time is newer, and a signature without a time is older than one with it. A fetched index older than the newest pair ever accepted is refused with `IndexRollback`, and the launcher keeps its copy. That pair is kept in `cache/mod-index.state.json`, so deleting the cached index doesn't reset it. The same index fetched again is accepted. +- **Dated in the future.** An index whose `generated` or signing time is more than a day ahead of the computer's clock is refused with `IndexInvalid`. +- **Stale.** An index signed more than 30 days ago (by `generated` when there is no signing time) has freshness `stale`. It is still used; the launcher says revocations since then may be missing. +- **Cache.** The launcher keeps the newest two verified generations, each index with its signature, and verifies and parses the cached copy on every read. If the newest doesn't verify, the previous one is used. Offline, the cache is used with freshness `cached` (or `stale`). The index is fetched only when the catalogue is refreshed; Play never waits for it. If saving a fetched index fails, it is used until the launcher closes and reported as `cacheError`. + +### Duplicates + +The first entry wins. Two `mods[]` with the same `id` both stay listed, but lookups by id (dependencies, updates) use the first. Two versions with the same `asset.sha256` resolve to the first one. The index CI should refuse both. + +### Sources -What the launcher does with all of this, beyond the field rules: - -- **Size limits.** The index and its signature are at most 8 MB each. A `.mod.json` is read up to 64 KB, and a longer one is ignored. An icon is read up to 512 KB and must be a PNG. A mod zip is at most 512 MB (and at most 1 KB more than its `asset.size`), at most 20,000 entries, at most 1 GB unpacked, and no entry over 1 MB may compress better than 200:1. These limits are first checked against the sizes the zip's central directory declares, a fast refusal. Then, before the zip enters the store, every entry is inflated once into nothing and counted (`archive.rs`, `inflate_all`): an entry that inflates to more than it declares, or to less, whose checksum fails, that doesn't inflate, or that is encrypted refuses the zip. So the declared sizes the limits were checked against are what the game unpacks too. Nothing is written to disk and no library is loaded. -- **Redirects.** Every download follows redirects only over `https`, and only to `github.com`, `githubusercontent.com`, `github.io` and their subdomains. -- **The bytes.** Every file is hashed again when it enters the launcher's store and must equal `asset.sha256`; its zip and its library for this system are checked then, also when the store already had a file with that hash. -- **The zip itself.** Only `.dll`, `lib.so`, `lib.dylib` and files under `assets/` are allowed. Folders elsewhere, `..`, absolute paths, `\` in names and symbolic links are refused. The library for the player's system must be there. -- **Rollback refusal.** An index's age is the pair (`generated`, signing time). Pairs compare by `generated` first; with the same `generated`, the later signing time is newer, and a signature without a time is older than one with it. A fetched index older than the newest pair ever accepted is refused: the launcher keeps the copy it has and reports `IndexRollback`. That pair is kept in `cache/mod-index.state.json`, apart from the cached index, so deleting the cached index doesn't reset it. The same index fetched again is accepted. -- **Dated in the future.** An index whose `generated` or signing time is more than a day ahead of the computer's clock is refused with `IndexInvalid`, since it would lock out every honest index until then. -- **Stale.** An index that verified but was signed more than 30 days ago (by `generated`, without a signing time) has freshness `stale`. Its entries are still used; the launcher says revocations since then may be missing. A cached copy shown because a refresh failed keeps freshness `cached`, with the error. -- **The cache.** Each verified index and its signature are stored together as one generation, and the launcher keeps the newest two. The signature is checked again on every read. If the newest pair doesn't verify, for example after a crash while writing it, the previous generation is used. Offline, the cached index is used, with freshness `cached` (or `stale`). The launcher only fetches the index when the catalogue is refreshed; Play never waits on it. Generations are written and pruned under one lock, and an index that finishes fetching after a newer one was saved is not saved over it. If saving a fetched index fails (a full disk, say), it is still used until the launcher closes, and the index status carries the failure as `cacheError`: the Mods tab and Sources say it won't be there after a restart, when the previous saved copy, if any, is used instead. -- **Skipping bad entries.** A mod, version or `weft` entry that doesn't parse (for example an unknown `channel`), or that breaks a rule above, is skipped on its own. The rest of the index is used. An unknown platform string is ignored. `revoked` entries are the exception: see the rule above. -- **Duplicates.** The first entry wins. Two `mods[]` with the same `id` both stay in the list, but lookups by id (dependencies, updates) use the first; two versions with the same `asset.sha256` resolve to the first one listed. The index CI should refuse both. -- **Read again, every time.** The cached index is verified and parsed with these rules on every read, and so is each source's cached answer (`sources.rs`, `revalidate`), not only when they were fetched. A download checks [the URL rule](#the-asset-url-rule) once more right before it starts, whatever the catalogue says. -- **The URL rule.** See below. -- **Reviewed needs a review.** An index version without `reviewed` is dropped. -- **Sources.** The newest 500 releases of a source are read, in 5 pages of 100. When GitHub can't be reached or refuses (rate limit, server error), the last good answer is shown with the error. The catalogue reports both. +The newest 500 releases of a source are read. Each source's cached answer is checked again on every read. When GitHub can't be reached or refuses (rate limit, server error), the last good answer is shown with the error. ### The asset URL rule From f9023baf9ea694e0c48655edc9b84384d1439137 Mon Sep 17 00:00:00 2001 From: Zorro909 Date: Fri, 6 Nov 2026 23:34:21 +0000 Subject: [PATCH 137/156] AUTHORING is the one publishing guide: it gains the noMods table, the full tag rule and the ABI hash, and says when a revocation takes effect --- docs/mod-manager/AUTHORING.md | 54 +++++++++++++++++++++-------------- 1 file changed, 33 insertions(+), 21 deletions(-) diff --git a/docs/mod-manager/AUTHORING.md b/docs/mod-manager/AUTHORING.md index d284440..2138e46 100644 --- a/docs/mod-manager/AUTHORING.md +++ b/docs/mod-manager/AUTHORING.md @@ -6,9 +6,8 @@ rules are in [INDEX_FORMAT.md](INDEX_FORMAT.md); this page is the path through t ## 1. The zip -One `.zip` per build, where `` is your mod id: `[a-z0-9_]{1,64}`, not `base`, and the -crate name the engine expects (the engine refuses a library whose crate isn't named like its zip). -Inside, nothing but: +One `.zip` per build, where `` is your mod id: `[a-z0-9_]{1,64}`, not `base`, and equal to +your crate name (the engine refuses a mismatch). Inside, nothing but: ```text .dll Windows @@ -18,19 +17,20 @@ assets/… textures, lang files, and assets/textures/gui/icon.png ``` No other files at the top, no folders above these, no `..`, absolute paths or links. Ship the -libraries for the platforms you build, and list them in `platforms`. The launcher checks the zip -before it stores it, inflating every entry once to count it (INDEX_FORMAT, "Size limits"): at most -512 MB, 20,000 entries and 1 GB unpacked. +libraries for the platforms you build, and list them in `platforms`. Limits: 512 MB, 20,000 entries +and 1 GB unpacked ([all limits](INDEX_FORMAT.md#size-limits)). ## 2. The release -- **Tag** each build `-v`, for example `lanterns-v1.2.0`. Any tag of - `[A-Za-z0-9._-]` works; that one keeps several mods in one repo apart. +- **Tag** each build `-v`, for example `lanterns-v1.2.0`, to keep several mods in one + repo apart. Any tag works if it is 1–100 letters, digits, `.`, `_` or `-` and doesn't start with + `.`. - **Version** is semver (`1.2.0`, `1.3.0-beta.1`). A prerelease on GitHub is a prerelease in the launcher too. - Make the release (not a draft) with `.zip` and `.mod.json` as its assets. GitHub records a SHA-256 digest for each asset; a zip without one is skipped. -- A new build is a new version and a new release, every time. +- A new build is a new version and a new release, every time. If two releases share a version or + a zip hash, the older one's copy is skipped. ## 3. `.mod.json` @@ -40,20 +40,32 @@ Describe the build exactly as GitHub has it: the example and every rule are in - `asset.url` is the zip's `browser_download_url`, character for character, and `asset.size` and `asset.sha256` are the zip's. -- `weft.abi` is the mod interface of the Weft version you built against: 16 hex digits, the hash - of that version's `crates/abi/src/lib.rs`. Players get your build only on Weft versions with the - same ABI. +- `weft.abi` is the FNV-1a-64 hash of the raw bytes of `crates/abi/src/lib.rs` at the Weft commit + you built against, as 16 lowercase hex digits. Players get your build only on Weft versions with + the same ABI. - `deps` lists every mod yours needs to load, by id. - `provenance` from your own repo is shown as your claim, never as a record: leave it `null`, or describe your attestation (step 4). -Then add your repo in the launcher (Mods → Sources). When a source shows no mods, the Sources dialog -says why (INDEX_FORMAT, "Publishing from your own repo"). +Then add your repo in the launcher (Mods → Sources). + +### When a source shows no mods + +The Sources dialog names the reason that got furthest across all releases (`SourceStatus.noMods`): + +| Reason | Means | +| --- | --- | +| `noReleases` | The repo has no published releases. | +| `noModFiles` | No release has an `.zip` together with its `.mod.json`. | +| `unreadable` | An `.mod.json` couldn't be read as JSON, or isn't a description. | +| `noDigest` | GitHub has no SHA-256 digest for the zip, so nothing could be checked. | +| `disagrees` | The description doesn't match GitHub's record of the release: tag, repo, asset name, URL, size or hash, or the id isn't the zip's. | +| `invalid` | It matches, but breaks a [rule every entry follows](INDEX_FORMAT.md#rules-the-launcher-enforces). | ## 4. Into the signed index -A build in the index is **Reviewed**: someone read it, and its bytes are pinned by the index's -signature, so a changed release can't change what players install. +A build in the index is **Reviewed**: someone read it, and the index signature pins its bytes, so a +changed release can't change what players install. 1. Build in GitHub Actions and attest the zip with [`actions/attest-build-provenance`](https://github.com/actions/attest-build-provenance), from a @@ -62,10 +74,10 @@ signature, so a changed release can't change what players install. (the mod too, the first time): the same fields as your `.mod.json`, with `source.commit` the full commit the release was built from. 3. A reviewer reads the source at that commit, runs `gh attestation verify` on the zip, and adds - `reviewed` (who, when) and `provenance` (`kind: "github-attestation"`, your repo, the workflow - path and when it was checked). The launcher shows the build record only when all of these are - there ([INDEX_FORMAT, Provenance](INDEX_FORMAT.md#provenance)). + `reviewed` and `provenance`. The launcher shows a build record only when that record is complete + ([INDEX_FORMAT, Provenance](INDEX_FORMAT.md#provenance)). 4. Once merged, CI signs the index and Pages serves it; players get it on their next refresh. -If a build turns out to be harmful or broken, ask for it to be revoked: the index lists its -`sha256` in `revoked`, with a reason, and every launcher turns it off. +If a build turns out to be harmful or broken, ask for its `sha256` to be revoked, with a reason. +Launchers learn of it when they next read the index, and turn the build off the next time its +version's mods are read or before Play. A running game keeps it until it stops. From 8e6442398aca9a3924ea15446e956986f28d15f3 Mon Sep 17 00:00:00 2001 From: Zorro909 Date: Fri, 6 Nov 2026 23:34:21 +0000 Subject: [PATCH 138/156] PROPOSAL describes the current mod manager design: engine constraints, architecture boundaries, trust and one Remaining work section, without the shipped notes, the stale repo name, the 64 MB figure or launcher-side attestation checks --- docs/mod-manager/PROPOSAL.md | 479 +++++++++++++++++------------------ 1 file changed, 229 insertions(+), 250 deletions(-) diff --git a/docs/mod-manager/PROPOSAL.md b/docs/mod-manager/PROPOSAL.md index c2c1e22..4df0194 100644 --- a/docs/mod-manager/PROPOSAL.md +++ b/docs/mod-manager/PROPOSAL.md @@ -1,78 +1,57 @@ -# Mod manager for Weft Launcher: proposal - -Status: Phases 1 and 2 are implemented on `feat/mod-manager` (see [What shipped](#what-shipped)); Phase 3 needs engine changes and is not started. Written 2026-11-04. Mockup (the first design; the app has moved on since): [`history/mockup.html`](history/mockup.html). Open it in a browser; it's drawn at 1:1 in the launcher's own pixel style. - -Sources: -- Read-only research of the engine (`Weft` @ `7c69c9c`, `master`). -- Read-only research of `Zorro909/weft-mods` (engine pinned at `ff9c712` = `v26.11.1`). -- This repo. - -Engine file references below are relative to the Weft repo. - ---- - -## 1. What we're working with (facts, not proposals) - -### A mod is native code -- A mod is a Rust `cdylib` that exports `weft_mod() -> *const ModDecl` (`crates/sdk/src/lib.rs:103-125`). -- It is distributed as one fat `.zip` holding `.dll`, `lib.so`, `lib.dylib` and `assets/`. The engine picks the library for its own OS (`crates/engine/src/modloader.rs:71-75`). -- It is loaded with `libloading`, so it runs with the player's full privileges (`modloader.rs:309`). There is no sandbox and no permission system. A WASM tier doesn't exist yet. -- **Turned-off mods still run code.** The engine `dlopen`s every library and calls `weft_mod()` before it applies `disabled_mods.txt` (`modloader.rs:305-332` vs `:346`). -- There is **no manifest file**: - - `id`, `version` and `deps` are compiled into the library. - - The display name, description and authors come from lang keys. - - The icon is `assets/textures/gui/icon.png`. - - The file name must equal the crate name (`modloader.rs:328-331`). -- Data-only mods are impossible today: a folder without a library is an error. Resource packs and shader packs are separate and data-only. - -### Compatibility is exact, fragile, and failure is fatal -- `ABI_VERSION` is the FNV-1a-64 hash of the raw text of `crates/abi/src/lib.rs`. A mod loads only if its hash is equal (`modloader.rs:321`). -- Across v0.1.0 → v26.11.1 the hash changed in almost every release. Only 26.10.1 → 26.10.2 kept it. -- A mismatch, a missing symbol, a missing dependency or a panicking `init` **all abort startup**. The result is `exit(1)` with no dialog, and the text only appears in `latest.log` (`app.rs:319`, `main.rs:144-147`). -- One mismatched mod blocks every version it wasn't built for. That includes a turned-off mod (the ABI check runs before the off filter). - -### Where the engine looks -The engine checks these folders in order; for each id, the first copy found wins (`modloader.rs:52-59`, `main.rs:61,118-135`): -1. Every `--mods ` (repeatable). -2. `/mods`, the player's folder. It is **shared by every installed version**. -3. `/run/mods`, the shipped mods such as `base`. - -`--run-dir

      ` moves data, config and cache under `

      `. There is no flag to skip the player's folder. Unknown flags are passed through to mods and **start the game**, so `weft --version` can't be used as a probe (`main.rs:81-87`). - -### How weft-mods releases today -- Tags have the form `-v`. Each GitHub release has exactly one asset, `.zip`. -- RCs and experimental builds are marked `--prerelease`. Experimental tags look like `-v-experimental..`, and their binary reports the same version as the full release. -- The only integrity data is GitHub's own `digest: sha256:…` per asset. There is no checksums file, signature or attestation. -- Compatibility is recorded only as prose in the release notes: `Built against Weft , commit `. -- There is no machine-readable index, and the repo has no LICENSE. - -### What the launcher already does that we can reuse -- It reads the GitHub releases API with ETag caching (`releases.rs`). -- It requires a SHA-256 and verifies it (`install_version` in `install.rs`). -- It restricts download URLs to one repo (also `install_version`). -- It extracts archives safely and installs atomically. -- It enforces one running game at a time and captures logs per version. -- **`minisign-verify` 0.3 is already in `Cargo.lock`**, pulled in by the updater, so signature checking costs no new dependency. - ---- +# Mod manager design + +The launcher keeps a separate mod set for each installed Weft version. It installs mods from a +signed index, from GitHub repos the player adds, and from local zips; it checks the set before Play +and names the mod when the game fails to load one. + +Status: built on `feat/mod-manager`. Engine-side changes are still needed; see +[Remaining work](#5-remaining-work). The index contract is [INDEX_FORMAT.md](INDEX_FORMAT.md); the +path for mod authors is [AUTHORING.md](AUTHORING.md). + +## 1. Engine constraints + +These facts explain the design. They are from `Weft-Rust/Weft` @ `7c69c9c`; paths are relative to +that repo. + +- **A mod is native code.** It is a Rust `cdylib` exporting `weft_mod()` + (`crates/sdk/src/lib.rs`), shipped as one `.zip` with `.dll`, `lib.so`, + `lib.dylib` and `assets/`. The engine loads the library for its OS with `libloading`, so it + runs with the player's privileges. There is no sandbox and no permission system. +- **Turned-off mods still run code.** The engine `dlopen`s every library and calls `weft_mod()` + before it applies `disabled_mods.txt` (`crates/engine/src/modloader.rs`). +- **No manifest.** `id`, `version` and `deps` are compiled into the library; reading them means + loading it. The zip's stem must equal the crate name. A mod without a library is an error. +- **Compatibility is exact.** `ABI_VERSION` is the FNV-1a-64 hash of the raw text of + `crates/abi/src/lib.rs`, and a mod loads only if its hash is equal. It changes in almost every + release. +- **Failure is fatal.** An ABI mismatch, a missing symbol, a missing dependency or a panicking + `init` aborts startup with `exit(1)` and no dialog; the reason is only in `latest.log`. The ABI + check runs before the off filter, so one mismatched mod, even a turned-off one, blocks the + version. +- **Search order.** For each id the first copy found wins: every `--mods

      `, then the player's + `/mods` (shared by every version), then `/run/mods` (shipped mods such as `base`). + There is no flag to skip the player's folder. +- **No safe probe.** Unknown flags are passed to mods and start the game, so `weft --version` + can't be used to ask a build for its ABI. ## 2. Design principles -1. **Mod sets belong to a game version.** The ABI changes almost every release, so a shared mod folder is a trap. Each installed version gets its own set, passed with `--mods`. -2. **The launcher never writes into the player's Weft folder.** This keeps the README promise. Mods live under the launcher's data folder. -3. **"Off" means not passed to the game.** The engine runs code even from turned-off mods, so the launcher turns a mod off by leaving it out of the folder it hands over. It doesn't use `disabled_mods.txt`. -4. **Don't start a launch that is known to fail.** Check the ABI and dependencies before Play. If the game still dies during mod loading, name the mod. -5. **Be honest about trust.** Show what was actually verified. Never show a fake permission list. Always say that native mods can do anything. +1. **Mod sets belong to a game version.** The ABI changes almost every release, so a shared mod + folder is a trap. Each installed version gets its own set, passed with `--mods`. +2. **The launcher never writes into the player's Weft folder.** Mods live under the launcher's + data folder. +3. **"Off" means not passed to the game.** The engine runs code even from turned-off mods, so the + launcher leaves an off mod out of the folder it hands over. It doesn't use `disabled_mods.txt`. +4. **Don't start a launch that is known to fail.** Check the ABI and dependencies before Play. If + the game still dies during mod loading, name the mod. +5. **Be honest about trust.** Show what was actually verified. Never show a permission list. + Always say that native mods can do anything. ---- +## 3. Architecture -## 3. Architecture (launcher side) +### On-disk layout -### On-disk layout (`%APPDATA%\dev.weft.launcher\…`) -*Shipped as described.* - - -As built (`src-tauri/src/mods/paths.rs`): +Under the launcher's data folder (`src-tauri/src/mods/paths.rs`): ``` mods/ @@ -94,220 +73,220 @@ cache/ downloads/mod--.part a download in progress; never shared by two downloads ``` -The index URL is built in (`index/fetch.rs`); `sources.json` holds only repos. +The index URL is built in (`index/fetch.rs`); `sources.json` holds only repos. A `sources.json` +that can't be read is never taken for an empty list: it is set aside and reported +(`ModSourcesDamaged`), and adding a source starts a new list. -Launch becomes `weft --mods /mods>`. Turning a mod on or off copies its build into that folder or removes it. When a version is deleted, its profile goes with it, and unused store entries are garbage-collected. Garbage collection skips any store object an install is still working on or a removal can still undo, and deletes nothing if any lock file can't be read. Lock files are checked when read: an id that isn't a mod id or a hash that isn't one is an error, never a path. +Launch is `weft --mods /mods>`. Turning a mod on or off copies its build into that +folder or removes it. Deleting a version deletes its profile, and unused store entries are +garbage-collected. Garbage collection never deletes an object an install has reserved or a removal +can still undo, and deletes nothing if a lock file can't be read. Lock files are validated on read: +an id or hash of the wrong form is an error, never a path. -### Backend modules (`src-tauri/src/mods/`) -*Shipped as described.* +### Backend (`src-tauri/src/mods/`) +| Part | Modules | Boundary | +|---|---|---| +| Rules | `plan/`, `profile.rs`, `catalog.rs`, `index/format.rs`, `diagnose.rs`, `version.rs` | Plain data in and out; no I/O. | +| Store | `store.rs`, `paths.rs` | `store::admit` is the only way into the store: it hashes the file and checks its zip and platform, also when the store already has that hash. | +| Fetching | `index/`, `sources.rs`, `abi.rs`, `install.rs` | Network and caches. `install.rs` downloads through the shared `download.rs`. | +| Files | `archive.rs`, `inspect.rs` | Zip checks and local-file inspection. Never unpack to disk, never load a library. | +| Operations | `service/`, `context.rs` | One operation per command; `Ctx` holds the folders, locks and running game. | +| IPC | `commands.rs`, `error.rs` | Each command is one call into `service/`. Errors reach the page as an `ErrorCode`, never an English sentence. | -| Module | Job | -|---|---| -| `abi.rs` | ABI hash of a Weft tag: the index's `weft[]` table, else a cache, else FNV-1a-64 of `raw.githubusercontent.com/Weft-Rust/Weft//crates/abi/src/lib.rs` (verified against v26.10.2 and v26.10.4). **Later:** read a `weft-abi.txt` release asset. | -| `index/` | The index contract. `format.rs` parses it entry by entry (a bad entry is skipped, not fatal); `signature.rs` verifies it (minisign) and reads the signing time; `cache.rs` keeps verified copies as generations for offline use, refuses an index older than the newest ever accepted, and calls one signed over 30 days ago stale; `fetch.rs` gets it. | -| `sources.rs` | GitHub repos the player added: read their releases (ETag-cached, up to 5 pages of 100) and accept a `.mod.json` only where it agrees with GitHub (see [INDEX_FORMAT.md](INDEX_FORMAT.md)). Falls back to the last good answer and reports why. | -| `catalog.rs` | Merge the index and the sources into one list with a trust tier per version; a source build with a reviewed hash counts as Reviewed. | -| `archive.rs` | Check the zip structure (below) before anything is stored. | -| `profile.rs` | Rules on a mod set: the status of each mod, what blocks the game and why (`blockingReasons`), which files in the player's folder shadow it, which update fits, revocations. | -| `plan/` | Rules for changing a mod set. `resolution.rs`: what an install brings along (`plan_mod_install`), through the whole dependency graph, and whether a build is installable. `consent.rs`: the plan token (`Consent`) and whether a plan still holds. `copies.rs`: what a copy brings over. `mod.rs`: turning on and off, and removal. | -| `store.rs` | The store, lock files, the undo record, the per-version folders (`reconcile`, which copies), reservations for in-flight installs, garbage collection. `admit` is the one way into the store: it hashes the file and checks its zip and platform, also for a build the store already has. | -| `install.rs` | Download a build through the shared `download.rs`, cancellable, and hand it to `store::admit`. | -| `inspect.rs` | Describe a zip from the player's computer before it is installed. | -| `diagnose.rs` | Read the game log after a crash and name the mod that stopped it. | -| `context.rs` | `Ctx`: what the operations need from the app (folders, the mods lock, reservations, the catalogue the page last saw, the running game, the operations under way, progress events). Tests build one on a temporary folder. | -| `paths.rs` | Where everything lives under the launcher's data folder, and `write_atomic`. | -| `version.rs` | Comparing mod versions (semver order). | -| `error.rs` | `ModsError`, and the `ErrorCode` each one reports to the page. | -| `service/` | The operations, one per command. `mod.rs`: the ABI, reports, saving a set and reconciling its folder. `fetching.rs`: the catalogue (index and sources, held in parts), icons and the list of sources. `changes.rs`: plan, install from the catalogue or a file, on/off, removal and undo. `copying.rs`: "Copy mods from…". `launching.rs`: the pre-Play check. | -| `commands.rs` | The Tauri commands: each one is a single call into `service`. | +Everything outside `service/` takes plain data, a `Paths` root or a `Ctx`, so it is tested without +the app (`service_tests.rs` runs installs against a local HTTP server). The IPC types are generated +into `src/mods/bindings/`, and `src/mods/api.ts` calls commands only through them; the README says +how to regenerate them. -Pure rules (`plan/`, `profile.rs`, `catalog.rs`, `index/format.rs`, `diagnose.rs`) take plain data, and everything else takes a `Paths` root or a `Ctx`, so all of it is tested without the app (`service_tests.rs` runs installs against a local HTTP server). The TypeScript types for IPC are generated from the Rust types into `src/mods/bindings/`, with every command's arguments and result (`CommandSignatures`) and every event's payload (`EventPayloads`); `src/mods/api.ts` calls commands only through them. After changing a type or a command, regenerate them with `cargo test export_bindings -- --ignored` in `src-tauri`; `bindings_are_current` fails CI until that is committed. The mock's rule cases in `src/dev/fixtures/rules.json` run against both the mock (`mockRules.test.ts`) and these rules (`rules_fixture.rs`). +Tauri commands: `list_mod_catalog`, `refresh_mod_source`, `get_mod_sources`, `add_mod_source`, `remove_mod_source`, `get_mod_profile`, `plan_mod_install`, `install_mod`, `inspect_mod_file`, `install_mod_file`, `set_mod_enabled`, `remove_mod`, `restore_mod`, `preview_copy_mod_profile`, `copy_mod_profile`, `cancel_mod_operation`, `get_mod_icon`, `open_player_mods_folder`. `doc_tests.rs` checks this list against `lib.rs`. -Tauri commands: `list_mod_catalog`, `refresh_mod_source`, `get_mod_sources`, `add_mod_source`, `remove_mod_source`, `get_mod_profile`, `plan_mod_install`, `install_mod`, `inspect_mod_file`, `install_mod_file`, `set_mod_enabled`, `remove_mod`, `restore_mod`, `preview_copy_mod_profile`, `copy_mod_profile`, `cancel_mod_operation`, `get_mod_icon`, `open_player_mods_folder`. (`doc_tests.rs` checks this list against `lib.rs`.) The pre-Play check runs inside `launch_version`, which holds the mod lock from that check until the game counts as running, so no mod change can land in between. +The catalogue merges the index and every source. Each part carries the ticket of the refresh that +fetched it, so a slow refresh that finishes last can't replace newer answers; the page keeps the +catalogue with the highest `revision`. ### Changing a mod set safely -*Shipped as described.* - -- Every change to a profile or the store's contents happens under the mods lock. Downloads happen before taking it; their store files are reserved so garbage collection leaves them alone. -- **Dependencies resolve through the whole graph.** An install, a copy and turning a mod on walk every dependency, through builds the change picks and through installed mods alike. An installed dependency that fits and isn't revoked is used, turned on if it is off, and what *it* needs is resolved in turn. `InstallPlan.enable` lists everything that turns on. -- **The reviewed plan is binding.** `plan_mod_install`, `preview_copy_mod_profile` and `inspect_mod_file` return a `planToken`: a digest of everything the player agrees to, namely every build that will be installed (hash and trust), every mod that ends up on, what turns on, what is installed off or missing, and the version's ABI. `install_mod`, `copy_mod_profile` and `install_mod_file` take it back. The backend recomputes it from the current catalogue, profile and ABI before downloading anything, and again under the lock at commit. When it differs, the command fails with `ModChangedMeanwhile` and writes nothing; the page plans again and shows the new plan. At commit the version must also still be installed and not running, and no build may be revoked in the latest catalogue. A file install checks revocation again at commit by the file's hash, whether the catalogue knows the file or not. -- **Operations have ids and can be cancelled.** `install_mod`, `copy_mod_profile`, `install_mod_file` and `restore_mod` take an `operationId` the page chooses. Every `mod-progress` event carries it, with the file's place in the operation (`index` of `count`). `cancel_mod_operation(operationId)` stops the operation until it starts writing: its download is aborted, the command fails with `ModCancelled`, the lock file is untouched, and no partial file stays (builds it had already stored are collected). Once it writes, cancelling returns `false`. -- **A new install never breaks Play.** A mod is installed turned on when it is new or was on, and nothing it needs is missing at any depth (`InstallPlan.turnsOn`). Otherwise it is installed turned off and listed in `ChangeResult.installedOff`; the dependencies it brought come in off too, and nothing else is turned on. Copies follow the same rule per mod. A mod that is already on *can* block Play: when something it needs is later turned off, removed or revoked, or it doesn't fit the version. Home and Mods then name the cause and offer the fix. -- **Nothing changes under a running game.** While a version's game runs, nothing rewrites its lock file or its `mods/` folder. Changes are refused with `GameRunning`; a revocation that arrives is reported (`ProfileReport.revocationsPending`, reason `revoked` in `blockingReasons`) and applied when the game stops. -- **Each version's folder holds copies.** `reconcile` copies each turned-on build from the store (a reflink where the file system does that), never a hard link, so a write to one file can't reach the store or another version. It hashes what is there again on every run and replaces anything that changed. That costs one read of every turned-on build per save and per launch: at most 64 MB a mod, a few MB for a typical one, about a second for a large set on a slow disk. It is kept that way on purpose: the folder is the player's to touch, and a size and time that match say nothing about the bytes, so nothing reaches the game without its hash being the one that was checked. -- **Remove can be undone.** `remove_mod` keeps the removed entry in `removed.json`, and its store file, until the version's next change; `restore_mod` puts it back exactly as it was, and turns on again what the removal turned off. - -### Checking the zip structure (before it is stored) -*Shipped, and changed: sizes are counted by inflating every entry, not only read from the zip; see [Changed from the proposal](#changed-from-the-proposal).* -- Only `.dll`, `lib.so`, `lib.dylib` and `assets/**` are allowed, for the expected ``. -- No `..`, absolute paths or symlinks. -- Caps on entry count, uncompressed size and compression ratio (zip bombs). -- `` is a mod id (`[a-z0-9_]{1,64}`). The store names the file `.zip`; the version's folder names it `.zip`, which the engine requires. - -The launcher never unpacks the zip to run anything, and **never `dlopen`s a mod to read its metadata**: that would run its code. +- **One lock.** Every change to a profile or to the store's contents happens under the mods lock. + Downloads happen before it is taken; their store files are reserved so garbage collection leaves + them alone. +- **Dependencies resolve through the whole graph.** An install, a copy and turning a mod on walk + every dependency, through new builds and installed mods alike. An installed dependency that fits + and isn't revoked is used, and turned on if it is off. `InstallPlan.enable` lists everything that + turns on. +- **The reviewed plan is binding.** `plan_mod_install`, `preview_copy_mod_profile` and + `inspect_mod_file` return a `planToken`: a digest of the builds (hash and trust), what ends up + on, what is installed off or missing, and the version's ABI. The matching install command passes + it back. The backend recomputes it before downloading and again under the lock at commit. If it + differs, the command fails with `ModChangedMeanwhile`, writes nothing, and the page plans again. + At commit the version must still be installed and not running, and no build may be revoked. A + file install re-checks revocation by the file's hash. +- **Operations can be cancelled.** `install_mod`, `copy_mod_profile`, `install_mod_file` and + `restore_mod` take an `operationId`, which every `mod-progress` event carries with the file's + place (`index` of `count`). `cancel_mod_operation` works until the operation starts writing: the + download is aborted, the command fails with `ModCancelled`, and nothing is left behind. After + that it returns `false`. +- **A new install never breaks Play.** A mod is installed on when it is new or was on, and nothing + it needs is missing at any depth (`InstallPlan.turnsOn`). Otherwise it and the dependencies it + brought are installed off (`ChangeResult.installedOff`). Copies follow the same rule per mod. + Updates keep a mod's on/off state. A mod already on can still block Play when something it needs + is turned off, removed or revoked; the report says which (`depsNotInstalled`, `blockingReasons`), + and Home and Mods offer the fix. +- **Nothing changes under a running game.** While a version's game runs, its lock file and `mods/` + folder are not rewritten. Changes fail with `GameRunning`. A revocation that arrives is reported + (`ProfileReport.revocationsPending`) and applied when the game stops. +- **Saving writes in a fixed order:** clear the old Undo, write the lock, write the removal's Undo + (a failure there only loses Undo), then reconcile the version's folder from the lock. +- **Folders hold copies.** `reconcile` copies each turned-on build from the store (a reflink where + possible, never a hard link), so a write to one file can't reach the store or another version. On + every save and launch it hashes each file again and replaces any that changed: the folder is the + player's to touch, and size and time say nothing about the bytes. +- **Remove can be undone.** `remove_mod` keeps the entry in `removed.json`, and its store file, + until the version's next change. `restore_mod` puts it back and turns on again what the removal + turned off. + +### Checking the zip + +Before a zip is stored: + +- Only `.dll`, `lib.so`, `lib.dylib` and `assets/**` are allowed, for a valid mod id + ``. No `..`, absolute paths or symlinks. +- Entry count, unpacked size and compression ratio are capped. Every entry is inflated once, + without writing it anywhere, so a zip that lies about its sizes, is damaged or is encrypted is + refused. The limits are in [INDEX_FORMAT.md](INDEX_FORMAT.md#size-limits). +- The store names the file `.zip`; the version's folder names it `.zip`, as the engine + requires. + +The launcher never unpacks a mod to run it, and never loads a library to read its metadata: that +would run its code. ### The pre-Play check -*Shipped. Changed: ABIs are compared from published metadata, never the library; see [Changed from the proposal](#changed-from-the-proposal).* -- Every turned-on mod's ABI must equal the version's ABI. +- Every turned-on mod's ABI must equal the version's ABI. ABIs come from published metadata + (`weft.abi`), never the library. A build from an untagged Weft commit fits + every release with the same ABI. With the version's ABI unknown, `fitsVersion` is `null`, mods + are "unchecked", and Play isn't blocked for it. - Every dependency must be turned on, or be `base`. -- If either fails, Play is blocked with the reason and a fix (turn off / find update). -- It never touches the network: it uses the catalogue the page last loaded (or the cached one) and the cached ABI. With no ABI known, mods are only "unchecked" and Play isn't blocked for it; the game has the last word. -- Also list what is in the player's own `/mods`. The path comes from the `game folder …` log line the launcher already parses. Warn: "these load in every version and can't be checked". This is the one place the launcher *reads* the game folder, and it's a listing only. +- If either fails, Play is blocked with the reason and a fix (turn off, find update). +- It never touches the network: it uses the catalogue the page last loaded (or the cached one) and + the cached ABI. +- It runs inside `launch_version`, which holds the mods lock until the game counts as running, so + no mod change lands in between. +- It lists the player's own `/mods` (path from the `game folder …` log line) and warns that + those load in every version and can't be checked. This is the only place the launcher reads the + game folder, and it only lists it. ### The player's folder and precedence -*Shipped as described.* -The engine names a mod by its folder, or by the stem of `.zip`, and loads the **first** copy of each id it finds, searching the `--mods` folder (the launcher's, for this version) before the player's `/mods`, and that before the shipped mods. The player's folder loads even when the launcher passes no `--mods` at all. +The engine loads the first copy of each id it finds: the launcher's `--mods` folder, then the +player's `/mods`, then the shipped mods. A file in the player's folder with the id of a mod +in the set (`ProfileReport.shadowed`): -So a file in the player's folder with the id of a mod in the set (`ProfileReport.shadowed`, with what the launcher's copy is: `on`, `off` or `revoked`): -- is ignored while the launcher's copy is on, since the launcher's loads first; -- **loads instead** while the launcher's copy is off: a warning, since "Turn off" doesn't turn it off; -- **blocks Play** when the launcher's copy is revoked, since the pulled mod would load anyway. It is listed in `blocking` with the reason `shadowedRevoked`, and Play is refused even with nothing on. +- is ignored while the launcher's copy is on; +- **loads instead** while the launcher's copy is off: a warning, since "Turn off" doesn't turn it + off; +- **blocks Play** when the launcher's copy is revoked, since the pulled mod would load anyway + (reason `shadowedRevoked`), even with nothing on. -Names compare exactly, as the engine compares them: `Cogwork.zip` is another id than `cogwork`. +Names compare exactly, as the engine compares them: `Cogwork.zip` is another id than `cogwork`. The +launcher never deletes the player's file. ### Diagnosing a failure after Play -*Shipped. A crash that names no mod offers turning off all of the version's mods, with Undo; a file mod's crash offers another file.* -On `exit(1)`, scan the version's log for the engine's fixed messages. The engine's loader returns them as errors (`crates/engine/src/modloader.rs`, `content.rs`), and the `weft` binary logs them at ERROR, as `