Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
208ae62ca6
|
|||
|
ada9698ecf
|
|||
|
516f1cfde2
|
|||
|
c79aad1cb9
|
|||
|
79e953bbe7
|
|||
|
b2df81130e
|
|||
|
86b8ab9c75
|
|||
|
fa6eb856dc
|
|||
|
7ac541def5
|
@@ -5,3 +5,20 @@ COSMETICS_COLLECTION_URL=steam://openurl/https://steamcommunity.com/sharedfiles/
|
||||
|
||||
# ModsConfig.xml Path Template
|
||||
MODSCONFIG_PATH_TEMPLATE=%USERPROFILE%\AppData\LocalLow\Ludeon Studios\RimWorld by Ludeon Studios\Config\ModsConfig.xml
|
||||
|
||||
# Web backend
|
||||
PROGRESSION_HOST=127.0.0.1
|
||||
PROGRESSION_PORT=8000
|
||||
PROGRESSION_DB_PATH=.data/progression.db
|
||||
# Windows default; use `steamcmd` when it is installed on PATH on Linux.
|
||||
STEAMCMD_PATH=.steamcmd/steamcmd.exe
|
||||
STEAMCMD_WORKSHOP_DIR=.steamcmd/steamapps/workshop/content/294100
|
||||
STEAMCMD_BATCH_SIZE=25
|
||||
STEAMCMD_TIMEOUT_SECONDS=1800
|
||||
REFRESH_INTERVAL_SECONDS=3600
|
||||
REFRESH_ON_STARTUP=true
|
||||
# Hard ceiling for the first full Workshop download (12 hours).
|
||||
INITIAL_DOWNLOAD_TIMEOUT_SECONDS=43200
|
||||
MODLIST_GAME_VERSION=1.6
|
||||
# Optional bearer token required by POST /api/refresh when set.
|
||||
PROGRESSION_ADMIN_TOKEN=
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
name: Build and Publish Container
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
concurrency:
|
||||
group: container-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Publish Linux container
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Resolve registry image name
|
||||
id: image
|
||||
shell: bash
|
||||
env:
|
||||
SERVER_URL: ${{ github.server_url }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
registry="${SERVER_URL#http://}"
|
||||
registry="${registry#https://}"
|
||||
registry="${registry%%/*}"
|
||||
repository="$(printf '%s' "$REPOSITORY" | tr '[:upper:]' '[:lower:]')"
|
||||
echo "registry=$registry" >> "$GITHUB_OUTPUT"
|
||||
echo "image=$registry/$repository" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Verify registry credentials
|
||||
shell: bash
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "$REGISTRY_TOKEN" ]]; then
|
||||
echo "::error::Add a REGISTRY_TOKEN repository secret containing a Gitea personal access token with write:package permission."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ steps.image.outputs.registry }}
|
||||
username: ${{ secrets.REGISTRY_USERNAME || 'HRiggs' }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Generate image metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ steps.image.outputs.image }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=ref,event=branch
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha,prefix=sha-
|
||||
labels: |
|
||||
org.opencontainers.image.title=Progression Mods
|
||||
org.opencontainers.image.description=Progression RimWorld mod-list service
|
||||
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and publish
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
provenance: false
|
||||
|
||||
@@ -201,3 +201,7 @@ art/thumbnails/
|
||||
# Steam Workshop related (if any cache files are generated)
|
||||
workshop_cache/
|
||||
*.cache
|
||||
|
||||
# Progression web backend runtime data
|
||||
.data/
|
||||
.steamcmd/
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
FROM ubuntu:24.04
|
||||
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
RUN dpkg --add-architecture i386 \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl lib32gcc-s1 python3 python3-pip python3-venv \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& useradd --create-home --shell /bin/bash progression \
|
||||
&& mkdir -p /opt/steamcmd/steamapps /srv/progression/.data /srv/steam-library/steamapps \
|
||||
&& curl -fsSL https://steamcdn-a.akamaihd.net/client/installer/steamcmd_linux.tar.gz \
|
||||
| tar -xz -C /opt/steamcmd \
|
||||
&& chown -R progression:progression /opt/steamcmd /srv/progression /srv/steam-library
|
||||
|
||||
WORKDIR /srv/progression
|
||||
COPY --chown=progression:progression requirements-web.txt ./
|
||||
RUN python3 -m venv /srv/venv \
|
||||
&& /srv/venv/bin/pip install --no-cache-dir -r requirements-web.txt
|
||||
|
||||
COPY --chown=progression:progression progression_backend ./progression_backend
|
||||
COPY --chown=progression:progression art ./art
|
||||
COPY --chmod=755 docker-entrypoint.sh /usr/local/bin/progression-entrypoint
|
||||
|
||||
ENV HOME=/home/progression \
|
||||
PROGRESSION_HOST=0.0.0.0 \
|
||||
PROGRESSION_PORT=8000 \
|
||||
PROGRESSION_DB_PATH=.data/progression.db \
|
||||
STEAMCMD_PATH=/opt/steamcmd/steamcmd.sh \
|
||||
STEAMCMD_WORKSHOP_DIR=/srv/steam-library/steamapps/workshop/content/294100 \
|
||||
REFRESH_INTERVAL_SECONDS=3600 \
|
||||
REFRESH_ON_STARTUP=true \
|
||||
INITIAL_DOWNLOAD_TIMEOUT_SECONDS=43200
|
||||
|
||||
VOLUME ["/srv/progression/.data", "/srv/steam-library/steamapps"]
|
||||
EXPOSE 8000
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD curl -fsS http://127.0.0.1:8000/api/health || exit 1
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/progression-entrypoint"]
|
||||
CMD ["/srv/venv/bin/python", "-m", "progression_backend"]
|
||||
@@ -16,3 +16,77 @@ python version_manager.py 1.0.1
|
||||
build
|
||||
|
||||
pyinstaller --clean --onefile --windowed --icon=art/Progression.ico --add-data "art;art" --name ProgressionLoader steam_workshop_gui.py
|
||||
|
||||
## Web dashboard and backend
|
||||
|
||||
The repository now includes a FastAPI backend and responsive dashboard in
|
||||
`progression_backend`. It resolves the three Workshop collections through
|
||||
Steam's `GetCollectionDetails` API, downloads missing or updated RimWorld mods
|
||||
with anonymous SteamCMD, reads `About/About.xml`, and generates the same RimWorld
|
||||
`.rml` format as the desktop loader.
|
||||
|
||||
The backend refreshes once per hour and records each run in SQLite, including:
|
||||
|
||||
- complete refresh time and status
|
||||
- requested, successful, and failed downloads
|
||||
- Workshop cache size on disk
|
||||
- size added during the run and average throughput
|
||||
- per-mod size, validation status, timestamps, and SteamCMD batch diagnostics
|
||||
|
||||
### Run locally
|
||||
|
||||
Install SteamCMD, copy `.env.example` to `.env`, and set `STEAMCMD_PATH` plus
|
||||
`STEAMCMD_WORKSHOP_DIR` to the matching SteamCMD Workshop directory. Then run:
|
||||
|
||||
```powershell
|
||||
python -m pip install -r requirements-web.txt
|
||||
python -m progression_backend
|
||||
```
|
||||
|
||||
Open `http://127.0.0.1:8000`. API documentation is available at
|
||||
`http://127.0.0.1:8000/api/docs`.
|
||||
|
||||
Use one application process/worker because that process owns the hourly
|
||||
scheduler. For an externally scheduled deployment, set `REFRESH_ON_STARTUP=false`
|
||||
and call `POST /api/refresh` once per hour.
|
||||
|
||||
### Run with Docker
|
||||
|
||||
Docker provisions the SteamCMD binary and persists both analytics and downloaded
|
||||
Workshop content:
|
||||
|
||||
```powershell
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
The SteamCMD Linux runtime requires an amd64 host or amd64 emulation. Set
|
||||
`PROGRESSION_ADMIN_TOKEN` before exposing the manual refresh endpoint publicly.
|
||||
|
||||
### Published container
|
||||
|
||||
Gitea Actions builds and publishes the `linux/amd64` image on every push to
|
||||
`main` and every `v*` version tag:
|
||||
|
||||
```text
|
||||
git.hudsonriggs.systems/hriggs/progressionmods:latest
|
||||
```
|
||||
|
||||
Published tags include `latest`, the branch or semantic version, and
|
||||
`sha-<commit>`. Before running the workflow, configure `REGISTRY_TOKEN` as a
|
||||
repository Actions secret containing a Gitea personal access token with
|
||||
`write:package` permission.
|
||||
|
||||
The registry username defaults to `HRiggs`. An optional `REGISTRY_USERNAME`
|
||||
secret can override it.
|
||||
|
||||
Gitea's built-in Actions token cannot currently publish OCI packages, so the
|
||||
registry token is required even though the workflow requests `packages: write`.
|
||||
|
||||
### Mod-list endpoints
|
||||
|
||||
- `GET /api/modlists/vanilla?dlc=royalty&dlc=biotech`
|
||||
- `POST /api/modlists/homebrew` with a `mods_config` XML upload
|
||||
- `GET /api/status`, `/api/analytics`, and `/api/mods`
|
||||
|
||||
Generation fails closed when any current collection item is unavailable. An
|
||||
operator can explicitly request `allow_partial=true` for diagnostics.
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
services:
|
||||
progression-web:
|
||||
build: .
|
||||
platform: linux/amd64
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
PROGRESSION_ADMIN_TOKEN: ${PROGRESSION_ADMIN_TOKEN:-}
|
||||
MODLIST_GAME_VERSION: ${MODLIST_GAME_VERSION:-1.6}
|
||||
INITIAL_DOWNLOAD_TIMEOUT_SECONDS: ${INITIAL_DOWNLOAD_TIMEOUT_SECONDS:-43200}
|
||||
volumes:
|
||||
- progression-data:/srv/progression/.data
|
||||
- progression-workshop:/srv/steam-library/steamapps
|
||||
|
||||
volumes:
|
||||
progression-data:
|
||||
progression-workshop:
|
||||
@@ -0,0 +1,25 @@
|
||||
# Published image. Use a version such as 1.0.0 instead of latest to pin a release.
|
||||
PROGRESSION_IMAGE=git.hudsonriggs.systems/hriggs/progressionmods
|
||||
PROGRESSION_IMAGE_TAG=latest
|
||||
|
||||
# Public listener. Change the external port without changing the container.
|
||||
PROGRESSION_BIND_ADDRESS=0.0.0.0
|
||||
PROGRESSION_EXTERNAL_PORT=8000
|
||||
|
||||
# Protects POST /api/refresh. Set a long random value before public deployment.
|
||||
PROGRESSION_ADMIN_TOKEN=change-me
|
||||
MODLIST_GAME_VERSION=1.6
|
||||
|
||||
# Progression Steam Workshop collections.
|
||||
CORE_COLLECTION_URL=https://steamcommunity.com/workshop/filedetails/?id=3521297585
|
||||
CONTENT_COLLECTION_URL=https://steamcommunity.com/sharedfiles/filedetails/?id=3521319712
|
||||
COSMETICS_COLLECTION_URL=https://steamcommunity.com/sharedfiles/filedetails/?id=3637541646
|
||||
|
||||
# SteamCMD download and hourly refresh behavior.
|
||||
STEAMCMD_BATCH_SIZE=25
|
||||
STEAMCMD_TIMEOUT_SECONDS=1800
|
||||
REFRESH_INTERVAL_SECONDS=3600
|
||||
REFRESH_ON_STARTUP=true
|
||||
# Hard ceiling for the first full Workshop download (12 hours).
|
||||
INITIAL_DOWNLOAD_TIMEOUT_SECONDS=43200
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Progression deployment
|
||||
|
||||
This deployment pulls the published container from the Gitea registry. No
|
||||
repository checkout or local image build is required on the server.
|
||||
|
||||
1. Copy `.env.example` to `.env` and change `PROGRESSION_ADMIN_TOKEN`.
|
||||
2. Set `PROGRESSION_EXTERNAL_PORT` if port `8000` is already in use.
|
||||
3. Start the service:
|
||||
|
||||
```sh
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The SQLite database and Steam Workshop files are stored in named Docker volumes
|
||||
and survive image upgrades. To update later, run the same two commands again.
|
||||
|
||||
On a new deployment, the hourly refresh timer stays paused while the first full
|
||||
Workshop download runs. Its hard limit defaults to 12 hours and can be changed
|
||||
with `INITIAL_DOWNLOAD_TIMEOUT_SECONDS`. Follow the per-mod title, Workshop ID,
|
||||
result, and completion percentage in Dockhand or with `docker compose logs -f`.
|
||||
Failed batches also include SteamCMD's output tail so network, subscription, and
|
||||
filesystem errors are visible directly in the container log.
|
||||
|
||||
SteamCMD is installed in `/opt/steamcmd`, while its forced library root is the
|
||||
separate `/srv/steam-library` path. The existing `progression-workshop` volume
|
||||
is mounted below that library root, so upgrades keep all completed downloads.
|
||||
On the first `I/O Operation Failed` result, transient `.acf`, download, and temp
|
||||
state is moved into `.progression-recovery`; completed mod directories are left
|
||||
untouched. Startup also checks that the volume has enough free space for the
|
||||
remaining Steam metadata estimate.
|
||||
|
||||
The image does not switch Unix users at startup because some rootless and
|
||||
capability-restricted container hosts block `setuid`. The process remains inside
|
||||
the container's user namespace and writes directly to its named volumes.
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
name: progression-mods
|
||||
|
||||
services:
|
||||
progression-web:
|
||||
image: ${PROGRESSION_IMAGE:-git.hudsonriggs.systems/hriggs/progressionmods}:${PROGRESSION_IMAGE_TAG:-latest}
|
||||
platform: linux/amd64
|
||||
pull_policy: always
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${PROGRESSION_BIND_ADDRESS:-0.0.0.0}:${PROGRESSION_EXTERNAL_PORT:-8000}:8000"
|
||||
environment:
|
||||
PROGRESSION_HOST: 0.0.0.0
|
||||
PROGRESSION_PORT: 8000
|
||||
PROGRESSION_DB_PATH: /srv/progression/.data/progression.db
|
||||
PROGRESSION_ADMIN_TOKEN: ${PROGRESSION_ADMIN_TOKEN:-}
|
||||
MODLIST_GAME_VERSION: ${MODLIST_GAME_VERSION:-1.6}
|
||||
CORE_COLLECTION_URL: ${CORE_COLLECTION_URL:-https://steamcommunity.com/workshop/filedetails/?id=3521297585}
|
||||
CONTENT_COLLECTION_URL: ${CONTENT_COLLECTION_URL:-https://steamcommunity.com/sharedfiles/filedetails/?id=3521319712}
|
||||
COSMETICS_COLLECTION_URL: ${COSMETICS_COLLECTION_URL:-https://steamcommunity.com/sharedfiles/filedetails/?id=3637541646}
|
||||
STEAMCMD_PATH: /opt/steamcmd/steamcmd.sh
|
||||
STEAMCMD_WORKSHOP_DIR: /srv/steam-library/steamapps/workshop/content/294100
|
||||
STEAMCMD_BATCH_SIZE: ${STEAMCMD_BATCH_SIZE:-25}
|
||||
STEAMCMD_TIMEOUT_SECONDS: ${STEAMCMD_TIMEOUT_SECONDS:-1800}
|
||||
REFRESH_INTERVAL_SECONDS: ${REFRESH_INTERVAL_SECONDS:-3600}
|
||||
REFRESH_ON_STARTUP: ${REFRESH_ON_STARTUP:-true}
|
||||
INITIAL_DOWNLOAD_TIMEOUT_SECONDS: ${INITIAL_DOWNLOAD_TIMEOUT_SECONDS:-43200}
|
||||
volumes:
|
||||
- progression-data:/srv/progression/.data
|
||||
- progression-workshop:/srv/steam-library/steamapps
|
||||
|
||||
volumes:
|
||||
progression-data:
|
||||
progression-workshop:
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
prepare_volume() {
|
||||
path="$1"
|
||||
marker="$path/.progression-root-volume-ready"
|
||||
mkdir -p "$path"
|
||||
if [ ! -e "$marker" ]; then
|
||||
echo "Preparing persistent volume for the container user: $path"
|
||||
chown -R 0:0 "$path"
|
||||
touch "$marker"
|
||||
fi
|
||||
}
|
||||
|
||||
prepare_steam_volume() {
|
||||
path="$1"
|
||||
marker="$path/.progression-steam-owner-v2"
|
||||
|
||||
# SteamCMD starts as root in this container, but its Linux filesystem
|
||||
# workers use the account that owns the SteamCMD installation. Keep both
|
||||
# its HOME and Workshop library writable by that account.
|
||||
mkdir -p \
|
||||
/home/progression/Steam/logs \
|
||||
"$path/workshop/content/294100"
|
||||
chown -R progression:progression /home/progression
|
||||
|
||||
if [ ! -e "$marker" ]; then
|
||||
echo "Preparing persistent Steam library for SteamCMD: $path"
|
||||
chown -R progression:progression "$path"
|
||||
touch "$marker"
|
||||
chown progression:progression "$marker"
|
||||
else
|
||||
chown progression:progression \
|
||||
"$path" \
|
||||
"$path/workshop" \
|
||||
"$path/workshop/content" \
|
||||
"$path/workshop/content/294100"
|
||||
fi
|
||||
}
|
||||
|
||||
prepare_volume /srv/progression/.data
|
||||
prepare_steam_volume /srv/steam-library/steamapps
|
||||
|
||||
exec "$@"
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Progression web backend."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,18 @@
|
||||
import uvicorn
|
||||
|
||||
from .config import Settings
|
||||
|
||||
|
||||
def main() -> None:
|
||||
settings = Settings.load()
|
||||
uvicorn.run(
|
||||
"progression_backend.app:app",
|
||||
host=settings.host,
|
||||
port=settings.port,
|
||||
reload=False,
|
||||
workers=1,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,289 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
from fastapi import (
|
||||
FastAPI,
|
||||
File,
|
||||
Header,
|
||||
HTTPException,
|
||||
Query,
|
||||
UploadFile,
|
||||
status,
|
||||
)
|
||||
from fastapi.responses import FileResponse, RedirectResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from .config import Settings
|
||||
from .database import Database
|
||||
from .modlist import core_items, generate_rml, parse_active_mods, workshop_items
|
||||
from .refresh import RefreshCoordinator, RefreshService
|
||||
from .workshop import SteamCmdRunner
|
||||
|
||||
|
||||
DESKTOP_RELEASES_API = (
|
||||
"https://git.hudsonriggs.systems/api/v1/repos/HRiggs/ProgressionMods/releases"
|
||||
)
|
||||
DESKTOP_RELEASE_HOST = "git.hudsonriggs.systems"
|
||||
|
||||
|
||||
def latest_windows_release_url() -> str | None:
|
||||
"""Return the newest Windows executable, including prerelease builds."""
|
||||
response = requests.get(
|
||||
DESKTOP_RELEASES_API,
|
||||
headers={"Accept": "application/json"},
|
||||
timeout=10,
|
||||
)
|
||||
response.raise_for_status()
|
||||
releases = response.json()
|
||||
if not isinstance(releases, list):
|
||||
raise ValueError("Unexpected Gitea releases response")
|
||||
|
||||
published = sorted(
|
||||
(release for release in releases if not release.get("draft")),
|
||||
key=lambda release: release.get("published_at") or release.get("created_at") or "",
|
||||
reverse=True,
|
||||
)
|
||||
stable = [release for release in published if not release.get("prerelease")]
|
||||
candidates = stable or published
|
||||
for release in candidates:
|
||||
assets = release.get("assets") or []
|
||||
executables = [
|
||||
asset
|
||||
for asset in assets
|
||||
if str(asset.get("name") or "").lower().endswith(".exe")
|
||||
]
|
||||
executables.sort(
|
||||
key=lambda asset: (
|
||||
str(asset.get("name") or "").lower() != "progressionloader.exe",
|
||||
str(asset.get("name") or "").lower(),
|
||||
)
|
||||
)
|
||||
for asset in executables:
|
||||
download_url = str(asset.get("browser_download_url") or "")
|
||||
parsed = urlparse(download_url)
|
||||
if parsed.scheme == "https" and parsed.netloc == DESKTOP_RELEASE_HOST:
|
||||
return download_url
|
||||
return None
|
||||
|
||||
|
||||
def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
settings = settings or Settings.load()
|
||||
database = Database(settings.database_path)
|
||||
database.initialize()
|
||||
runner = SteamCmdRunner(
|
||||
settings.steamcmd_path,
|
||||
settings.workshop_content_dir,
|
||||
settings.steamcmd_timeout_seconds,
|
||||
)
|
||||
service = RefreshService(settings, database, runner=runner)
|
||||
coordinator = RefreshCoordinator(
|
||||
service,
|
||||
settings.refresh_interval_seconds,
|
||||
settings.refresh_on_startup,
|
||||
settings.initial_download_timeout_seconds,
|
||||
)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI):
|
||||
await coordinator.start()
|
||||
yield
|
||||
await coordinator.stop()
|
||||
|
||||
app = FastAPI(
|
||||
title="Progression Mods Backend",
|
||||
version="0.1.0",
|
||||
lifespan=lifespan,
|
||||
docs_url="/api/docs",
|
||||
redoc_url=None,
|
||||
)
|
||||
app.state.settings = settings
|
||||
app.state.database = database
|
||||
app.state.coordinator = coordinator
|
||||
app.state.runner = runner
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health() -> dict:
|
||||
return {
|
||||
"status": "ok",
|
||||
"steamcmd_available": runner.available(),
|
||||
"workshop_content_dir": str(settings.workshop_content_dir),
|
||||
"scheduler": coordinator.state(),
|
||||
}
|
||||
|
||||
@app.get("/api/status")
|
||||
async def dashboard_status() -> dict:
|
||||
return {
|
||||
**database.dashboard(),
|
||||
"scheduler": coordinator.state(),
|
||||
"steamcmd_available": runner.available(),
|
||||
"game_version": settings.game_version,
|
||||
}
|
||||
|
||||
@app.get("/api/analytics")
|
||||
async def analytics(limit: int = Query(24, ge=1, le=168)) -> dict:
|
||||
return {"runs": database.runs(limit)}
|
||||
|
||||
@app.get("/api/analytics/downloads")
|
||||
async def download_analytics(
|
||||
run_id: str | None = None,
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
) -> dict:
|
||||
if not run_id:
|
||||
runs = database.runs(1)
|
||||
run_id = runs[0]["id"] if runs else None
|
||||
return {
|
||||
"run_id": run_id,
|
||||
"attempts": database.download_attempts(run_id, limit) if run_id else [],
|
||||
}
|
||||
|
||||
@app.get("/api/mods")
|
||||
async def mods(
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
state: str | None = Query(None, pattern="^(ready|pending|failed|skipped)$"),
|
||||
) -> dict:
|
||||
clauses = [
|
||||
"workshop_id IN (SELECT DISTINCT workshop_id FROM collection_members)"
|
||||
]
|
||||
parameters: list[object] = []
|
||||
if state:
|
||||
clauses.append("status = ?")
|
||||
parameters.append(state)
|
||||
where = " AND ".join(clauses)
|
||||
total = database.fetch_one(
|
||||
f"SELECT COUNT(*) AS count FROM mods WHERE {where}", parameters
|
||||
) or {"count": 0}
|
||||
rows = database.fetch_all(
|
||||
f"""
|
||||
SELECT workshop_id, title, package_id, display_name, time_updated,
|
||||
metadata_file_size, disk_size, status, error, last_checked_at,
|
||||
last_downloaded_at
|
||||
FROM mods WHERE {where}
|
||||
ORDER BY CAST(workshop_id AS INTEGER)
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
[*parameters, limit, offset],
|
||||
)
|
||||
return {
|
||||
"total": total["count"],
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"items": rows,
|
||||
}
|
||||
|
||||
@app.post("/api/refresh", status_code=status.HTTP_202_ACCEPTED)
|
||||
async def refresh(authorization: str | None = Header(None)) -> dict:
|
||||
if settings.admin_token:
|
||||
expected = f"Bearer {settings.admin_token}"
|
||||
if not authorization or not secrets.compare_digest(authorization, expected):
|
||||
raise HTTPException(status_code=401, detail="Invalid admin token")
|
||||
run_id, started = await coordinator.trigger("manual")
|
||||
return {"run_id": run_id, "started": started}
|
||||
|
||||
def ready_workshop_rows(allow_partial: bool) -> list[dict]:
|
||||
rows = database.current_mods()
|
||||
failed = [
|
||||
row for row in rows if row.get("status") not in {"ready", "skipped"}
|
||||
]
|
||||
if failed and not allow_partial:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"message": "The mod list is incomplete",
|
||||
"not_ready": len(failed),
|
||||
"hint": "Run a refresh or request allow_partial=true",
|
||||
},
|
||||
)
|
||||
if not rows:
|
||||
raise HTTPException(
|
||||
status_code=409, detail="No collection data is available yet"
|
||||
)
|
||||
return rows
|
||||
|
||||
@app.get("/api/modlists/vanilla")
|
||||
async def vanilla_modlist(
|
||||
dlc: Annotated[list[str] | None, Query()] = None,
|
||||
allow_partial: Annotated[bool, Query()] = False,
|
||||
) -> Response:
|
||||
rows = ready_workshop_rows(allow_partial)
|
||||
rml = generate_rml(
|
||||
[*core_items(dlc or []), *workshop_items(rows)], settings.game_version
|
||||
)
|
||||
return Response(
|
||||
rml,
|
||||
media_type="application/xml",
|
||||
headers={
|
||||
"Content-Disposition": 'attachment; filename="ProgressionVanilla.rml"'
|
||||
},
|
||||
)
|
||||
|
||||
@app.get("/api/downloads/windows", include_in_schema=False)
|
||||
def latest_windows_download() -> RedirectResponse:
|
||||
try:
|
||||
download_url = latest_windows_release_url()
|
||||
except (requests.RequestException, TypeError, ValueError) as error:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="The Windows download could not be resolved from Gitea",
|
||||
) from error
|
||||
if not download_url:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="No Windows executable is attached to a published release",
|
||||
)
|
||||
return RedirectResponse(download_url, status_code=307)
|
||||
|
||||
@app.post("/api/modlists/homebrew")
|
||||
async def homebrew_modlist(
|
||||
mods_config: Annotated[UploadFile, File()],
|
||||
allow_partial: Annotated[bool, Query()] = False,
|
||||
) -> Response:
|
||||
if not mods_config.filename or not mods_config.filename.lower().endswith(
|
||||
".xml"
|
||||
):
|
||||
raise HTTPException(status_code=400, detail="Upload ModsConfig.xml")
|
||||
content = await mods_config.read(5 * 1024 * 1024 + 1)
|
||||
try:
|
||||
current = parse_active_mods(content)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=400, detail=str(error)) from error
|
||||
rows = ready_workshop_rows(allow_partial)
|
||||
by_package = {
|
||||
str(row.get("package_id") or "").lower(): row
|
||||
for row in rows
|
||||
if row.get("package_id")
|
||||
}
|
||||
hydrated = []
|
||||
for item in current:
|
||||
match = by_package.get(item.package_id.lower())
|
||||
if match:
|
||||
hydrated.extend(workshop_items([match]))
|
||||
else:
|
||||
hydrated.append(item)
|
||||
rml = generate_rml([*hydrated, *workshop_items(rows)], settings.game_version)
|
||||
return Response(
|
||||
rml,
|
||||
media_type="application/xml",
|
||||
headers={
|
||||
"Content-Disposition": 'attachment; filename="ProgressionHomebrew.rml"'
|
||||
},
|
||||
)
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
async def index() -> FileResponse:
|
||||
return FileResponse(settings.static_dir / "index.html")
|
||||
|
||||
app.mount("/static", StaticFiles(directory=settings.static_dir), name="static")
|
||||
art_dir = Path(settings.static_dir).parents[1] / "art"
|
||||
if art_dir.is_dir():
|
||||
app.mount("/art", StaticFiles(directory=art_dir), name="art")
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
@@ -0,0 +1,93 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
DEFAULT_COLLECTIONS = {
|
||||
"Core": "3521297585",
|
||||
"Content": "3521319712",
|
||||
"Cosmetics": "3637541646",
|
||||
}
|
||||
|
||||
|
||||
def _as_bool(value: str | None, default: bool) -> bool:
|
||||
if value is None:
|
||||
return default
|
||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _collection_id(value: str | None, fallback: str) -> str:
|
||||
"""Accept either a raw Workshop id or one of the existing URL settings."""
|
||||
if not value:
|
||||
return fallback
|
||||
digits = "".join(character for character in value if character.isdigit())
|
||||
return digits or fallback
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
host: str
|
||||
port: int
|
||||
database_path: Path
|
||||
steamcmd_path: str
|
||||
workshop_content_dir: Path
|
||||
steamcmd_batch_size: int
|
||||
steamcmd_timeout_seconds: int
|
||||
refresh_interval_seconds: int
|
||||
refresh_on_startup: bool
|
||||
initial_download_timeout_seconds: int
|
||||
game_version: str
|
||||
admin_token: str | None
|
||||
collections: dict[str, str]
|
||||
static_dir: Path
|
||||
|
||||
@classmethod
|
||||
def load(cls, root: Path | None = None) -> Settings:
|
||||
root = (root or Path.cwd()).resolve()
|
||||
load_dotenv(root / ".env")
|
||||
default_steamcmd = ".steamcmd/steamcmd.exe" if os.name == "nt" else "steamcmd"
|
||||
|
||||
return cls(
|
||||
host=os.getenv("PROGRESSION_HOST", "127.0.0.1"),
|
||||
port=int(os.getenv("PROGRESSION_PORT", "8000")),
|
||||
database_path=(
|
||||
root / os.getenv("PROGRESSION_DB_PATH", ".data/progression.db")
|
||||
).resolve(),
|
||||
steamcmd_path=os.getenv("STEAMCMD_PATH", default_steamcmd),
|
||||
workshop_content_dir=(
|
||||
root
|
||||
/ os.getenv(
|
||||
"STEAMCMD_WORKSHOP_DIR",
|
||||
".steamcmd/steamapps/workshop/content/294100",
|
||||
)
|
||||
).resolve(),
|
||||
steamcmd_batch_size=max(1, int(os.getenv("STEAMCMD_BATCH_SIZE", "25"))),
|
||||
steamcmd_timeout_seconds=max(
|
||||
60, int(os.getenv("STEAMCMD_TIMEOUT_SECONDS", "1800"))
|
||||
),
|
||||
refresh_interval_seconds=max(
|
||||
60, int(os.getenv("REFRESH_INTERVAL_SECONDS", "3600"))
|
||||
),
|
||||
refresh_on_startup=_as_bool(os.getenv("REFRESH_ON_STARTUP"), True),
|
||||
initial_download_timeout_seconds=max(
|
||||
60, int(os.getenv("INITIAL_DOWNLOAD_TIMEOUT_SECONDS", "43200"))
|
||||
),
|
||||
game_version=os.getenv("MODLIST_GAME_VERSION", "1.6").strip() or "1.6",
|
||||
admin_token=os.getenv("PROGRESSION_ADMIN_TOKEN") or None,
|
||||
collections={
|
||||
"Core": _collection_id(
|
||||
os.getenv("CORE_COLLECTION_URL"), DEFAULT_COLLECTIONS["Core"]
|
||||
),
|
||||
"Content": _collection_id(
|
||||
os.getenv("CONTENT_COLLECTION_URL"), DEFAULT_COLLECTIONS["Content"]
|
||||
),
|
||||
"Cosmetics": _collection_id(
|
||||
os.getenv("COSMETICS_COLLECTION_URL"),
|
||||
DEFAULT_COLLECTIONS["Cosmetics"],
|
||||
),
|
||||
},
|
||||
static_dir=root / "progression_backend" / "static",
|
||||
)
|
||||
@@ -0,0 +1,336 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from collections.abc import Iterator, Sequence
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS refresh_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
trigger TEXT NOT NULL,
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT,
|
||||
status TEXT NOT NULL,
|
||||
collection_items INTEGER NOT NULL DEFAULT 0,
|
||||
unique_items INTEGER NOT NULL DEFAULT 0,
|
||||
ready_items INTEGER NOT NULL DEFAULT 0,
|
||||
downloads_requested INTEGER NOT NULL DEFAULT 0,
|
||||
downloads_succeeded INTEGER NOT NULL DEFAULT 0,
|
||||
downloads_failed INTEGER NOT NULL DEFAULT 0,
|
||||
bytes_before INTEGER NOT NULL DEFAULT 0,
|
||||
bytes_after INTEGER NOT NULL DEFAULT 0,
|
||||
bytes_downloaded INTEGER NOT NULL DEFAULT 0,
|
||||
cache_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
elapsed_seconds REAL NOT NULL DEFAULT 0,
|
||||
average_mbps REAL NOT NULL DEFAULT 0,
|
||||
error TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mods (
|
||||
workshop_id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
package_id TEXT,
|
||||
steam_app_id TEXT,
|
||||
display_name TEXT,
|
||||
time_updated INTEGER NOT NULL DEFAULT 0,
|
||||
metadata_file_size INTEGER NOT NULL DEFAULT 0,
|
||||
disk_size INTEGER NOT NULL DEFAULT 0,
|
||||
local_path TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
error TEXT,
|
||||
last_checked_at TEXT,
|
||||
last_downloaded_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS collection_members (
|
||||
collection_id TEXT NOT NULL,
|
||||
collection_name TEXT NOT NULL,
|
||||
workshop_id TEXT NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
PRIMARY KEY (collection_id, workshop_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS download_attempts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
run_id TEXT NOT NULL,
|
||||
batch_number INTEGER NOT NULL,
|
||||
item_count INTEGER NOT NULL,
|
||||
workshop_ids TEXT NOT NULL,
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT NOT NULL,
|
||||
elapsed_seconds REAL NOT NULL,
|
||||
exit_code INTEGER,
|
||||
status TEXT NOT NULL,
|
||||
output_tail TEXT,
|
||||
FOREIGN KEY (run_id) REFERENCES refresh_runs(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_runs_started ON refresh_runs(started_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_mods_status ON mods(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_attempts_run ON download_attempts(run_id);
|
||||
"""
|
||||
|
||||
|
||||
class Database:
|
||||
def __init__(self, path: Path):
|
||||
self.path = path
|
||||
|
||||
@contextmanager
|
||||
def connect(self) -> Iterator[sqlite3.Connection]:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
connection = sqlite3.connect(self.path, timeout=30)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA foreign_keys = ON")
|
||||
connection.execute("PRAGMA journal_mode = WAL")
|
||||
try:
|
||||
yield connection
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def initialize(self) -> None:
|
||||
with self.connect() as connection:
|
||||
connection.executescript(SCHEMA)
|
||||
|
||||
def execute(self, sql: str, parameters: Sequence[Any] = ()) -> None:
|
||||
with self.connect() as connection:
|
||||
connection.execute(sql, parameters)
|
||||
|
||||
def fetch_one(
|
||||
self, sql: str, parameters: Sequence[Any] = ()
|
||||
) -> dict[str, Any] | None:
|
||||
with self.connect() as connection:
|
||||
row = connection.execute(sql, parameters).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def fetch_all(
|
||||
self, sql: str, parameters: Sequence[Any] = ()
|
||||
) -> list[dict[str, Any]]:
|
||||
with self.connect() as connection:
|
||||
rows = connection.execute(sql, parameters).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def create_run(self, run_id: str, trigger: str, started_at: str) -> None:
|
||||
self.execute(
|
||||
"INSERT INTO refresh_runs (id, trigger, started_at, status) VALUES (?, ?, ?, 'running')",
|
||||
(run_id, trigger, started_at),
|
||||
)
|
||||
|
||||
def finish_run(self, run_id: str, values: dict[str, Any]) -> None:
|
||||
allowed = {
|
||||
"finished_at",
|
||||
"status",
|
||||
"collection_items",
|
||||
"unique_items",
|
||||
"ready_items",
|
||||
"downloads_requested",
|
||||
"downloads_succeeded",
|
||||
"downloads_failed",
|
||||
"bytes_before",
|
||||
"bytes_after",
|
||||
"bytes_downloaded",
|
||||
"cache_bytes",
|
||||
"elapsed_seconds",
|
||||
"average_mbps",
|
||||
"error",
|
||||
}
|
||||
selected = {key: value for key, value in values.items() if key in allowed}
|
||||
assignments = ", ".join(f"{key} = ?" for key in selected)
|
||||
self.execute(
|
||||
f"UPDATE refresh_runs SET {assignments} WHERE id = ?",
|
||||
(*selected.values(), run_id),
|
||||
)
|
||||
|
||||
def replace_collection_members(
|
||||
self, collections: dict[str, tuple[str, list[str]]]
|
||||
) -> None:
|
||||
with self.connect() as connection:
|
||||
connection.execute("DELETE FROM collection_members")
|
||||
for name, (collection_id, workshop_ids) in collections.items():
|
||||
connection.executemany(
|
||||
"""
|
||||
INSERT INTO collection_members
|
||||
(collection_id, collection_name, workshop_id, position)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
[
|
||||
(collection_id, name, workshop_id, position)
|
||||
for position, workshop_id in enumerate(workshop_ids)
|
||||
],
|
||||
)
|
||||
|
||||
def _upsert_mod(
|
||||
self, connection: sqlite3.Connection, values: dict[str, Any]
|
||||
) -> None:
|
||||
columns = [
|
||||
"workshop_id",
|
||||
"title",
|
||||
"package_id",
|
||||
"steam_app_id",
|
||||
"display_name",
|
||||
"time_updated",
|
||||
"metadata_file_size",
|
||||
"disk_size",
|
||||
"local_path",
|
||||
"status",
|
||||
"error",
|
||||
"last_checked_at",
|
||||
"last_downloaded_at",
|
||||
]
|
||||
workshop_id = str(values["workshop_id"])
|
||||
existing_row = connection.execute(
|
||||
"SELECT * FROM mods WHERE workshop_id = ?", (workshop_id,)
|
||||
).fetchone()
|
||||
existing = dict(existing_row) if existing_row else {}
|
||||
defaults = {
|
||||
"title": "",
|
||||
"time_updated": 0,
|
||||
"metadata_file_size": 0,
|
||||
"disk_size": 0,
|
||||
"status": "pending",
|
||||
}
|
||||
payload: dict[str, Any] = {"workshop_id": workshop_id}
|
||||
for column in columns[1:]:
|
||||
candidate = values.get(column)
|
||||
if candidate is not None:
|
||||
payload[column] = candidate
|
||||
elif column in existing:
|
||||
payload[column] = existing[column]
|
||||
else:
|
||||
payload[column] = defaults.get(column)
|
||||
updates = ", ".join(
|
||||
f"{column} = excluded.{column}"
|
||||
for column in columns
|
||||
if column != "workshop_id"
|
||||
)
|
||||
placeholders = ", ".join("?" for _ in columns)
|
||||
connection.execute(
|
||||
f"""
|
||||
INSERT INTO mods ({", ".join(columns)}) VALUES ({placeholders})
|
||||
ON CONFLICT(workshop_id) DO UPDATE SET {updates}
|
||||
""",
|
||||
tuple(payload[column] for column in columns),
|
||||
)
|
||||
|
||||
def upsert_mod(self, values: dict[str, Any]) -> None:
|
||||
with self.connect() as connection:
|
||||
self._upsert_mod(connection, values)
|
||||
|
||||
def upsert_mods(self, values: Sequence[dict[str, Any]]) -> None:
|
||||
"""Upsert a metadata page in one transaction instead of per-mod commits."""
|
||||
with self.connect() as connection:
|
||||
for value in values:
|
||||
self._upsert_mod(connection, value)
|
||||
|
||||
def add_download_attempt(self, values: dict[str, Any]) -> None:
|
||||
self.execute(
|
||||
"""
|
||||
INSERT INTO download_attempts
|
||||
(run_id, batch_number, item_count, workshop_ids, started_at,
|
||||
finished_at, elapsed_seconds, exit_code, status, output_tail)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
values["run_id"],
|
||||
values["batch_number"],
|
||||
values["item_count"],
|
||||
json.dumps(values["workshop_ids"]),
|
||||
values["started_at"],
|
||||
values["finished_at"],
|
||||
values["elapsed_seconds"],
|
||||
values.get("exit_code"),
|
||||
values["status"],
|
||||
values.get("output_tail"),
|
||||
),
|
||||
)
|
||||
|
||||
def dashboard(self) -> dict[str, Any]:
|
||||
recent_runs = self.runs(1)
|
||||
latest = recent_runs[0] if recent_runs else None
|
||||
last_update = self.fetch_one(
|
||||
"""
|
||||
SELECT finished_at
|
||||
FROM refresh_runs
|
||||
WHERE finished_at IS NOT NULL AND unique_items > 0
|
||||
ORDER BY finished_at DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
totals = (
|
||||
self.fetch_one(
|
||||
"""
|
||||
SELECT COUNT(*) AS total_mods,
|
||||
SUM(CASE WHEN status = 'ready' THEN 1 ELSE 0 END) AS ready_mods,
|
||||
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed_mods,
|
||||
SUM(CASE WHEN status = 'skipped' THEN 1 ELSE 0 END) AS skipped_mods,
|
||||
COALESCE(SUM(disk_size), 0) AS cache_bytes
|
||||
FROM mods
|
||||
WHERE workshop_id IN (SELECT DISTINCT workshop_id FROM collection_members)
|
||||
"""
|
||||
)
|
||||
or {}
|
||||
)
|
||||
collections = self.fetch_all(
|
||||
"""
|
||||
SELECT collection_name AS name, collection_id AS id, COUNT(*) AS item_count
|
||||
FROM collection_members
|
||||
GROUP BY collection_name, collection_id
|
||||
ORDER BY CASE collection_name
|
||||
WHEN 'Core' THEN 1 WHEN 'Content' THEN 2 WHEN 'Cosmetics' THEN 3 ELSE 4 END
|
||||
"""
|
||||
)
|
||||
return {
|
||||
"latest_run": latest,
|
||||
"rml_updated_at": last_update["finished_at"] if last_update else None,
|
||||
"totals": totals,
|
||||
"collections": collections,
|
||||
}
|
||||
|
||||
def runs(self, limit: int = 48) -> list[dict[str, Any]]:
|
||||
return self.fetch_all(
|
||||
"""
|
||||
SELECT r.*,
|
||||
COALESCE(SUM(a.elapsed_seconds), 0) AS download_elapsed_seconds,
|
||||
COUNT(a.id) AS download_batches
|
||||
FROM refresh_runs r
|
||||
LEFT JOIN download_attempts a ON a.run_id = r.id
|
||||
GROUP BY r.id
|
||||
ORDER BY r.started_at DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
|
||||
def download_attempts(self, run_id: str, limit: int = 100) -> list[dict[str, Any]]:
|
||||
return self.fetch_all(
|
||||
"""
|
||||
SELECT batch_number, item_count, workshop_ids, started_at, finished_at,
|
||||
elapsed_seconds, exit_code, status
|
||||
FROM download_attempts
|
||||
WHERE run_id = ?
|
||||
ORDER BY batch_number
|
||||
LIMIT ?
|
||||
""",
|
||||
(run_id, limit),
|
||||
)
|
||||
|
||||
def current_mods(self) -> list[dict[str, Any]]:
|
||||
return self.fetch_all(
|
||||
"""
|
||||
SELECT * FROM mods
|
||||
WHERE workshop_id IN (SELECT DISTINCT workshop_id FROM collection_members)
|
||||
ORDER BY CAST(workshop_id AS INTEGER)
|
||||
"""
|
||||
)
|
||||
|
||||
def mod_map(self, workshop_ids: Sequence[str]) -> dict[str, dict[str, Any]]:
|
||||
if not workshop_ids:
|
||||
return {}
|
||||
placeholders = ",".join("?" for _ in workshop_ids)
|
||||
rows = self.fetch_all(
|
||||
f"SELECT * FROM mods WHERE workshop_id IN ({placeholders})", workshop_ids
|
||||
)
|
||||
return {row["workshop_id"]: row for row in rows}
|
||||
@@ -0,0 +1,110 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import xml.etree.ElementTree as ET
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
|
||||
DLC = {
|
||||
"royalty": ("ludeon.rimworld.royalty", "Royalty"),
|
||||
"ideology": ("ludeon.rimworld.ideology", "Ideology"),
|
||||
"biotech": ("ludeon.rimworld.biotech", "Biotech"),
|
||||
"anomaly": ("ludeon.rimworld.anomaly", "Anomaly"),
|
||||
"odyssey": ("ludeon.rimworld.odyssey", "Odyssey"),
|
||||
}
|
||||
BASE_GAME = ("ludeon.rimworld", "Core")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModListItem:
|
||||
package_id: str
|
||||
workshop_id: str
|
||||
name: str
|
||||
|
||||
|
||||
def core_items(selected_dlc: Iterable[str]) -> list[ModListItem]:
|
||||
result = [ModListItem(BASE_GAME[0], "0", BASE_GAME[1])]
|
||||
normalized = {value.strip().lower() for value in selected_dlc}
|
||||
for key, (package_id, name) in DLC.items():
|
||||
if key in normalized:
|
||||
result.append(ModListItem(package_id, "0", name))
|
||||
return result
|
||||
|
||||
|
||||
def workshop_items(rows: Iterable[dict]) -> list[ModListItem]:
|
||||
result: list[ModListItem] = []
|
||||
for row in rows:
|
||||
if row.get("status") != "ready" or not row.get("package_id"):
|
||||
continue
|
||||
# The Workshop id is the correct RimWorld modSteamIds value. The old loader
|
||||
# sometimes substituted About.xml's steamAppId, which can be the game app id.
|
||||
result.append(
|
||||
ModListItem(
|
||||
package_id=str(row["package_id"]),
|
||||
workshop_id=str(row["workshop_id"]),
|
||||
name=str(
|
||||
row.get("display_name") or row.get("title") or row["package_id"]
|
||||
),
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def deduplicate(items: Iterable[ModListItem]) -> list[ModListItem]:
|
||||
result: list[ModListItem] = []
|
||||
seen_packages: set[str] = set()
|
||||
seen_workshop_ids: set[str] = set()
|
||||
for item in items:
|
||||
package_key = item.package_id.lower()
|
||||
if package_key in seen_packages:
|
||||
continue
|
||||
if item.workshop_id != "0" and item.workshop_id in seen_workshop_ids:
|
||||
continue
|
||||
seen_packages.add(package_key)
|
||||
if item.workshop_id != "0":
|
||||
seen_workshop_ids.add(item.workshop_id)
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
|
||||
def parse_active_mods(xml_bytes: bytes) -> list[ModListItem]:
|
||||
if len(xml_bytes) > 5 * 1024 * 1024:
|
||||
raise ValueError("ModsConfig.xml is larger than 5 MB")
|
||||
try:
|
||||
root = ET.fromstring(xml_bytes)
|
||||
except ET.ParseError as error:
|
||||
raise ValueError(f"ModsConfig.xml is invalid: {error}") from error
|
||||
active = root.find("activeMods")
|
||||
if active is None:
|
||||
raise ValueError("ModsConfig.xml has no activeMods section")
|
||||
items: list[ModListItem] = []
|
||||
for entry in active.findall("li"):
|
||||
package_id = (entry.text or "").strip()
|
||||
if package_id:
|
||||
items.append(ModListItem(package_id, "0", package_id))
|
||||
return items
|
||||
|
||||
|
||||
def _append_list(parent: ET.Element, name: str, values: Iterable[str]) -> None:
|
||||
element = ET.SubElement(parent, name)
|
||||
for value in values:
|
||||
ET.SubElement(element, "li").text = value
|
||||
|
||||
|
||||
def generate_rml(items: Iterable[ModListItem], game_version: str) -> bytes:
|
||||
normalized = deduplicate(items)
|
||||
root = ET.Element("savedModList")
|
||||
meta = ET.SubElement(root, "meta")
|
||||
ET.SubElement(meta, "gameVersion").text = game_version
|
||||
_append_list(meta, "modIds", (item.package_id for item in normalized))
|
||||
_append_list(meta, "modSteamIds", (item.workshop_id for item in normalized))
|
||||
_append_list(meta, "modNames", (item.name for item in normalized))
|
||||
|
||||
mod_list = ET.SubElement(root, "modList")
|
||||
_append_list(mod_list, "ids", (item.package_id for item in normalized))
|
||||
_append_list(mod_list, "names", (item.name for item in normalized))
|
||||
|
||||
ET.indent(root, space="\t")
|
||||
output = io.BytesIO()
|
||||
ET.ElementTree(root).write(output, encoding="utf-8", xml_declaration=True)
|
||||
return output.getvalue()
|
||||
@@ -0,0 +1,660 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import shutil
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from .config import Settings
|
||||
from .database import Database
|
||||
from .steam_api import SteamWorkshopClient, WorkshopDetails
|
||||
from .workshop import SteamCmdRunner, directory_size, parse_about_xml, utc_now
|
||||
|
||||
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
ProgressCallback = Callable[[dict[str, Any]], None]
|
||||
|
||||
|
||||
class InitialDownloadTimeout(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _batches(values: list[str], size: int) -> list[list[str]]:
|
||||
return [values[index : index + size] for index in range(0, len(values), size)]
|
||||
|
||||
|
||||
class RefreshService:
|
||||
def __init__(
|
||||
self,
|
||||
settings: Settings,
|
||||
database: Database,
|
||||
steam: SteamWorkshopClient | None = None,
|
||||
runner: SteamCmdRunner | None = None,
|
||||
):
|
||||
self.settings = settings
|
||||
self.database = database
|
||||
self.steam = steam or SteamWorkshopClient()
|
||||
self.runner = runner or SteamCmdRunner(
|
||||
settings.steamcmd_path,
|
||||
settings.workshop_content_dir,
|
||||
settings.steamcmd_timeout_seconds,
|
||||
)
|
||||
|
||||
def needs_initial_download(self) -> bool:
|
||||
rows = self.database.current_mods()
|
||||
if not rows:
|
||||
return True
|
||||
for row in rows:
|
||||
if row.get("status") == "skipped":
|
||||
continue
|
||||
if row.get("status") != "ready" or not (
|
||||
self.settings.workshop_content_dir / row["workshop_id"]
|
||||
).is_dir():
|
||||
return True
|
||||
return False
|
||||
|
||||
def _store_metadata(
|
||||
self,
|
||||
detail: WorkshopDetails,
|
||||
previous: dict[str, Any] | None,
|
||||
stale: bool,
|
||||
skip_reason: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
invalid_reason = skip_reason
|
||||
if invalid_reason is None and detail.result != 1:
|
||||
invalid_reason = f"Steam returned result {detail.result}"
|
||||
elif invalid_reason is None and detail.consumer_app_id not in (0, 294100):
|
||||
invalid_reason = f"Workshop item belongs to app {detail.consumer_app_id}"
|
||||
return {
|
||||
"workshop_id": detail.workshop_id,
|
||||
"title": detail.title,
|
||||
"package_id": None,
|
||||
"steam_app_id": None,
|
||||
"display_name": None,
|
||||
"time_updated": detail.time_updated,
|
||||
"metadata_file_size": detail.file_size,
|
||||
"disk_size": None,
|
||||
"local_path": None,
|
||||
"status": "skipped"
|
||||
if invalid_reason
|
||||
else ("pending" if stale else (previous or {}).get("status", "pending")),
|
||||
"error": invalid_reason,
|
||||
"last_checked_at": utc_now(),
|
||||
"last_downloaded_at": None,
|
||||
}
|
||||
|
||||
def _inspection_values(
|
||||
self,
|
||||
workshop_id: str,
|
||||
downloaded: bool,
|
||||
forced_error: str | None = None,
|
||||
) -> tuple[bool, dict[str, Any]]:
|
||||
mod_path = self.settings.workshop_content_dir / workshop_id
|
||||
if forced_error:
|
||||
return False, {
|
||||
"workshop_id": workshop_id,
|
||||
"status": "failed",
|
||||
"error": forced_error,
|
||||
"disk_size": directory_size(mod_path),
|
||||
"local_path": str(mod_path) if mod_path.exists() else None,
|
||||
"last_checked_at": utc_now(),
|
||||
"last_downloaded_at": utc_now() if downloaded else None,
|
||||
}
|
||||
try:
|
||||
parsed = parse_about_xml(mod_path)
|
||||
return True, {
|
||||
"workshop_id": workshop_id,
|
||||
"package_id": parsed.package_id,
|
||||
"steam_app_id": parsed.declared_steam_app_id,
|
||||
"display_name": parsed.display_name,
|
||||
"disk_size": directory_size(mod_path),
|
||||
"local_path": str(mod_path),
|
||||
"status": "ready",
|
||||
"error": "",
|
||||
"last_checked_at": utc_now(),
|
||||
"last_downloaded_at": utc_now() if downloaded else None,
|
||||
}
|
||||
except (OSError, ValueError) as error:
|
||||
return False, {
|
||||
"workshop_id": workshop_id,
|
||||
"status": "failed",
|
||||
"error": str(error),
|
||||
"disk_size": directory_size(mod_path),
|
||||
"local_path": str(mod_path) if mod_path.exists() else None,
|
||||
"last_checked_at": utc_now(),
|
||||
"last_downloaded_at": utc_now() if downloaded else None,
|
||||
}
|
||||
|
||||
def refresh(
|
||||
self,
|
||||
run_id: str,
|
||||
trigger: str,
|
||||
*,
|
||||
initial_download: bool = False,
|
||||
overall_timeout_seconds: int | None = None,
|
||||
progress: ProgressCallback | None = None,
|
||||
) -> None:
|
||||
started_at = utc_now()
|
||||
started = time.monotonic()
|
||||
deadline = (
|
||||
started + overall_timeout_seconds
|
||||
if initial_download and overall_timeout_seconds is not None
|
||||
else None
|
||||
)
|
||||
self.database.create_run(run_id, trigger, started_at)
|
||||
metrics: dict[str, Any] = {
|
||||
"collection_items": 0,
|
||||
"unique_items": 0,
|
||||
"ready_items": 0,
|
||||
"downloads_requested": 0,
|
||||
"downloads_succeeded": 0,
|
||||
"downloads_failed": 0,
|
||||
"bytes_before": directory_size(self.settings.workshop_content_dir),
|
||||
"bytes_after": 0,
|
||||
"bytes_downloaded": 0,
|
||||
"cache_bytes": 0,
|
||||
"status": "failed",
|
||||
"error": None,
|
||||
}
|
||||
|
||||
def report(**values: Any) -> None:
|
||||
if progress:
|
||||
progress(values)
|
||||
|
||||
def remaining_time() -> float | None:
|
||||
if deadline is None:
|
||||
return None
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise InitialDownloadTimeout(
|
||||
"The initial Workshop download reached its "
|
||||
f"{overall_timeout_seconds}-second limit"
|
||||
)
|
||||
return remaining
|
||||
|
||||
try:
|
||||
report(stage="collections", completed_mods=0, total_mods=0, percent=0.0)
|
||||
remaining_time()
|
||||
collection_ids = list(self.settings.collections.values())
|
||||
response, collection_reference_ids = self.steam.get_collections(collection_ids)
|
||||
named_collections: dict[str, tuple[str, list[str]]] = {}
|
||||
all_ids: list[str] = []
|
||||
for name, collection_id in self.settings.collections.items():
|
||||
workshop_ids = response.get(collection_id)
|
||||
if workshop_ids is None:
|
||||
raise RuntimeError(
|
||||
f"Steam did not return collection {collection_id}"
|
||||
)
|
||||
named_collections[name] = (collection_id, workshop_ids)
|
||||
all_ids.extend(workshop_ids)
|
||||
self.database.replace_collection_members(named_collections)
|
||||
|
||||
unique_ids = sorted(set(all_ids), key=int)
|
||||
metrics["collection_items"] = len(all_ids)
|
||||
metrics["unique_items"] = len(unique_ids)
|
||||
details = self.steam.get_item_details(unique_ids)
|
||||
previous = self.database.mod_map(unique_ids)
|
||||
|
||||
targets: list[str] = []
|
||||
metadata_rows: list[dict[str, Any]] = []
|
||||
for workshop_id in unique_ids:
|
||||
detail = details.get(workshop_id)
|
||||
if not detail:
|
||||
self.database.upsert_mod(
|
||||
{
|
||||
"workshop_id": workshop_id,
|
||||
"status": "skipped",
|
||||
"error": "Steam returned no item metadata",
|
||||
"last_checked_at": utc_now(),
|
||||
}
|
||||
)
|
||||
continue
|
||||
old = previous.get(workshop_id)
|
||||
local_path = self.settings.workshop_content_dir / workshop_id
|
||||
stale = (
|
||||
old is None
|
||||
or int(old.get("time_updated") or 0) != detail.time_updated
|
||||
or old.get("status") != "ready"
|
||||
or not local_path.is_dir()
|
||||
)
|
||||
skip_reason = (
|
||||
"Workshop entry is a collection reference"
|
||||
if workshop_id in collection_reference_ids
|
||||
else None
|
||||
)
|
||||
metadata_rows.append(
|
||||
self._store_metadata(detail, old, stale, skip_reason=skip_reason)
|
||||
)
|
||||
if (
|
||||
detail.result == 1
|
||||
and detail.consumer_app_id in (0, 294100)
|
||||
and workshop_id not in collection_reference_ids
|
||||
and stale
|
||||
):
|
||||
targets.append(workshop_id)
|
||||
|
||||
self.database.upsert_mods(metadata_rows)
|
||||
|
||||
metrics["downloads_requested"] = len(targets)
|
||||
total_targets = len(targets)
|
||||
completed_targets = 0
|
||||
current_after_metadata = self.database.mod_map(unique_ids)
|
||||
resolved_before = sum(
|
||||
1
|
||||
for row in current_after_metadata.values()
|
||||
if row.get("status") in {"ready", "skipped"}
|
||||
)
|
||||
progress_total = len(unique_ids) if initial_download else total_targets
|
||||
progress_completed = resolved_before if initial_download else 0
|
||||
title_by_id = {
|
||||
workshop_id: details[workshop_id].title
|
||||
for workshop_id in targets
|
||||
if workshop_id in details
|
||||
}
|
||||
report(
|
||||
stage="downloading" if targets else "inspecting",
|
||||
completed_mods=progress_completed,
|
||||
total_mods=progress_total,
|
||||
percent=round(
|
||||
(progress_completed / max(progress_total, 1)) * 100, 1
|
||||
)
|
||||
if progress_total
|
||||
else 100.0,
|
||||
)
|
||||
if initial_download:
|
||||
logger.info(
|
||||
"Initial Workshop download started: %d mods need download, "
|
||||
"%d/%d collection entries already resolved, %.1f-hour hard limit",
|
||||
total_targets,
|
||||
resolved_before,
|
||||
len(unique_ids),
|
||||
(overall_timeout_seconds or 0) / 3600,
|
||||
)
|
||||
if targets:
|
||||
remaining_bytes = sum(
|
||||
max(int(details[workshop_id].file_size or 0), 0)
|
||||
for workshop_id in targets
|
||||
if workshop_id in details
|
||||
)
|
||||
workshop_root = self.settings.workshop_content_dir.parent.parent
|
||||
workshop_root.mkdir(parents=True, exist_ok=True)
|
||||
free_bytes = shutil.disk_usage(workshop_root).free
|
||||
required_bytes = int(remaining_bytes * 1.1)
|
||||
logger.info(
|
||||
"Workshop capacity check: %.2f GiB expected, %.2f GiB free",
|
||||
remaining_bytes / (1024**3),
|
||||
free_bytes / (1024**3),
|
||||
)
|
||||
if required_bytes and free_bytes < required_bytes:
|
||||
raise RuntimeError(
|
||||
"Insufficient Workshop volume space: "
|
||||
f"{free_bytes / (1024**3):.2f} GiB free, "
|
||||
f"approximately {required_bytes / (1024**3):.2f} GiB required"
|
||||
)
|
||||
if targets and not self.runner.available():
|
||||
reason = (
|
||||
f"SteamCMD was not found at '{self.settings.steamcmd_path}'. "
|
||||
"Set STEAMCMD_PATH to enable anonymous downloads."
|
||||
)
|
||||
inspection_rows: list[dict[str, Any]] = []
|
||||
for workshop_id in targets:
|
||||
mod_path = self.settings.workshop_content_dir / workshop_id
|
||||
success, values = self._inspection_values(
|
||||
workshop_id,
|
||||
downloaded=False,
|
||||
forced_error=None if mod_path.is_dir() else reason,
|
||||
)
|
||||
inspection_rows.append(values)
|
||||
if not success:
|
||||
metrics["downloads_failed"] += 1
|
||||
self.database.upsert_mods(inspection_rows)
|
||||
else:
|
||||
download_queue = list(targets)
|
||||
ready_target_ids: set[str] = set()
|
||||
batch_number = 0
|
||||
pass_number = 1
|
||||
while download_queue:
|
||||
# SteamCMD stops processing the rest of a command when one
|
||||
# Workshop item times out. Keep the first pass efficient,
|
||||
# then isolate every remaining item so one bad mod cannot
|
||||
# permanently block the items that followed it in a batch.
|
||||
pass_batch_size = (
|
||||
self.settings.steamcmd_batch_size if pass_number == 1 else 1
|
||||
)
|
||||
batches = _batches(download_queue, pass_batch_size)
|
||||
if pass_number > 1:
|
||||
logger.info(
|
||||
"Initial Workshop download retry pass %d is "
|
||||
"isolating %d remaining mods individually",
|
||||
pass_number,
|
||||
len(download_queue),
|
||||
)
|
||||
for pass_batch_number, batch in enumerate(batches, start=1):
|
||||
remaining = remaining_time()
|
||||
batch_number += 1
|
||||
logger.info(
|
||||
"%s pass %d batch %d/%d starting (%d mods): %s",
|
||||
"Initial Workshop download"
|
||||
if initial_download
|
||||
else "Workshop refresh",
|
||||
pass_number,
|
||||
pass_batch_number,
|
||||
len(batches),
|
||||
len(batch),
|
||||
", ".join(
|
||||
f"{title_by_id.get(workshop_id, 'Unknown mod')} "
|
||||
f"[{workshop_id}]"
|
||||
for workshop_id in batch
|
||||
),
|
||||
)
|
||||
result = self.runner.run_batch(
|
||||
batch, timeout_seconds=remaining
|
||||
)
|
||||
if result.status != "completed":
|
||||
logger.warning(
|
||||
"SteamCMD pass %d batch %d/%d finished with "
|
||||
"status=%s exit_code=%s after %.1fs. Output:\n%s",
|
||||
pass_number,
|
||||
pass_batch_number,
|
||||
len(batches),
|
||||
result.status,
|
||||
result.exit_code,
|
||||
result.elapsed_seconds,
|
||||
result.output_tail or "(no SteamCMD output)",
|
||||
)
|
||||
if result.has_io_failure:
|
||||
recover = getattr(self.runner, "recover_io_failure", None)
|
||||
moved = recover() if callable(recover) else []
|
||||
if moved:
|
||||
logger.warning(
|
||||
"SteamCMD I/O recovery quarantined transient "
|
||||
"Workshop state: %s",
|
||||
", ".join(str(path) for path in moved),
|
||||
)
|
||||
self.database.add_download_attempt(
|
||||
{
|
||||
"run_id": run_id,
|
||||
"batch_number": batch_number,
|
||||
"item_count": len(batch),
|
||||
"workshop_ids": batch,
|
||||
"started_at": result.started_at,
|
||||
"finished_at": result.finished_at,
|
||||
"elapsed_seconds": result.elapsed_seconds,
|
||||
"exit_code": result.exit_code,
|
||||
"status": result.status,
|
||||
"output_tail": result.output_tail,
|
||||
}
|
||||
)
|
||||
inspection_rows = []
|
||||
for workshop_id in batch:
|
||||
failure = (
|
||||
(
|
||||
"SteamCMD did not complete this item; "
|
||||
"queued for retry"
|
||||
if initial_download
|
||||
else "SteamCMD reported a download failure"
|
||||
)
|
||||
if workshop_id in result.failed_ids
|
||||
else None
|
||||
)
|
||||
success, values = self._inspection_values(
|
||||
workshop_id, downloaded=True, forced_error=failure
|
||||
)
|
||||
inspection_rows.append(values)
|
||||
if success:
|
||||
metrics["downloads_succeeded"] += 1
|
||||
ready_target_ids.add(workshop_id)
|
||||
else:
|
||||
metrics["downloads_failed"] += 1
|
||||
completed_targets = len(ready_target_ids)
|
||||
progress_completed = (
|
||||
resolved_before + completed_targets
|
||||
if initial_download
|
||||
else completed_targets
|
||||
)
|
||||
percent = round(
|
||||
(progress_completed / max(progress_total, 1)) * 100,
|
||||
1,
|
||||
)
|
||||
title = title_by_id.get(workshop_id, "Unknown mod")
|
||||
outcome = (
|
||||
"ready"
|
||||
if success
|
||||
else ("retrying" if initial_download else "failed")
|
||||
)
|
||||
report(
|
||||
stage="downloading",
|
||||
completed_mods=progress_completed,
|
||||
total_mods=progress_total,
|
||||
percent=percent,
|
||||
current_workshop_id=workshop_id,
|
||||
current_title=title,
|
||||
current_status=outcome,
|
||||
)
|
||||
logger.info(
|
||||
"%s %d/%d (%.1f%%) %s: %s [%s]",
|
||||
"Initial mod download"
|
||||
if initial_download
|
||||
else "Mod refresh",
|
||||
progress_completed,
|
||||
progress_total,
|
||||
percent,
|
||||
outcome,
|
||||
title,
|
||||
workshop_id,
|
||||
)
|
||||
self.database.upsert_mods(inspection_rows)
|
||||
remaining_time()
|
||||
|
||||
if not initial_download:
|
||||
break
|
||||
download_queue = [
|
||||
workshop_id
|
||||
for workshop_id in download_queue
|
||||
if workshop_id not in ready_target_ids
|
||||
]
|
||||
if download_queue:
|
||||
remaining = remaining_time()
|
||||
retry_delay = min(30.0, remaining or 30.0)
|
||||
logger.warning(
|
||||
"Initial Workshop download pass %d left %d mods; "
|
||||
"retrying in %.0f seconds",
|
||||
pass_number,
|
||||
len(download_queue),
|
||||
retry_delay,
|
||||
)
|
||||
time.sleep(retry_delay)
|
||||
remaining_time()
|
||||
pass_number += 1
|
||||
|
||||
# Re-inspect ready cached items whose metadata has never been parsed by this backend.
|
||||
current = self.database.mod_map(unique_ids)
|
||||
pending_rows = []
|
||||
for workshop_id in unique_ids:
|
||||
row = current.get(workshop_id)
|
||||
if row and row.get("status") == "pending":
|
||||
_, values = self._inspection_values(workshop_id, downloaded=False)
|
||||
pending_rows.append(values)
|
||||
if pending_rows:
|
||||
self.database.upsert_mods(pending_rows)
|
||||
|
||||
metrics["bytes_after"] = directory_size(self.settings.workshop_content_dir)
|
||||
metrics["cache_bytes"] = metrics["bytes_after"]
|
||||
metrics["bytes_downloaded"] = max(
|
||||
metrics["bytes_after"] - metrics["bytes_before"], 0
|
||||
)
|
||||
final_rows = self.database.mod_map(unique_ids)
|
||||
metrics["ready_items"] = sum(
|
||||
1 for row in final_rows.values() if row.get("status") == "ready"
|
||||
)
|
||||
resolved_items = sum(
|
||||
1
|
||||
for row in final_rows.values()
|
||||
if row.get("status") in {"ready", "skipped"}
|
||||
)
|
||||
metrics["status"] = (
|
||||
"completed"
|
||||
if resolved_items == metrics["unique_items"]
|
||||
else "partial"
|
||||
)
|
||||
if metrics["status"] == "partial" and not metrics["error"]:
|
||||
metrics["error"] = (
|
||||
f"{metrics['unique_items'] - resolved_items} items are not resolved"
|
||||
)
|
||||
report(
|
||||
stage=metrics["status"],
|
||||
completed_mods=resolved_items,
|
||||
total_mods=metrics["unique_items"],
|
||||
percent=round(
|
||||
(resolved_items / max(metrics["unique_items"], 1)) * 100, 1
|
||||
)
|
||||
if metrics["unique_items"]
|
||||
else 100.0,
|
||||
)
|
||||
except Exception as error: # noqa: BLE001 - the run must always persist a terminal state
|
||||
metrics["status"] = "failed"
|
||||
metrics["error"] = str(error)
|
||||
metrics["bytes_after"] = directory_size(self.settings.workshop_content_dir)
|
||||
metrics["cache_bytes"] = metrics["bytes_after"]
|
||||
metrics["bytes_downloaded"] = max(
|
||||
metrics["bytes_after"] - metrics["bytes_before"], 0
|
||||
)
|
||||
report(stage="failed", error=str(error))
|
||||
logger.error(
|
||||
"%s failed: %s",
|
||||
"Initial Workshop download" if initial_download else "Workshop refresh",
|
||||
error,
|
||||
)
|
||||
finally:
|
||||
if metrics["unique_items"]:
|
||||
metrics["ready_items"] = sum(
|
||||
1
|
||||
for row in self.database.current_mods()
|
||||
if row.get("status") == "ready"
|
||||
)
|
||||
elapsed = max(time.monotonic() - started, 0.001)
|
||||
metrics["elapsed_seconds"] = round(elapsed, 3)
|
||||
metrics["average_mbps"] = round(
|
||||
(metrics["bytes_downloaded"] * 8 / 1_000_000) / elapsed, 3
|
||||
)
|
||||
metrics["finished_at"] = utc_now()
|
||||
self.database.finish_run(run_id, metrics)
|
||||
logger.info(
|
||||
"%s finished: status=%s, ready=%d/%d, elapsed=%.1fs, "
|
||||
"cache=%d bytes",
|
||||
"Initial Workshop download" if initial_download else "Workshop refresh",
|
||||
metrics["status"],
|
||||
metrics["ready_items"],
|
||||
metrics["unique_items"],
|
||||
elapsed,
|
||||
metrics["cache_bytes"],
|
||||
)
|
||||
|
||||
|
||||
class RefreshCoordinator:
|
||||
def __init__(
|
||||
self,
|
||||
service: RefreshService,
|
||||
interval_seconds: int,
|
||||
refresh_on_startup: bool,
|
||||
initial_download_timeout_seconds: int = 43200,
|
||||
):
|
||||
self.service = service
|
||||
self.interval_seconds = interval_seconds
|
||||
self.refresh_on_startup = refresh_on_startup
|
||||
self.initial_download_timeout_seconds = initial_download_timeout_seconds
|
||||
self.current_run_id: str | None = None
|
||||
self.next_refresh_at: str | None = None
|
||||
self._refresh_task: asyncio.Task[None] | None = None
|
||||
self._scheduler_task: asyncio.Task[None] | None = None
|
||||
self._stop = asyncio.Event()
|
||||
self._active_initial_download = False
|
||||
self._progress: dict[str, Any] | None = None
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._scheduler_task:
|
||||
return
|
||||
self._scheduler_task = asyncio.create_task(self._scheduler_loop())
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._stop.set()
|
||||
if self._scheduler_task:
|
||||
self._scheduler_task.cancel()
|
||||
await asyncio.gather(self._scheduler_task, return_exceptions=True)
|
||||
|
||||
async def trigger(self, trigger: str) -> tuple[str, bool]:
|
||||
if self._refresh_task and not self._refresh_task.done():
|
||||
return self.current_run_id or "", False
|
||||
run_id = uuid.uuid4().hex
|
||||
self.current_run_id = run_id
|
||||
initial_download = self.service.needs_initial_download()
|
||||
self._active_initial_download = initial_download
|
||||
self._progress = {
|
||||
"stage": "starting",
|
||||
"completed_mods": 0,
|
||||
"total_mods": 0,
|
||||
"percent": 0.0,
|
||||
}
|
||||
|
||||
def update_progress(values: dict[str, Any]) -> None:
|
||||
self._progress = {**(self._progress or {}), **values}
|
||||
|
||||
async def run() -> None:
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
self.service.refresh,
|
||||
run_id,
|
||||
trigger,
|
||||
initial_download=initial_download,
|
||||
overall_timeout_seconds=(
|
||||
self.initial_download_timeout_seconds
|
||||
if initial_download
|
||||
else None
|
||||
),
|
||||
progress=update_progress,
|
||||
)
|
||||
finally:
|
||||
self.current_run_id = None
|
||||
self._active_initial_download = False
|
||||
|
||||
self._refresh_task = asyncio.create_task(run())
|
||||
return run_id, True
|
||||
|
||||
async def _wait_for_initial_download(self, started: bool) -> None:
|
||||
if started and self._active_initial_download and self._refresh_task:
|
||||
self.next_refresh_at = None
|
||||
await asyncio.shield(self._refresh_task)
|
||||
|
||||
async def _scheduler_loop(self) -> None:
|
||||
if self.refresh_on_startup:
|
||||
_, started = await self.trigger("startup")
|
||||
await self._wait_for_initial_download(started)
|
||||
while not self._stop.is_set():
|
||||
next_time = datetime.now(timezone.utc) + timedelta(
|
||||
seconds=self.interval_seconds
|
||||
)
|
||||
self.next_refresh_at = next_time.isoformat()
|
||||
try:
|
||||
await asyncio.wait_for(self._stop.wait(), timeout=self.interval_seconds)
|
||||
except asyncio.TimeoutError:
|
||||
_, started = await self.trigger("scheduled")
|
||||
await self._wait_for_initial_download(started)
|
||||
|
||||
def state(self) -> dict[str, Any]:
|
||||
return {
|
||||
"running": bool(self._refresh_task and not self._refresh_task.done()),
|
||||
"current_run_id": self.current_run_id,
|
||||
"next_refresh_at": self.next_refresh_at,
|
||||
"interval_seconds": self.interval_seconds,
|
||||
"initial_download": bool(
|
||||
self._active_initial_download
|
||||
and self._refresh_task
|
||||
and not self._refresh_task.done()
|
||||
),
|
||||
"initial_download_timeout_seconds": self.initial_download_timeout_seconds,
|
||||
"progress": dict(self._progress) if self._progress else None,
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
const $$ = (selector) => [...document.querySelectorAll(selector)];
|
||||
|
||||
const INTRO_MINIMUM_MS = 900;
|
||||
const REQUIRED_EXPANSIONS = {
|
||||
royalty: { name: "Royalty", art: "/art/NotOwned_Royalty.png" },
|
||||
ideology: { name: "Ideology", art: "/art/NotOwned_Ideology.png" },
|
||||
biotech: { name: "Biotech", art: "/art/NotOwned_Biotech.png" },
|
||||
anomaly: { name: "Anomaly", art: "/art/NotOwned_Anomaly.png" },
|
||||
odyssey: { name: "Odyssey", art: null }
|
||||
};
|
||||
const INSTALL_PATHS = {
|
||||
windows: {
|
||||
label: "Windows",
|
||||
path: "%USERPROFILE%\\AppData\\LocalLow\\Ludeon Studios\\RimWorld by Ludeon Studios\\ModLists"
|
||||
},
|
||||
macos: {
|
||||
label: "macOS",
|
||||
path: "~/Library/Application Support/RimWorld/ModLists"
|
||||
},
|
||||
linux: {
|
||||
label: "Linux",
|
||||
path: "~/.config/unity3d/Ludeon Studios/RimWorld by Ludeon Studios/ModLists"
|
||||
}
|
||||
};
|
||||
const state = {
|
||||
currentStep: 0,
|
||||
listType: "standard",
|
||||
sortAcknowledged: false,
|
||||
polling: null,
|
||||
rmlReady: false
|
||||
};
|
||||
|
||||
function detectedOperatingSystem() {
|
||||
const platform = String(navigator.userAgentData?.platform || navigator.platform || navigator.userAgent || "").toLowerCase();
|
||||
if (platform.includes("mac")) return "macos";
|
||||
if (platform.includes("linux") || platform.includes("x11")) return "linux";
|
||||
return "windows";
|
||||
}
|
||||
|
||||
function renderInstallLocation() {
|
||||
const install = INSTALL_PATHS[detectedOperatingSystem()];
|
||||
$("#detected-os-name").textContent = install.label;
|
||||
$("#detected-os-path").textContent = install.path;
|
||||
}
|
||||
|
||||
function resetListType() {
|
||||
const standard = $('input[name="list-type"][value="standard"]');
|
||||
const acknowledgement = $("#acknowledge-autosort");
|
||||
standard.checked = true;
|
||||
state.listType = "standard";
|
||||
state.sortAcknowledged = false;
|
||||
$("#homebrew-settings").hidden = true;
|
||||
$("#autosort-confirmation").classList.remove("acknowledged");
|
||||
acknowledgement.setAttribute("aria-pressed", "false");
|
||||
acknowledgement.textContent = "Yes, I understand";
|
||||
setMessage("", "", "#wizard-message");
|
||||
updateDownloadAvailability();
|
||||
}
|
||||
|
||||
function formatDate(value, fallback = "Not available") {
|
||||
if (!value) return fallback;
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit"
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function formatRelativeDate(value) {
|
||||
if (!value) return "Waiting for the first refresh";
|
||||
const elapsedSeconds = Math.max(0, Math.round((Date.now() - new Date(value).getTime()) / 1000));
|
||||
if (elapsedSeconds < 60) return "Updated just now";
|
||||
const minutes = Math.floor(elapsedSeconds / 60);
|
||||
if (minutes < 60) return `Updated ${minutes} minute${minutes === 1 ? "" : "s"} ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `Updated ${hours} hour${hours === 1 ? "" : "s"} ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `Updated ${days} day${days === 1 ? "" : "s"} ago`;
|
||||
}
|
||||
|
||||
function setMessage(text, kind = "", selector = "#action-message") {
|
||||
const element = $(selector);
|
||||
element.textContent = text;
|
||||
element.className = `${element.dataset.baseClass || element.className.split(" ")[0]} ${kind}`.trim();
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const response = await fetch(path, options);
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
if (!response.ok) {
|
||||
let detail = `${response.status} ${response.statusText}`;
|
||||
if (contentType.includes("json")) {
|
||||
const body = await response.json();
|
||||
detail = typeof body.detail === "string" ? body.detail : body.detail?.message || detail;
|
||||
}
|
||||
throw new Error(detail);
|
||||
}
|
||||
return contentType.includes("json") ? response.json() : response;
|
||||
}
|
||||
|
||||
function renderServiceState(data) {
|
||||
const element = $("#service-state");
|
||||
const label = element.querySelector("span:last-child");
|
||||
const total = Number(data.totals?.total_mods || 0);
|
||||
const ready = Number(data.totals?.ready_mods || 0);
|
||||
const skipped = Number(data.totals?.skipped_mods || 0);
|
||||
const resolved = ready + skipped;
|
||||
element.className = "service-state";
|
||||
|
||||
const progress = data.scheduler?.progress || {};
|
||||
const progressPercent = Number(progress.percent || 0);
|
||||
|
||||
if (data.scheduler?.initial_download) {
|
||||
element.classList.add("working");
|
||||
label.textContent = progress.total_mods
|
||||
? `Downloading first-time mods · ${Math.round(progressPercent)}%`
|
||||
: "Downloading first-time mods";
|
||||
} else if (data.scheduler?.running) {
|
||||
element.classList.add("working");
|
||||
label.textContent = progress.total_mods
|
||||
? `Updating now · ${Math.round(progressPercent)}%`
|
||||
: "Updating now";
|
||||
} else if (data.latest_run?.status === "failed") {
|
||||
element.classList.add("failed");
|
||||
label.textContent = "Update needs attention";
|
||||
} else if (!data.steamcmd_available) {
|
||||
element.classList.add("failed");
|
||||
label.textContent = "Downloads unavailable";
|
||||
} else if (resolved < total) {
|
||||
element.classList.add("working");
|
||||
label.textContent = "Preparing RML";
|
||||
} else {
|
||||
element.classList.add("ready");
|
||||
label.textContent = "Ready";
|
||||
}
|
||||
}
|
||||
|
||||
function updateDownloadAvailability() {
|
||||
const downloadButton = $("#download-list");
|
||||
const availability = $("#rml-availability");
|
||||
const needsFile = state.listType === "homebrew" && !$("#mods-config").files[0];
|
||||
|
||||
downloadButton.disabled = !state.rmlReady || needsFile || !state.sortAcknowledged;
|
||||
if (!state.rmlReady) {
|
||||
availability.textContent = "The current RML is still being prepared. You can review every step now and download when it is ready.";
|
||||
availability.className = "availability waiting";
|
||||
} else if (needsFile) {
|
||||
availability.textContent = "Choose ModsConfig.xml to finish your homebrew list.";
|
||||
availability.className = "availability waiting";
|
||||
} else if (!state.sortAcknowledged) {
|
||||
availability.textContent = "Confirm the auto-sort step above to unlock your download.";
|
||||
availability.className = "availability waiting";
|
||||
} else {
|
||||
availability.textContent = state.listType === "homebrew"
|
||||
? "Your homebrew list is ready to build."
|
||||
: "The current Progression list is ready to download.";
|
||||
availability.className = "availability ready";
|
||||
}
|
||||
}
|
||||
|
||||
function renderDashboard(data) {
|
||||
renderServiceState(data);
|
||||
const totals = data.totals || {};
|
||||
const total = Number(totals.total_mods || 0);
|
||||
const ready = Number(totals.ready_mods || 0);
|
||||
const skipped = Number(totals.skipped_mods || 0);
|
||||
const resolved = ready + skipped;
|
||||
const counts = Object.fromEntries(
|
||||
(data.collections || []).map(collection => [String(collection.name).toLowerCase(), Number(collection.item_count || 0)])
|
||||
);
|
||||
|
||||
$("#count-total").textContent = total ? total.toLocaleString() : "—";
|
||||
for (const category of ["core", "content", "cosmetics"]) {
|
||||
const formatted = counts[category]?.toLocaleString() || "—";
|
||||
$(`#count-${category}`).textContent = formatted;
|
||||
$(`[data-collection-count="${category}"]`).textContent = formatted;
|
||||
}
|
||||
|
||||
const updatedAt = data.rml_updated_at;
|
||||
const progress = data.scheduler?.progress || {};
|
||||
const initialDownload = Boolean(data.scheduler?.initial_download);
|
||||
$("#last-updated").textContent = initialDownload
|
||||
? `Downloading ${Math.round(Number(progress.percent || 0))}%`
|
||||
: (data.scheduler?.running ? "Updating now" : formatRelativeDate(updatedAt));
|
||||
$("#last-updated").title = formatDate(updatedAt);
|
||||
|
||||
state.rmlReady = total > 0 && resolved === total && !data.scheduler?.running;
|
||||
const nextRefresh = data.scheduler?.next_refresh_at;
|
||||
if (initialDownload) {
|
||||
const completed = Number(progress.completed_mods || 0).toLocaleString();
|
||||
const target = Number(progress.total_mods || 0).toLocaleString();
|
||||
$("#update-detail").textContent = progress.total_mods
|
||||
? `First-time setup: ${completed} of ${target} entries resolved · ${ready.toLocaleString()} mods ready. Hourly checks begin after it finishes.`
|
||||
: "Preparing the first-time Workshop download. Hourly checks are paused.";
|
||||
} else if (data.scheduler?.running) {
|
||||
$("#update-detail").textContent = "The hourly Workshop refresh is currently running.";
|
||||
} else if (!state.rmlReady) {
|
||||
$("#update-detail").textContent = "The current RML is being prepared from the latest Workshop data.";
|
||||
} else {
|
||||
$("#update-detail").textContent = nextRefresh
|
||||
? `Next automatic check ${formatDate(nextRefresh)}.`
|
||||
: "Workshop data refreshes every hour.";
|
||||
}
|
||||
updateDownloadAvailability();
|
||||
}
|
||||
|
||||
async function loadDashboard() {
|
||||
try {
|
||||
renderDashboard(await api("/api/status"));
|
||||
} catch (error) {
|
||||
const element = $("#service-state");
|
||||
element.className = "service-state failed";
|
||||
element.querySelector("span:last-child").textContent = "Backend unavailable";
|
||||
$("#last-updated").textContent = "Unavailable";
|
||||
$("#update-detail").textContent = "The service could not be reached.";
|
||||
state.rmlReady = false;
|
||||
updateDownloadAvailability();
|
||||
setMessage(error.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
function showStep(step) {
|
||||
const panels = $$(".wizard-step");
|
||||
const progressItems = $$(".wizard-progress li");
|
||||
state.currentStep = Math.max(0, Math.min(step, panels.length - 1));
|
||||
|
||||
panels.forEach((panel, index) => {
|
||||
const active = index === state.currentStep;
|
||||
panel.hidden = !active;
|
||||
panel.classList.toggle("active", active);
|
||||
});
|
||||
|
||||
progressItems.forEach((item, index) => {
|
||||
item.classList.toggle("active", index === state.currentStep);
|
||||
item.classList.toggle("complete", index < state.currentStep);
|
||||
if (index === state.currentStep) item.setAttribute("aria-current", "step");
|
||||
else item.removeAttribute("aria-current");
|
||||
});
|
||||
|
||||
if (state.currentStep === 2) updateExpansionRequirements();
|
||||
}
|
||||
|
||||
function updateExpansionRequirements() {
|
||||
const inputs = $$("#dlc-options input");
|
||||
const missing = inputs
|
||||
.filter(input => !input.checked)
|
||||
.map(input => REQUIRED_EXPANSIONS[input.value]);
|
||||
const warning = $("#expansion-warning");
|
||||
const iconList = $("#missing-expansion-icons");
|
||||
|
||||
inputs.forEach(input => {
|
||||
const isMissing = !input.checked;
|
||||
input.closest("label").classList.toggle("missing", isMissing);
|
||||
input.setAttribute("aria-invalid", String(isMissing));
|
||||
});
|
||||
|
||||
warning.hidden = missing.length === 0;
|
||||
$("#expansions-next").disabled = missing.length > 0;
|
||||
if (!missing.length) {
|
||||
iconList.replaceChildren();
|
||||
$("#expansion-warning-text").textContent = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const names = missing.map(expansion => expansion.name);
|
||||
$("#expansion-warning-text").textContent = `${names.join(", ")} ${missing.length === 1 ? "is" : "are"} required before you can continue.`;
|
||||
iconList.innerHTML = missing.map(expansion => expansion.art
|
||||
? `<img src="${expansion.art}" alt="">`
|
||||
: `<span class="missing-expansion-fallback"><b>!</b><small>ODYSSEY</small><em>REQUIRED</em></span>`
|
||||
).join("");
|
||||
}
|
||||
|
||||
function selectedDlcQuery() {
|
||||
return $$("#dlc-options input:checked")
|
||||
.map(input => `dlc=${encodeURIComponent(input.value)}`)
|
||||
.join("&");
|
||||
}
|
||||
|
||||
async function downloadResponse(responsePromise, filename) {
|
||||
const response = await responsePromise;
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
$("#start-wizard").addEventListener("click", () => {
|
||||
resetListType();
|
||||
showStep(0);
|
||||
$("#download-wizard").showModal();
|
||||
});
|
||||
|
||||
$("#close-wizard").addEventListener("click", () => $("#download-wizard").close());
|
||||
|
||||
$("#download-wizard").addEventListener("click", event => {
|
||||
if (event.target === event.currentTarget) event.currentTarget.close();
|
||||
});
|
||||
|
||||
$$('[data-next]').forEach(button => button.addEventListener("click", () => showStep(state.currentStep + 1)));
|
||||
$$('[data-back]').forEach(button => button.addEventListener("click", () => showStep(state.currentStep - 1)));
|
||||
|
||||
$("#setup-confirm").addEventListener("change", event => {
|
||||
$("#setup-next").disabled = !event.target.checked;
|
||||
});
|
||||
|
||||
$$("#dlc-options input").forEach(input => input.addEventListener("change", updateExpansionRequirements));
|
||||
|
||||
$("#show-all-paths").addEventListener("click", event => {
|
||||
const paths = $("#all-install-paths");
|
||||
const show = paths.hidden;
|
||||
paths.hidden = !show;
|
||||
event.currentTarget.setAttribute("aria-expanded", String(show));
|
||||
event.currentTarget.textContent = show ? "Hide other OS" : "Show other OS";
|
||||
});
|
||||
|
||||
$("#acknowledge-autosort").addEventListener("click", event => {
|
||||
state.sortAcknowledged = true;
|
||||
$("#autosort-confirmation").classList.add("acknowledged");
|
||||
event.currentTarget.setAttribute("aria-pressed", "true");
|
||||
event.currentTarget.textContent = "Understood ✓";
|
||||
updateDownloadAvailability();
|
||||
});
|
||||
|
||||
$$('input[name="list-type"]').forEach(input => input.addEventListener("change", event => {
|
||||
state.listType = event.target.value;
|
||||
$("#homebrew-settings").hidden = state.listType !== "homebrew";
|
||||
setMessage("", "", "#wizard-message");
|
||||
updateDownloadAvailability();
|
||||
}));
|
||||
|
||||
$("#mods-config").addEventListener("change", event => {
|
||||
const file = event.target.files[0];
|
||||
$("#file-label").textContent = file?.name || "Choose ModsConfig.xml";
|
||||
$(".file-picker").classList.toggle("has-file", Boolean(file));
|
||||
updateDownloadAvailability();
|
||||
});
|
||||
|
||||
$("#download-list").addEventListener("click", async () => {
|
||||
const button = $("#download-list");
|
||||
button.disabled = true;
|
||||
setMessage("Building your mod list…", "", "#wizard-message");
|
||||
|
||||
try {
|
||||
if (state.listType === "homebrew") {
|
||||
const file = $("#mods-config").files[0];
|
||||
if (!file) throw new Error("Choose ModsConfig.xml before building a homebrew list.");
|
||||
const form = new FormData();
|
||||
form.append("mods_config", file);
|
||||
await downloadResponse(api("/api/modlists/homebrew", { method: "POST", body: form }), "ProgressionHomebrew.rml");
|
||||
setMessage("ProgressionHomebrew.rml is ready. Auto-sort it after loading.", "success", "#wizard-message");
|
||||
} else {
|
||||
const query = selectedDlcQuery();
|
||||
await downloadResponse(api(`/api/modlists/vanilla${query ? `?${query}` : ""}`), "ProgressionVanilla.rml");
|
||||
setMessage("ProgressionVanilla.rml is ready.", "success", "#wizard-message");
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage(error.message, "error", "#wizard-message");
|
||||
} finally {
|
||||
updateDownloadAvailability();
|
||||
}
|
||||
});
|
||||
|
||||
async function start() {
|
||||
$("#action-message").dataset.baseClass = "action-message";
|
||||
$("#wizard-message").dataset.baseClass = "wizard-message";
|
||||
renderInstallLocation();
|
||||
const minimumIntro = new Promise(resolve => window.setTimeout(resolve, INTRO_MINIMUM_MS));
|
||||
await Promise.all([loadDashboard(), minimumIntro]);
|
||||
document.body.classList.add("is-ready");
|
||||
window.setTimeout(() => {
|
||||
$("#intro").hidden = true;
|
||||
}, 850);
|
||||
}
|
||||
|
||||
start();
|
||||
state.polling = window.setInterval(loadDashboard, 60_000);
|
||||
@@ -0,0 +1,253 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="description" content="Build the latest Progression mod list for RimWorld.">
|
||||
<meta name="theme-color" content="#151515">
|
||||
<title>Progression — RimWorld Modpack</title>
|
||||
<link rel="icon" type="image/png" href="/art/ProgressionICO.png">
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="intro" id="intro" aria-hidden="true">
|
||||
<div class="intro-shade"></div>
|
||||
<div class="intro-content">
|
||||
<img src="/art/GameTitle.png" alt="" class="intro-logo">
|
||||
<p>Preparing the latest mod list</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page-shell" id="app">
|
||||
<header class="site-header">
|
||||
<a class="brand" href="/" aria-label="Progression home">
|
||||
<img src="/art/GameTitle.png" alt="RimWorld Progression">
|
||||
</a>
|
||||
<div class="service-state" id="service-state" aria-live="polite">
|
||||
<span class="state-dot"></span>
|
||||
<span>Connecting</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="hero panel" aria-labelledby="page-title">
|
||||
<div class="hero-copy">
|
||||
<h1 id="page-title"><em>Built for beginners.</em><br>A current mod list in one click.</h1>
|
||||
<p class="hero-summary">Choose the expansions you own and download a ready-to-load RimWorld mod list.</p>
|
||||
|
||||
<button class="start-button" id="start-wizard" type="button">
|
||||
<span>
|
||||
<strong>Download RimWorld Mod List</strong>
|
||||
<small>Start here</small>
|
||||
</span>
|
||||
<span class="start-arrow" aria-hidden="true">→</span>
|
||||
</button>
|
||||
|
||||
<a class="windows-download" href="/api/downloads/windows">
|
||||
<span class="windows-mark" aria-hidden="true">⊞</span>
|
||||
<span>
|
||||
<strong>Download the Windows app</strong>
|
||||
<small>Latest ProgressionLoader.exe</small>
|
||||
</span>
|
||||
<span class="windows-download-arrow" aria-hidden="true">↓</span>
|
||||
</a>
|
||||
|
||||
<div class="freshness">
|
||||
<span>RML last updated</span>
|
||||
<strong id="last-updated">Checking…</strong>
|
||||
<small id="update-detail">Workshop data refreshes every hour.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-art" aria-hidden="true">
|
||||
<img src="/art/ProgressionLogo.png" alt="">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="count-grid" aria-label="Current mod counts">
|
||||
<article class="count-card count-total">
|
||||
<span class="count-label">Total</span>
|
||||
<strong id="count-total">—</strong>
|
||||
<small>unique mods</small>
|
||||
</article>
|
||||
<article class="count-card count-core">
|
||||
<span class="count-label">Core</span>
|
||||
<strong id="count-core">—</strong>
|
||||
<small>foundation</small>
|
||||
</article>
|
||||
<article class="count-card count-content">
|
||||
<span class="count-label">Content</span>
|
||||
<strong id="count-content">—</strong>
|
||||
<small>gameplay</small>
|
||||
</article>
|
||||
<article class="count-card count-cosmetics">
|
||||
<span class="count-label">Cosmetics</span>
|
||||
<strong id="count-cosmetics">—</strong>
|
||||
<small>visuals</small>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<p class="action-message" id="action-message" aria-live="polite"></p>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<span id="footer-status">Workshop data refreshed every hour</span>
|
||||
<div class="footer-credits">
|
||||
<span class="art-credit">Art by Ferny</span>
|
||||
<a class="hr-link" href="https://hudsonriggs.systems" target="_blank" rel="noopener noreferrer" aria-label="Visit Hudson Riggs Systems">
|
||||
<img src="/art/hudsonriggssystems.png" alt="Hudson Riggs Systems">
|
||||
</a>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<dialog class="wizard" id="download-wizard" aria-labelledby="wizard-title">
|
||||
<div class="wizard-shell">
|
||||
<header class="wizard-header">
|
||||
<div>
|
||||
<span class="wizard-eyebrow">Progression Loader</span>
|
||||
<strong id="wizard-title">Build your mod list</strong>
|
||||
</div>
|
||||
<button class="icon-button" id="close-wizard" type="button" aria-label="Close">×</button>
|
||||
</header>
|
||||
|
||||
<ol class="wizard-progress" aria-label="Download steps">
|
||||
<li class="active" aria-current="step"><span>1</span><small>Collections</small></li>
|
||||
<li><span>2</span><small>Setup</small></li>
|
||||
<li><span>3</span><small>Expansions</small></li>
|
||||
<li><span>4</span><small>Download</small></li>
|
||||
</ol>
|
||||
|
||||
<div class="wizard-viewport">
|
||||
<section class="wizard-step active" data-step="0">
|
||||
<p class="step-kicker">Step one</p>
|
||||
<h2>Steam Workshop collections</h2>
|
||||
<p class="step-lede">The service keeps Progression’s three source collections current for you.</p>
|
||||
<div class="collection-checks">
|
||||
<div><span class="checkmark">✓</span><span><strong>Core</strong><small><b data-collection-count="core">—</b> mods</small></span></div>
|
||||
<div><span class="checkmark">✓</span><span><strong>Content</strong><small><b data-collection-count="content">—</b> mods</small></span></div>
|
||||
<div><span class="checkmark">✓</span><span><strong>Cosmetics</strong><small><b data-collection-count="cosmetics">—</b> mods</small></span></div>
|
||||
</div>
|
||||
<div class="wizard-actions wizard-actions-end">
|
||||
<button class="button button-primary" type="button" data-next>Continue</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="wizard-step" data-step="1" hidden>
|
||||
<p class="step-kicker">Step two</p>
|
||||
<h2>RimWorld setup verification</h2>
|
||||
<p class="step-lede">Before creating the list, make sure the game has created its configuration folders.</p>
|
||||
<ul class="setup-list">
|
||||
<li><span>✓</span>RimWorld has finished downloading</li>
|
||||
<li><span>✓</span>You have launched RimWorld at least once</li>
|
||||
<li><span>✓</span>The game reaches the main menu without errors</li>
|
||||
<li><span>✓</span>Your owned expansions are installed and active</li>
|
||||
</ul>
|
||||
<label class="confirm-row">
|
||||
<input type="checkbox" id="setup-confirm">
|
||||
<span>I’ve completed these steps</span>
|
||||
</label>
|
||||
<div class="wizard-actions">
|
||||
<button class="button button-back" type="button" data-back>Back</button>
|
||||
<button class="button button-primary" id="setup-next" type="button" data-next disabled>Continue</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="wizard-step" data-step="2" hidden>
|
||||
<p class="step-kicker">Step three</p>
|
||||
<h2>Choose your expansions</h2>
|
||||
<p class="step-lede">The complete Progression experience requires every RimWorld expansion listed below.</p>
|
||||
<fieldset class="dlc-grid" id="dlc-options">
|
||||
<legend class="sr-only">Required RimWorld expansions</legend>
|
||||
<label><input type="checkbox" value="royalty" checked><span>Royalty</span></label>
|
||||
<label><input type="checkbox" value="ideology" checked><span>Ideology</span></label>
|
||||
<label><input type="checkbox" value="biotech" checked><span>Biotech</span></label>
|
||||
<label><input type="checkbox" value="anomaly" checked><span>Anomaly</span></label>
|
||||
<label><input type="checkbox" value="odyssey" checked><span>Odyssey</span></label>
|
||||
</fieldset>
|
||||
<div class="expansion-warning" id="expansion-warning" role="status" aria-live="polite" hidden>
|
||||
<div class="expansion-warning-copy">
|
||||
<span>Required</span>
|
||||
<strong>Missing required expansions</strong>
|
||||
<p id="expansion-warning-text"></p>
|
||||
</div>
|
||||
<div class="missing-expansion-icons" id="missing-expansion-icons" aria-hidden="true"></div>
|
||||
</div>
|
||||
<div class="wizard-actions">
|
||||
<button class="button button-back" type="button" data-back>Back</button>
|
||||
<button class="button button-primary" id="expansions-next" type="button" data-next>Continue</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="wizard-step" data-step="3" hidden>
|
||||
<p class="step-kicker">Final step</p>
|
||||
<h2>Choose your mod list</h2>
|
||||
<p class="step-lede">The standard mod list is ready by default. Choose Homebrew only if you want to keep mods already enabled in your game.</p>
|
||||
|
||||
<div class="list-type-grid" role="radiogroup" aria-label="Mod list type">
|
||||
<label>
|
||||
<input type="radio" name="list-type" value="standard" checked>
|
||||
<span><strong>Standard mod list</strong><small>The complete Progression list</small></span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="list-type" value="homebrew">
|
||||
<span><strong>Homebrew</strong><small>Keep your enabled mods</small></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="homebrew-upload" id="homebrew-settings" hidden>
|
||||
<label class="file-picker" for="mods-config">
|
||||
<input type="file" id="mods-config" accept=".xml,text/xml">
|
||||
<span class="file-picker-copy">
|
||||
<strong id="file-label">Choose ModsConfig.xml</strong>
|
||||
<small>Processed in memory and never stored</small>
|
||||
</span>
|
||||
<span class="file-action">Browse</span>
|
||||
</label>
|
||||
<p><strong>After loading:</strong> auto-sort and check for mod conflicts.</p>
|
||||
</div>
|
||||
|
||||
<section class="install-location" aria-labelledby="install-location-title">
|
||||
<div class="install-location-heading">
|
||||
<div>
|
||||
<span>After downloading</span>
|
||||
<strong id="install-location-title">Move the RML into your <span id="detected-os-name">computer’s</span> ModLists folder</strong>
|
||||
</div>
|
||||
<button class="path-toggle" id="show-all-paths" type="button" aria-expanded="false" aria-controls="all-install-paths">Show other OS</button>
|
||||
</div>
|
||||
<code class="detected-path" id="detected-os-path"></code>
|
||||
<div class="all-install-paths" id="all-install-paths" hidden>
|
||||
<div><strong>Windows</strong><code>%USERPROFILE%\AppData\LocalLow\Ludeon Studios\RimWorld by Ludeon Studios\ModLists</code></div>
|
||||
<div><strong>macOS</strong><code>~/Library/Application Support/RimWorld/ModLists</code></div>
|
||||
<div><strong>Linux</strong><code>~/.config/unity3d/Ludeon Studios/RimWorld by Ludeon Studios/ModLists</code></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="autosort-confirmation" id="autosort-confirmation">
|
||||
<section class="autosort-card" aria-labelledby="autosort-title">
|
||||
<span class="autosort-mark" aria-hidden="true">↕</span>
|
||||
<div>
|
||||
<span class="autosort-eyebrow">Required after loading</span>
|
||||
<h3 id="autosort-title">Auto-sort before you play</h3>
|
||||
<p>Open RimWorld’s mod manager, load the RML, then select <strong>Auto-sort</strong> and review any conflicts before starting a game.</p>
|
||||
</div>
|
||||
</section>
|
||||
<button class="autosort-acknowledge" id="acknowledge-autosort" type="button" aria-pressed="false">Yes, I understand</button>
|
||||
</div>
|
||||
|
||||
<div class="availability" id="rml-availability" aria-live="polite">Checking RML availability…</div>
|
||||
<p class="wizard-message" id="wizard-message" aria-live="polite"></p>
|
||||
|
||||
<div class="wizard-actions">
|
||||
<button class="button button-back" type="button" data-back>Back</button>
|
||||
<button class="button button-download" id="download-list" type="button" disabled>
|
||||
<span>Download mod list</span><span aria-hidden="true">↓</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<script src="/static/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,640 @@
|
||||
@font-face {
|
||||
font-family: "Progression Georgia";
|
||||
src: url("/art/georgia.ttf") format("truetype");
|
||||
font-display: swap;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Progression Georgia";
|
||||
src: url("/art/georgiab.ttf") format("truetype");
|
||||
font-display: swap;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Progression Georgia";
|
||||
src: url("/art/georgiaz.ttf") format("truetype");
|
||||
font-display: swap;
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #151515;
|
||||
--surface: #212121;
|
||||
--surface-raised: #2b2b2b;
|
||||
--surface-deep: #171a1c;
|
||||
--line: #4d4d4d;
|
||||
--line-soft: #373737;
|
||||
--text: #f5f2ec;
|
||||
--body: #c8c8c8;
|
||||
--muted: #979797;
|
||||
--yellow: #f0a621;
|
||||
--green: #79a557;
|
||||
--green-bright: #8fbd30;
|
||||
--purple: #cf83ce;
|
||||
--red: #ff6863;
|
||||
--pink: #ef718f;
|
||||
--radius: 22px;
|
||||
--font-serif: "Progression Georgia", Georgia, "Times New Roman", serif;
|
||||
--font-ui: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html { min-width: 320px; background: var(--bg); }
|
||||
|
||||
body {
|
||||
position: relative;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: #0b0b0c url("/art/Desolation.png") center / cover fixed no-repeat;
|
||||
color: var(--text);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
z-index: 0;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
radial-gradient(circle at center, rgba(21, 21, 21, .08), rgba(21, 21, 21, .76) 72%),
|
||||
linear-gradient(rgba(21, 21, 21, .36), rgba(21, 21, 21, .66));
|
||||
}
|
||||
|
||||
button, input { font: inherit; }
|
||||
button { color: inherit; }
|
||||
img { display: block; max-width: 100%; }
|
||||
h1, h2, p { margin: 0; }
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.intro {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
background: #0b0b0c url("/art/Desolation.png") center / cover no-repeat;
|
||||
transition: opacity .7s ease, visibility .7s ease;
|
||||
}
|
||||
|
||||
.intro-shade {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(circle at center, rgba(21, 21, 21, .08), rgba(21, 21, 21, .76) 72%),
|
||||
linear-gradient(rgba(21, 21, 21, .36), rgba(21, 21, 21, .66));
|
||||
}
|
||||
|
||||
.intro-content {
|
||||
position: relative;
|
||||
width: min(72vw, 760px);
|
||||
text-align: center;
|
||||
animation: intro-rise .85s cubic-bezier(.2, .72, .24, 1) both;
|
||||
}
|
||||
|
||||
.intro-logo { width: 100%; filter: drop-shadow(0 10px 32px rgba(0, 0, 0, .7)); }
|
||||
.intro-content p {
|
||||
margin-top: 20px;
|
||||
color: rgba(245, 242, 236, .65);
|
||||
font-family: var(--font-serif);
|
||||
font-size: clamp(.85rem, 1.3vw, 1.05rem);
|
||||
font-style: italic;
|
||||
letter-spacing: .12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.is-ready .intro { opacity: 0; visibility: hidden; }
|
||||
|
||||
.page-shell {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(1220px, calc(100% - 48px));
|
||||
margin: 0 auto;
|
||||
padding-bottom: 26px;
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
transition: opacity .55s ease .18s, transform .55s ease .18s;
|
||||
}
|
||||
|
||||
.is-ready .page-shell { opacity: 1; transform: none; }
|
||||
|
||||
.site-header {
|
||||
min-height: 98px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.brand { width: clamp(260px, 35vw, 420px); }
|
||||
.brand img { width: 100%; }
|
||||
|
||||
.service-state {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
color: var(--muted);
|
||||
font-size: .78rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: .08em;
|
||||
line-height: 1.3;
|
||||
text-align: right;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.service-state span:last-child { max-width: 300px; }
|
||||
|
||||
.state-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--yellow);
|
||||
box-shadow: 0 0 14px rgba(240, 166, 33, .5);
|
||||
}
|
||||
|
||||
.service-state.ready .state-dot { background: #75bf77; box-shadow: 0 0 14px rgba(117, 191, 119, .5); }
|
||||
.service-state.failed .state-dot { background: var(--red); box-shadow: none; }
|
||||
.service-state.working .state-dot { animation: pulse 1.2s ease-in-out infinite; }
|
||||
|
||||
main { display: grid; gap: 18px; padding-top: 2px; }
|
||||
|
||||
.panel {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
box-shadow: 0 18px 42px rgba(0, 0, 0, .18);
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
min-height: 430px;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.55fr) minmax(280px, .72fr);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.hero::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(110deg, rgba(20, 25, 29, .97) 0 60%, rgba(28, 43, 38, .82) 100%);
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
align-self: center;
|
||||
padding: clamp(34px, 5vw, 60px);
|
||||
}
|
||||
|
||||
h1, h2 { font-family: var(--font-serif); }
|
||||
|
||||
h1 {
|
||||
max-width: 760px;
|
||||
font-size: clamp(2.35rem, 5.4vw, 4.7rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: -.035em;
|
||||
line-height: .97;
|
||||
}
|
||||
|
||||
h1 em { color: #e7ffe0; font-size: .68em; }
|
||||
|
||||
.hero-summary {
|
||||
max-width: 58ch;
|
||||
margin-top: 22px;
|
||||
color: var(--body);
|
||||
font-family: var(--font-serif);
|
||||
font-size: clamp(1rem, 1.5vw, 1.16rem);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.start-button {
|
||||
width: min(100%, 620px);
|
||||
min-height: 86px;
|
||||
margin-top: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 15px 18px 15px 22px;
|
||||
border: 1px solid #8bad3f;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(120deg, #6f981f, #84ad28);
|
||||
box-shadow: inset 0 1px rgba(255,255,255,.15), 0 12px 26px rgba(50, 80, 15, .19);
|
||||
color: #f8ffe9;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: transform .18s ease, filter .18s ease, box-shadow .18s ease;
|
||||
}
|
||||
|
||||
.start-button:hover { transform: translateY(-2px); filter: brightness(1.06); box-shadow: inset 0 1px rgba(255,255,255,.15), 0 16px 32px rgba(50, 80, 15, .25); }
|
||||
.start-button:focus-visible { outline: 3px solid rgba(183, 226, 83, .35); outline-offset: 3px; }
|
||||
.start-button > span:first-child { display: grid; }
|
||||
.start-button strong { font-family: var(--font-serif); font-size: clamp(1.15rem, 2.3vw, 1.55rem); line-height: 1.15; }
|
||||
.start-button small { margin-top: 3px; color: rgba(248,255,233,.76); font-size: .76rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; }
|
||||
.start-arrow { width: 44px; height: 44px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 50%; background: rgba(18, 38, 8, .23); font-size: 1.35rem; transition: transform .18s ease; }
|
||||
.start-button:hover .start-arrow { transform: translateX(3px); }
|
||||
|
||||
.windows-download {
|
||||
width: min(100%, 620px);
|
||||
min-height: 62px;
|
||||
margin-top: 10px;
|
||||
display: grid;
|
||||
grid-template-columns: 36px minmax(0, 1fr) 28px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 15px;
|
||||
border: 1px solid #4c565e;
|
||||
border-radius: 10px;
|
||||
background: rgba(19, 24, 27, .82);
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
transition: border-color .18s ease, background .18s ease, transform .18s ease;
|
||||
}
|
||||
.windows-download:hover { transform: translateY(-1px); border-color: #7a8994; background: rgba(25, 31, 35, .94); }
|
||||
.windows-download:focus-visible { outline: 3px solid rgba(151, 179, 198, .25); outline-offset: 3px; }
|
||||
.windows-mark { width: 34px; height: 34px; display: grid; place-items: center; border-radius: 7px; background: #27343c; color: #b9d9eb; font-size: 1.35rem; line-height: 1; }
|
||||
.windows-download > span:nth-child(2) { min-width: 0; display: grid; }
|
||||
.windows-download strong { font-family: var(--font-serif); font-size: .98rem; }
|
||||
.windows-download small { color: var(--muted); font-size: .72rem; }
|
||||
.windows-download-arrow { color: #afc6d4; font-size: 1.1rem; text-align: center; }
|
||||
|
||||
.freshness {
|
||||
width: fit-content;
|
||||
margin-top: 24px;
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
column-gap: 13px;
|
||||
align-items: baseline;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.freshness > span {
|
||||
color: var(--muted);
|
||||
font-size: .72rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: .07em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.freshness strong { color: var(--text); font-family: var(--font-serif); font-size: 1.04rem; }
|
||||
.freshness small { grid-column: 1 / -1; margin-top: 2px; color: var(--muted); }
|
||||
|
||||
.hero-art {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
min-height: 290px;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 30px;
|
||||
background:
|
||||
repeating-linear-gradient(135deg, rgba(255,255,255,.026) 0 18px, transparent 18px 36px),
|
||||
linear-gradient(145deg, #2b3033, #1e2d28);
|
||||
border-left: 1px solid rgba(255,255,255,.09);
|
||||
}
|
||||
|
||||
.hero-art img {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(62%, 220px);
|
||||
filter: drop-shadow(0 18px 16px rgba(0, 0, 0, .45));
|
||||
}
|
||||
|
||||
.count-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
|
||||
|
||||
.count-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 132px;
|
||||
padding: 20px 22px;
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: 16px;
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.count-card::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 72px;
|
||||
height: 5px;
|
||||
background: var(--card-accent, var(--yellow));
|
||||
}
|
||||
|
||||
.count-total { --card-accent: var(--yellow); }
|
||||
.count-core { --card-accent: #d7d7d7; }
|
||||
.count-content { --card-accent: var(--green); }
|
||||
.count-cosmetics { --card-accent: var(--purple); }
|
||||
.count-label { color: var(--body); font-family: var(--font-serif); font-size: 1rem; font-weight: 700; }
|
||||
.count-card strong { display: block; margin-top: 4px; font-family: var(--font-serif); font-size: clamp(2rem, 3vw, 2.75rem); font-weight: 700; line-height: 1; }
|
||||
.count-card small { color: var(--muted); }
|
||||
|
||||
.action-message { min-height: 18px; color: var(--muted); font-size: .86rem; text-align: center; }
|
||||
.action-message.error, .wizard-message.error { color: #ff918d; }
|
||||
.action-message.success, .wizard-message.success { color: #a2d596; }
|
||||
|
||||
footer {
|
||||
min-height: 88px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
color: #747474;
|
||||
font-size: .76rem;
|
||||
}
|
||||
|
||||
.footer-credits { margin-left: auto; display: flex; align-items: center; gap: 10px; }
|
||||
.art-credit { padding: 4px 8px; border: 1px solid #484848; border-radius: 999px; color: #999; font-family: var(--font-serif); font-style: italic; }
|
||||
|
||||
.hr-link {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: block;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
text-decoration: none;
|
||||
transition: transform .16s ease, filter .16s ease;
|
||||
}
|
||||
|
||||
.hr-link:hover { transform: translateY(-1px); filter: brightness(1.12); }
|
||||
.hr-link:focus-visible { outline: 3px solid rgba(241, 99, 101, .28); outline-offset: 3px; border-radius: 5px; }
|
||||
.hr-link img { width: 100%; height: 100%; object-fit: contain; }
|
||||
|
||||
.wizard {
|
||||
width: min(820px, calc(100% - 30px));
|
||||
max-width: none;
|
||||
max-height: min(94vh, 820px);
|
||||
margin: auto;
|
||||
padding: 0;
|
||||
overflow: auto;
|
||||
border: 0;
|
||||
border-radius: 22px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
opacity: 0;
|
||||
transform: translateY(12px) scale(.988);
|
||||
transition: opacity .22s ease, transform .22s ease, overlay .22s allow-discrete, display .22s allow-discrete;
|
||||
}
|
||||
|
||||
.wizard[open] { opacity: 1; transform: none; }
|
||||
|
||||
@starting-style {
|
||||
.wizard[open] { opacity: 0; transform: translateY(12px) scale(.988); }
|
||||
}
|
||||
|
||||
.wizard::backdrop {
|
||||
background: rgba(6, 8, 9, .82);
|
||||
backdrop-filter: blur(7px);
|
||||
animation: backdrop-in .22s ease both;
|
||||
}
|
||||
|
||||
.wizard-shell {
|
||||
overflow: hidden;
|
||||
border: 1px solid #555;
|
||||
border-radius: 22px;
|
||||
background:
|
||||
repeating-linear-gradient(135deg, rgba(255,255,255,.017) 0 18px, transparent 18px 36px),
|
||||
#202224;
|
||||
box-shadow: 0 28px 90px rgba(0,0,0,.58);
|
||||
}
|
||||
|
||||
.wizard-header {
|
||||
min-height: 76px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 15px 20px 14px 24px;
|
||||
border-bottom: 1px solid #393b3c;
|
||||
background: rgba(17, 20, 22, .78);
|
||||
}
|
||||
|
||||
.wizard-header > div { display: grid; }
|
||||
.wizard-eyebrow, .step-kicker { color: var(--yellow); font-size: .67rem; font-weight: 850; letter-spacing: .13em; text-transform: uppercase; }
|
||||
.wizard-header strong { font-family: var(--font-serif); font-size: 1.18rem; }
|
||||
.icon-button { width: 38px; height: 38px; display: grid; place-items: center; border: 1px solid #454849; border-radius: 50%; background: #292c2e; color: var(--muted); cursor: pointer; font-size: 1.4rem; line-height: 1; }
|
||||
.icon-button:hover { border-color: #727576; color: var(--text); }
|
||||
|
||||
.wizard-progress {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
margin: 0;
|
||||
padding: 20px 46px 18px;
|
||||
list-style: none;
|
||||
border-bottom: 1px solid #37393a;
|
||||
background: rgba(29, 31, 32, .9);
|
||||
}
|
||||
|
||||
.wizard-progress::before { content: ""; position: absolute; top: 35px; left: 14%; right: 14%; height: 1px; background: #4a4c4d; }
|
||||
.wizard-progress li { position: relative; z-index: 1; display: grid; justify-items: center; gap: 5px; color: #777; }
|
||||
.wizard-progress li span { width: 30px; height: 30px; display: grid; place-items: center; border: 1px solid #505253; border-radius: 50%; background: #242627; font-family: var(--font-serif); font-size: .84rem; transition: background .22s ease, border-color .22s ease, color .22s ease; }
|
||||
.wizard-progress li small { font-size: .68rem; font-weight: 750; letter-spacing: .04em; }
|
||||
.wizard-progress li.active { color: var(--text); }
|
||||
.wizard-progress li.active span { border-color: #a78340; background: #524326; color: #ffe6a4; }
|
||||
.wizard-progress li.complete { color: #91b989; }
|
||||
.wizard-progress li.complete span { border-color: #5d7d57; background: #33452f; color: #c9efc0; }
|
||||
|
||||
.wizard-viewport { min-height: 405px; }
|
||||
.wizard-step { padding: 28px 42px 26px; }
|
||||
.wizard-step.active { animation: step-enter .3s cubic-bezier(.2,.72,.24,1) both; }
|
||||
.wizard-step h2 { margin-top: 4px; font-size: clamp(1.8rem, 4vw, 2.65rem); line-height: 1.08; }
|
||||
.step-lede { max-width: 60ch; margin-top: 10px; color: var(--muted); }
|
||||
|
||||
.collection-checks { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-top: 28px; }
|
||||
.collection-checks > div { min-width: 0; display: flex; align-items: center; gap: 11px; padding: 15px; border: 1px solid #414443; border-radius: 10px; background: rgba(17,20,19,.72); }
|
||||
.collection-checks > div > span:last-child { min-width: 0; display: grid; }
|
||||
.collection-checks strong { font-family: var(--font-serif); }
|
||||
.collection-checks small { color: var(--muted); }
|
||||
.collection-checks b { color: #c8e9bd; font-weight: 800; }
|
||||
.checkmark { width: 30px; height: 30px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 50%; background: #33452f; color: #bfe6b5; font-weight: 900; }
|
||||
|
||||
.setup-list { display: grid; gap: 9px; margin: 24px 0 20px; padding: 0; list-style: none; }
|
||||
.setup-list li { display: flex; align-items: center; gap: 11px; padding: 10px 13px; border-radius: 8px; background: rgba(15,18,17,.58); color: var(--body); }
|
||||
.setup-list li span { color: #95ca89; font-weight: 900; }
|
||||
.confirm-row { width: fit-content; display: flex; align-items: center; gap: 10px; color: var(--text); cursor: pointer; font-weight: 750; }
|
||||
.confirm-row input { width: 18px; height: 18px; accent-color: #759f23; }
|
||||
|
||||
.dlc-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 8px; min-width: 0; margin: 27px 0 16px; padding: 0; border: 0; }
|
||||
.dlc-grid label { min-width: 0; cursor: pointer; }
|
||||
.dlc-grid input { position: absolute; opacity: 0; pointer-events: none; }
|
||||
.dlc-grid span { min-height: 58px; display: grid; place-items: center; padding: 8px 5px; border: 1px solid var(--line); border-radius: 9px; background: #1b1b1b; color: var(--muted); font-size: .78rem; font-weight: 800; transition: border-color .16s ease, background .16s ease, color .16s ease, transform .16s ease; }
|
||||
.dlc-grid input:checked + span { border-color: #807551; background: #3d392c; color: #fff7c8; }
|
||||
.dlc-grid label.missing span { border-color: #9b4446; background: #392426; color: #ffaaa7; }
|
||||
.dlc-grid label:hover span { transform: translateY(-1px); }
|
||||
.dlc-grid input:focus-visible + span { outline: 3px solid rgba(240,166,33,.32); outline-offset: 2px; }
|
||||
.expansion-warning {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
margin: 13px 0;
|
||||
padding: 11px 14px;
|
||||
border: 1px solid #814347;
|
||||
border-radius: 11px;
|
||||
background:
|
||||
repeating-linear-gradient(135deg, rgba(255,95,91,.045) 0 12px, transparent 12px 24px),
|
||||
#2d2022;
|
||||
animation: warning-enter .24s cubic-bezier(.2,.72,.24,1) both;
|
||||
}
|
||||
|
||||
.expansion-warning[hidden] { display: none; }
|
||||
.expansion-warning-copy { min-width: 0; display: grid; }
|
||||
.expansion-warning-copy > span { color: #ff7773; font-size: .64rem; font-weight: 850; letter-spacing: .13em; text-transform: uppercase; }
|
||||
.expansion-warning-copy strong { margin-top: 2px; font-family: var(--font-serif); font-size: 1.05rem; }
|
||||
.expansion-warning-copy p { margin-top: 2px; color: #cba9a8; font-size: .78rem; }
|
||||
.missing-expansion-icons { flex: 0 0 auto; display: flex; align-items: center; gap: 7px; }
|
||||
.missing-expansion-icons img { width: 58px; height: 58px; object-fit: contain; }
|
||||
.missing-expansion-fallback { width: 58px; height: 58px; display: grid; place-items: center; align-content: center; border: 2px solid #a8b2bd; clip-path: polygon(50% 0, 92% 24%, 92% 76%, 50% 100%, 8% 76%, 8% 24%); background: #31363a; text-align: center; }
|
||||
.missing-expansion-fallback b { color: #ff3f3a; font-family: var(--font-serif); font-size: 1.7rem; line-height: .75; }
|
||||
.missing-expansion-fallback small { margin-top: 3px; color: #d9dde0; font-size: .44rem; font-style: normal; font-weight: 900; letter-spacing: .03em; }
|
||||
.missing-expansion-fallback em { color: #fff; font-size: .46rem; font-style: normal; font-weight: 900; }
|
||||
|
||||
.list-type-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 24px; }
|
||||
.list-type-grid label { cursor: pointer; }
|
||||
.list-type-grid input { position: absolute; opacity: 0; pointer-events: none; }
|
||||
.list-type-grid label > span { min-height: 84px; display: grid; align-content: center; padding: 15px 17px; border: 1px solid #484a4b; border-radius: 10px; background: #191b1c; transition: border-color .16s ease, background .16s ease, transform .16s ease; }
|
||||
.list-type-grid label:hover > span { transform: translateY(-1px); }
|
||||
.list-type-grid strong { font-family: var(--font-serif); font-size: 1.05rem; }
|
||||
.list-type-grid small { color: var(--muted); }
|
||||
.list-type-grid input:checked + span { border-color: #987c47; background: #332f25; }
|
||||
.list-type-grid label:last-child input:checked + span { border-color: #8a608d; background: #332735; }
|
||||
.list-type-grid input:focus-visible + span { outline: 3px solid rgba(240,166,33,.25); outline-offset: 2px; }
|
||||
|
||||
.homebrew-upload { margin-top: 12px; animation: step-enter .25s ease both; }
|
||||
.homebrew-upload[hidden] { display: none; }
|
||||
.file-picker { min-height: 61px; min-width: 0; display: flex; align-items: center; gap: 14px; padding: 9px 10px 9px 14px; border: 1px dashed #665669; border-radius: 9px; background: #1b191c; cursor: pointer; }
|
||||
.file-picker:hover, .file-picker.has-file { border-color: var(--purple); }
|
||||
.file-picker input { position: absolute; opacity: 0; pointer-events: none; }
|
||||
.file-picker input:focus-visible + .file-picker-copy { outline: 3px solid rgba(207,131,206,.28); outline-offset: 3px; }
|
||||
.file-picker-copy { min-width: 0; display: grid; }
|
||||
.file-picker-copy strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.file-picker-copy small { color: var(--muted); }
|
||||
.file-action { margin-left: auto; padding: 7px 10px; border-radius: 6px; background: #302a31; color: #edc9ed; font-size: .78rem; font-weight: 800; }
|
||||
.homebrew-upload p { margin-top: 8px; color: var(--muted); font-size: .78rem; }
|
||||
.homebrew-upload p strong { color: #d6b7d6; }
|
||||
|
||||
.install-location { margin-top: 14px; padding: 12px 13px; border: 1px solid #454849; border-radius: 10px; background: rgba(17, 19, 20, .72); }
|
||||
.install-location-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
|
||||
.install-location-heading > div { min-width: 0; display: grid; }
|
||||
.install-location-heading > div > span { color: var(--yellow); font-size: .62rem; font-weight: 850; letter-spacing: .11em; text-transform: uppercase; }
|
||||
.install-location-heading strong { margin-top: 2px; font-family: var(--font-serif); font-size: .91rem; }
|
||||
.path-toggle { flex: 0 0 auto; border: 0; padding: 3px 0; background: transparent; color: #c6b77f; cursor: pointer; font-size: .72rem; font-weight: 800; text-decoration: underline; text-underline-offset: 3px; }
|
||||
.path-toggle:hover { color: #ffe5a0; }
|
||||
.detected-path, .all-install-paths code { display: block; overflow-wrap: anywhere; color: #bad1b4; font-family: Consolas, "Cascadia Mono", monospace; font-size: .7rem; line-height: 1.45; }
|
||||
.detected-path { margin-top: 8px; padding: 7px 9px; border-radius: 6px; background: #111413; }
|
||||
.all-install-paths { display: grid; gap: 8px; margin-top: 10px; padding-top: 10px; border-top: 1px solid #393c3d; }
|
||||
.all-install-paths[hidden] { display: none; }
|
||||
.all-install-paths > div { display: grid; grid-template-columns: 62px minmax(0, 1fr); gap: 10px; }
|
||||
.all-install-paths strong { color: var(--body); font-size: .7rem; }
|
||||
|
||||
.autosort-confirmation { margin-top: 14px; }
|
||||
.autosort-card { min-height: 118px; display: grid; grid-template-columns: 72px minmax(0, 1fr); align-items: center; gap: 18px; padding: 17px 19px; border: 1px solid #8b6728; border-radius: 12px 12px 0 0; background: linear-gradient(125deg, rgba(73, 51, 17, .84), rgba(38, 32, 22, .9)); box-shadow: inset 0 1px rgba(255, 227, 157, .08); }
|
||||
.autosort-mark { width: 64px; height: 64px; display: grid; place-items: center; border: 1px solid #aa7c2d; border-radius: 10px; background: #322615; color: #ffc75d; font-family: var(--font-serif); font-size: 2.35rem; font-weight: 900; line-height: 1; }
|
||||
.autosort-card > div { min-width: 0; }
|
||||
.autosort-eyebrow { color: #f4b947; font-size: .63rem; font-weight: 900; letter-spacing: .12em; text-transform: uppercase; }
|
||||
.autosort-card h3 { margin-top: 2px; font-family: var(--font-serif); font-size: 1.35rem; line-height: 1.1; }
|
||||
.autosort-card p { margin-top: 6px; color: #c8bda7; font-size: .79rem; line-height: 1.45; }
|
||||
.autosort-card p strong { color: #ffe0a0; }
|
||||
.autosort-acknowledge { width: 100%; min-height: 44px; border: 1px solid #8b6728; border-top: 0; border-radius: 0 0 12px 12px; background: #211d16; color: #f4d28e; cursor: pointer; font-weight: 850; transition: background .16s ease, color .16s ease, border-color .16s ease; }
|
||||
.autosort-acknowledge:hover { background: #2c2519; color: #ffe4a8; }
|
||||
.autosort-acknowledge:focus-visible { outline: 3px solid rgba(240, 166, 33, .3); outline-offset: 2px; }
|
||||
.autosort-confirmation.acknowledged .autosort-card { border-color: #56734f; background: linear-gradient(125deg, rgba(42, 64, 36, .75), rgba(29, 38, 27, .9)); }
|
||||
.autosort-confirmation.acknowledged .autosort-mark { border-color: #688a60; background: #293a25; color: #bde5b4; }
|
||||
.autosort-confirmation.acknowledged .autosort-acknowledge { border-color: #56734f; background: #31472d; color: #ccefc5; }
|
||||
|
||||
.availability { margin-top: 14px; padding: 9px 11px; border-radius: 7px; font-size: .79rem; }
|
||||
.availability.waiting { background: #302b20; color: #cdbd91; }
|
||||
.availability.ready { background: #273424; color: #a9d49e; }
|
||||
.wizard-message { min-height: 22px; margin-top: 6px; color: var(--muted); font-size: .8rem; }
|
||||
|
||||
.wizard-actions { display: flex; justify-content: space-between; gap: 10px; margin-top: 22px; }
|
||||
.wizard-actions-end { justify-content: flex-end; margin-top: 72px; }
|
||||
.button { min-height: 46px; display: inline-flex; align-items: center; justify-content: center; gap: 28px; border: 1px solid transparent; border-radius: 8px; padding: 10px 20px; cursor: pointer; font-weight: 850; transition: transform .15s ease, filter .15s ease, border-color .15s ease; }
|
||||
.button:hover { transform: translateY(-1px); filter: brightness(1.08); }
|
||||
.button:disabled { cursor: not-allowed; opacity: .42; transform: none; filter: none; }
|
||||
.button-primary { min-width: 150px; background: #729f20; color: #f7ffe9; }
|
||||
.button-back { border-color: #4b4d4e; background: transparent; color: var(--muted); }
|
||||
.button-download { min-width: 230px; justify-content: space-between; background: #729f20; color: #f7ffe9; }
|
||||
|
||||
@keyframes intro-rise { from { opacity: 0; transform: translateY(16px) scale(.985); } to { opacity: 1; transform: none; } }
|
||||
@keyframes pulse { 50% { opacity: .45; transform: scale(.78); } }
|
||||
@keyframes backdrop-in { from { background: rgba(6,8,9,0); backdrop-filter: blur(0); } }
|
||||
@keyframes step-enter { from { opacity: 0; transform: translateX(9px); } to { opacity: 1; transform: none; } }
|
||||
@keyframes warning-enter { from { opacity: 0; transform: translateY(-5px); } to { opacity: 1; transform: none; } }
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.hero { grid-template-columns: 1fr minmax(230px, .52fr); }
|
||||
.hero-copy { padding: 38px; }
|
||||
.count-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.page-shell { width: min(calc(100% - 24px), 1220px); }
|
||||
.site-header { min-height: 78px; gap: 12px; }
|
||||
.brand { width: min(59vw, 330px); }
|
||||
.service-state { max-width: 36vw; font-size: .62rem; }
|
||||
.hero { grid-template-columns: 1fr; }
|
||||
.hero-copy { padding: 30px 24px; }
|
||||
.hero-art { min-height: 180px; border-top: 1px solid rgba(255,255,255,.09); border-left: 0; }
|
||||
.hero-art img { width: 122px; }
|
||||
.freshness { grid-template-columns: 1fr; }
|
||||
.freshness small { grid-column: auto; }
|
||||
footer { align-items: flex-start; gap: 10px; }
|
||||
.wizard { width: min(100% - 18px, 820px); max-height: 94vh; }
|
||||
.wizard-header { min-height: 66px; padding-left: 18px; }
|
||||
.wizard-progress { padding: 16px 16px 14px; }
|
||||
.wizard-progress::before { top: 31px; }
|
||||
.wizard-progress li small { display: none; }
|
||||
.wizard-step { padding: 27px 20px 24px; }
|
||||
.wizard-viewport { min-height: 470px; }
|
||||
.collection-checks { grid-template-columns: 1fr; }
|
||||
.wizard-actions-end { margin-top: 30px; }
|
||||
.dlc-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.expansion-warning { align-items: flex-start; flex-direction: column; }
|
||||
.missing-expansion-icons { max-width: 100%; flex-wrap: wrap; }
|
||||
.list-type-grid { grid-template-columns: 1fr; }
|
||||
.install-location-heading { align-items: flex-start; flex-direction: column; gap: 6px; }
|
||||
.autosort-card { grid-template-columns: 54px minmax(0, 1fr); gap: 13px; padding: 15px; }
|
||||
.autosort-mark { width: 50px; height: 50px; font-size: 1.9rem; }
|
||||
}
|
||||
|
||||
@media (max-width: 430px) {
|
||||
.count-grid { grid-template-columns: 1fr; }
|
||||
.count-card { min-height: 100px; }
|
||||
.start-button { min-height: 78px; padding-left: 16px; }
|
||||
.footer-credits { gap: 6px; }
|
||||
.wizard-actions { display: grid; grid-template-columns: 1fr 1.5fr; }
|
||||
.button, .button-download { min-width: 0; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation: none !important; scroll-behavior: auto !important; transition: none !important; }
|
||||
.intro { display: none; }
|
||||
.page-shell { opacity: 1; transform: none; }
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
COLLECTION_DETAILS_URL = (
|
||||
"https://api.steampowered.com/ISteamRemoteStorage/GetCollectionDetails/v1/"
|
||||
)
|
||||
PUBLISHED_FILE_DETAILS_URL = (
|
||||
"https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkshopDetails:
|
||||
workshop_id: str
|
||||
title: str
|
||||
time_updated: int
|
||||
file_size: int
|
||||
consumer_app_id: int
|
||||
result: int
|
||||
|
||||
|
||||
def _chunks(values: list[str], size: int) -> Iterable[list[str]]:
|
||||
for index in range(0, len(values), size):
|
||||
yield values[index : index + size]
|
||||
|
||||
|
||||
class SteamWorkshopClient:
|
||||
def __init__(self, timeout: int = 30):
|
||||
self.timeout = timeout
|
||||
self.session = requests.Session()
|
||||
retries = Retry(
|
||||
total=4,
|
||||
backoff_factor=0.75,
|
||||
status_forcelist=(429, 500, 502, 503, 504),
|
||||
allowed_methods=frozenset({"POST"}),
|
||||
)
|
||||
self.session.mount("https://", HTTPAdapter(max_retries=retries))
|
||||
self.session.headers.update({"User-Agent": "ProgressionMods/1.0"})
|
||||
|
||||
def get_collections(
|
||||
self, collection_ids: list[str]
|
||||
) -> tuple[dict[str, list[str]], set[str]]:
|
||||
payload: dict[str, str | int] = {"collectioncount": len(collection_ids)}
|
||||
for index, collection_id in enumerate(collection_ids):
|
||||
payload[f"publishedfileids[{index}]"] = collection_id
|
||||
|
||||
response = self.session.post(
|
||||
COLLECTION_DETAILS_URL, data=payload, timeout=self.timeout
|
||||
)
|
||||
response.raise_for_status()
|
||||
details = response.json().get("response", {}).get("collectiondetails", [])
|
||||
|
||||
result: dict[str, list[str]] = {}
|
||||
collection_references: set[str] = set()
|
||||
for detail in details:
|
||||
collection_id = str(detail.get("publishedfileid", ""))
|
||||
if int(detail.get("result", 0)) != 1:
|
||||
raise RuntimeError(f"Steam rejected collection {collection_id}")
|
||||
children = sorted(
|
||||
detail.get("children", []),
|
||||
key=lambda child: int(child.get("sortorder", 0)),
|
||||
)
|
||||
child_ids = [str(child["publishedfileid"]) for child in children]
|
||||
result[collection_id] = child_ids
|
||||
collection_references.update(
|
||||
str(child["publishedfileid"])
|
||||
for child in children
|
||||
if int(child.get("filetype", 0) or 0) == 2
|
||||
)
|
||||
|
||||
# Steam currently reports filetype=2 for nested collections. Also
|
||||
# recognize configured collections by id in case that field is absent.
|
||||
all_children = {
|
||||
workshop_id for workshop_ids in result.values() for workshop_id in workshop_ids
|
||||
}
|
||||
collection_references.update(set(collection_ids) & all_children)
|
||||
return result, collection_references
|
||||
|
||||
def get_item_details(self, workshop_ids: list[str]) -> dict[str, WorkshopDetails]:
|
||||
result: dict[str, WorkshopDetails] = {}
|
||||
for batch in _chunks(workshop_ids, 100):
|
||||
payload: dict[str, str | int] = {"itemcount": len(batch)}
|
||||
for index, workshop_id in enumerate(batch):
|
||||
payload[f"publishedfileids[{index}]"] = workshop_id
|
||||
response = self.session.post(
|
||||
PUBLISHED_FILE_DETAILS_URL, data=payload, timeout=self.timeout
|
||||
)
|
||||
response.raise_for_status()
|
||||
details = (
|
||||
response.json().get("response", {}).get("publishedfiledetails", [])
|
||||
)
|
||||
for detail in details:
|
||||
workshop_id = str(detail.get("publishedfileid", ""))
|
||||
result[workshop_id] = WorkshopDetails(
|
||||
workshop_id=workshop_id,
|
||||
title=str(detail.get("title", "")),
|
||||
time_updated=int(detail.get("time_updated", 0) or 0),
|
||||
file_size=int(detail.get("file_size", 0) or 0),
|
||||
consumer_app_id=int(detail.get("consumer_app_id", 0) or 0),
|
||||
result=int(detail.get("result", 0) or 0),
|
||||
)
|
||||
return result
|
||||
@@ -0,0 +1,270 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from math import ceil
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
RIMWORLD_APP_ID = "294100"
|
||||
FAILED_ITEM_PATTERN = re.compile(
|
||||
r"(?:ERROR!\s*)?Downloading item\s+(\d+)\s+failed", re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def directory_size(path: Path) -> int:
|
||||
if not path.exists():
|
||||
return 0
|
||||
total = 0
|
||||
for entry in path.rglob("*"):
|
||||
try:
|
||||
if entry.is_file():
|
||||
total += entry.stat().st_size
|
||||
except OSError:
|
||||
continue
|
||||
return total
|
||||
|
||||
|
||||
def find_about_xml(mod_path: Path) -> Path | None:
|
||||
direct = mod_path / "About" / "About.xml"
|
||||
if direct.is_file():
|
||||
return direct
|
||||
try:
|
||||
for candidate in mod_path.rglob("*.xml"):
|
||||
if (
|
||||
candidate.name.lower() == "about.xml"
|
||||
and candidate.parent.name.lower() == "about"
|
||||
):
|
||||
return candidate
|
||||
except OSError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _child_text(root: ET.Element, name: str) -> str | None:
|
||||
wanted = name.lower()
|
||||
for child in root:
|
||||
if child.tag.lower() == wanted and child.text:
|
||||
return child.text.strip()
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParsedMod:
|
||||
package_id: str
|
||||
display_name: str
|
||||
declared_steam_app_id: str | None
|
||||
|
||||
|
||||
def parse_about_xml(mod_path: Path) -> ParsedMod:
|
||||
about_xml = find_about_xml(mod_path)
|
||||
if not about_xml:
|
||||
raise ValueError("About/About.xml not found")
|
||||
try:
|
||||
root = ET.parse(about_xml).getroot()
|
||||
except ET.ParseError as error:
|
||||
raise ValueError(f"About.xml is invalid XML: {error}") from error
|
||||
|
||||
package_id = _child_text(root, "packageId")
|
||||
if not package_id:
|
||||
raise ValueError("packageId not found in About.xml")
|
||||
display_name = _child_text(root, "name") or package_id
|
||||
return ParsedMod(
|
||||
package_id=package_id.lower(),
|
||||
display_name=display_name,
|
||||
declared_steam_app_id=_child_text(root, "steamAppId"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SteamCmdBatchResult:
|
||||
workshop_ids: list[str]
|
||||
started_at: str
|
||||
finished_at: str
|
||||
elapsed_seconds: float
|
||||
exit_code: int | None
|
||||
status: str
|
||||
failed_ids: set[str]
|
||||
output_tail: str
|
||||
|
||||
@property
|
||||
def has_io_failure(self) -> bool:
|
||||
return "i/o operation failed" in self.output_tail.lower()
|
||||
|
||||
|
||||
class SteamCmdRunner:
|
||||
def __init__(
|
||||
self,
|
||||
executable: str,
|
||||
content_dir: Path,
|
||||
timeout_seconds: int,
|
||||
):
|
||||
self.executable = executable
|
||||
self.content_dir = content_dir
|
||||
self.timeout_seconds = timeout_seconds
|
||||
self._io_recovery_attempted = False
|
||||
|
||||
@property
|
||||
def install_root(self) -> Path:
|
||||
try:
|
||||
return self.content_dir.parents[3]
|
||||
except IndexError as error:
|
||||
raise ValueError(
|
||||
f"Invalid SteamCMD Workshop content path: {self.content_dir}"
|
||||
) from error
|
||||
|
||||
def executable_path(self) -> str | None:
|
||||
configured = Path(self.executable)
|
||||
if configured.is_file():
|
||||
return str(configured.resolve())
|
||||
return shutil.which(self.executable)
|
||||
|
||||
def available(self) -> bool:
|
||||
return self.executable_path() is not None
|
||||
|
||||
def ensure_workshop_writable(self) -> None:
|
||||
workshop_dir = self.content_dir.parent.parent
|
||||
try:
|
||||
workshop_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.content_dir.mkdir(parents=True, exist_ok=True)
|
||||
probe = workshop_dir / ".progression-write-test"
|
||||
probe.write_text("ok", encoding="utf-8")
|
||||
probe.unlink()
|
||||
except OSError as error:
|
||||
raise PermissionError(
|
||||
f"SteamCMD Workshop directory is not writable: {workshop_dir}. "
|
||||
"Check the container volume ownership."
|
||||
) from error
|
||||
|
||||
def recover_io_failure(self) -> list[Path]:
|
||||
"""Quarantine Steam's transient Workshop state without touching mods."""
|
||||
if self._io_recovery_attempted:
|
||||
return []
|
||||
self._io_recovery_attempted = True
|
||||
workshop_dir = self.content_dir.parent.parent
|
||||
candidates = [
|
||||
workshop_dir / f"appworkshop_{RIMWORLD_APP_ID}.acf",
|
||||
workshop_dir / "downloads" / RIMWORLD_APP_ID,
|
||||
workshop_dir / "temp" / RIMWORLD_APP_ID,
|
||||
]
|
||||
existing = [candidate for candidate in candidates if candidate.exists()]
|
||||
if not existing:
|
||||
return []
|
||||
|
||||
timestamp = (
|
||||
datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
+ f"-{time.time_ns()}"
|
||||
)
|
||||
recovery_dir = workshop_dir / ".progression-recovery" / timestamp
|
||||
recovery_dir.mkdir(parents=True, exist_ok=True)
|
||||
moved: list[Path] = []
|
||||
for candidate in existing:
|
||||
label = (
|
||||
candidate.name
|
||||
if candidate.is_file()
|
||||
else f"{candidate.parent.name}-{candidate.name}"
|
||||
)
|
||||
destination = recovery_dir / label
|
||||
shutil.move(str(candidate), str(destination))
|
||||
moved.append(destination)
|
||||
return moved
|
||||
|
||||
def run_batch(
|
||||
self,
|
||||
workshop_ids: list[str],
|
||||
timeout_seconds: float | None = None,
|
||||
) -> SteamCmdBatchResult:
|
||||
executable = self.executable_path()
|
||||
if not executable:
|
||||
raise FileNotFoundError(
|
||||
f"SteamCMD was not found at '{self.executable}'. Set STEAMCMD_PATH."
|
||||
)
|
||||
invalid = [item for item in workshop_ids if not item.isdigit()]
|
||||
if invalid:
|
||||
raise ValueError(f"Invalid Workshop ids: {', '.join(invalid)}")
|
||||
self.ensure_workshop_writable()
|
||||
|
||||
command = [executable]
|
||||
if self.install_root.resolve() != Path(executable).resolve().parent:
|
||||
command.extend(["+force_install_dir", str(self.install_root)])
|
||||
command.extend(["+login", "anonymous"])
|
||||
for workshop_id in workshop_ids:
|
||||
command.extend(
|
||||
[
|
||||
"+workshop_download_item",
|
||||
RIMWORLD_APP_ID,
|
||||
workshop_id,
|
||||
"validate",
|
||||
]
|
||||
)
|
||||
command.append("+quit")
|
||||
|
||||
started_at = utc_now()
|
||||
started = time.monotonic()
|
||||
exit_code: int | None = None
|
||||
status = "completed"
|
||||
output = ""
|
||||
timeout = self.timeout_seconds
|
||||
if timeout_seconds is not None:
|
||||
timeout = max(1, min(timeout, ceil(timeout_seconds)))
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
cwd=str(Path(executable).resolve().parent),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
shell=False,
|
||||
)
|
||||
exit_code = completed.returncode
|
||||
output = f"{completed.stdout}\n{completed.stderr}".strip()
|
||||
if completed.returncode != 0:
|
||||
status = "failed"
|
||||
except subprocess.TimeoutExpired as error:
|
||||
status = "timeout"
|
||||
stdout = (
|
||||
error.stdout.decode(errors="replace")
|
||||
if isinstance(error.stdout, bytes)
|
||||
else error.stdout
|
||||
)
|
||||
stderr = (
|
||||
error.stderr.decode(errors="replace")
|
||||
if isinstance(error.stderr, bytes)
|
||||
else error.stderr
|
||||
)
|
||||
output = f"{stdout or ''}\n{stderr or ''}".strip()
|
||||
|
||||
failed_ids = set(FAILED_ITEM_PATTERN.findall(output))
|
||||
for workshop_id in workshop_ids:
|
||||
if not (self.content_dir / workshop_id).is_dir():
|
||||
failed_ids.add(workshop_id)
|
||||
if not failed_ids:
|
||||
# SteamCMD's bootstrapper can return 1 after updating and relaunching even
|
||||
# though every requested item was installed successfully.
|
||||
status = "completed"
|
||||
elif len(failed_ids) < len(workshop_ids):
|
||||
status = "partial"
|
||||
else:
|
||||
status = "failed"
|
||||
|
||||
return SteamCmdBatchResult(
|
||||
workshop_ids=workshop_ids,
|
||||
started_at=started_at,
|
||||
finished_at=utc_now(),
|
||||
elapsed_seconds=round(time.monotonic() - started, 3),
|
||||
exit_code=exit_code,
|
||||
status=status,
|
||||
failed_ids=failed_ids,
|
||||
output_tail=output[-8000:],
|
||||
)
|
||||
@@ -0,0 +1,2 @@
|
||||
-r requirements-web.txt
|
||||
httpx2>=0.1.0
|
||||
@@ -0,0 +1,5 @@
|
||||
requests>=2.25.1
|
||||
python-dotenv>=0.19.0
|
||||
fastapi>=0.115.0,<1.0
|
||||
uvicorn[standard]>=0.30.0,<1.0
|
||||
python-multipart>=0.0.12,<1.0
|
||||
@@ -5,3 +5,6 @@ Pillow>=8.0.0
|
||||
packaging>=21.0
|
||||
customtkinter>=5.0.0
|
||||
rich>=13.0.0
|
||||
fastapi>=0.115.0,<1.0
|
||||
uvicorn[standard]>=0.30.0,<1.0
|
||||
python-multipart>=0.0.12,<1.0
|
||||
|
||||
@@ -0,0 +1,756 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from progression_backend.app import create_app
|
||||
from progression_backend.app import latest_windows_release_url
|
||||
from progression_backend.config import Settings
|
||||
from progression_backend.database import Database
|
||||
from progression_backend.modlist import (
|
||||
ModListItem,
|
||||
core_items,
|
||||
generate_rml,
|
||||
parse_active_mods,
|
||||
workshop_items,
|
||||
)
|
||||
from progression_backend.refresh import RefreshCoordinator, RefreshService
|
||||
from progression_backend.steam_api import SteamWorkshopClient, WorkshopDetails
|
||||
from progression_backend.workshop import (
|
||||
SteamCmdBatchResult,
|
||||
SteamCmdRunner,
|
||||
parse_about_xml,
|
||||
utc_now,
|
||||
)
|
||||
|
||||
|
||||
class FakeSteam:
|
||||
def get_collections(
|
||||
self, collection_ids: list[str]
|
||||
) -> tuple[dict[str, list[str]], set[str]]:
|
||||
return (
|
||||
{collection_id: ["123456"] for collection_id in collection_ids},
|
||||
set(),
|
||||
)
|
||||
|
||||
def get_item_details(self, workshop_ids: list[str]) -> dict[str, WorkshopDetails]:
|
||||
return {
|
||||
workshop_id: WorkshopDetails(
|
||||
workshop_id=workshop_id,
|
||||
title="Test & Mod",
|
||||
time_updated=123,
|
||||
file_size=456,
|
||||
consumer_app_id=294100,
|
||||
result=1,
|
||||
)
|
||||
for workshop_id in workshop_ids
|
||||
}
|
||||
|
||||
|
||||
class FakeRunner:
|
||||
def __init__(self, content_dir: Path):
|
||||
self.content_dir = content_dir
|
||||
self.last_timeout_seconds: float | None = None
|
||||
|
||||
def available(self) -> bool:
|
||||
return True
|
||||
|
||||
def run_batch(
|
||||
self, workshop_ids: list[str], timeout_seconds: float | None = None
|
||||
) -> SteamCmdBatchResult:
|
||||
self.last_timeout_seconds = timeout_seconds
|
||||
for workshop_id in workshop_ids:
|
||||
about = self.content_dir / workshop_id / "About"
|
||||
about.mkdir(parents=True)
|
||||
(about / "About.xml").write_text(
|
||||
"<ModMetaData><name>Test & Mod</name>"
|
||||
"<packageId>Author.Test</packageId>"
|
||||
"<steamAppId>294100</steamAppId></ModMetaData>",
|
||||
encoding="utf-8",
|
||||
)
|
||||
timestamp = utc_now()
|
||||
return SteamCmdBatchResult(
|
||||
workshop_ids=workshop_ids,
|
||||
started_at=timestamp,
|
||||
finished_at=timestamp,
|
||||
elapsed_seconds=0.1,
|
||||
exit_code=0,
|
||||
status="completed",
|
||||
failed_ids=set(),
|
||||
output_tail="Success",
|
||||
)
|
||||
|
||||
|
||||
class FailingRunner:
|
||||
def __init__(self) -> None:
|
||||
self.calls = 0
|
||||
self.last_timeout_seconds: float | None = None
|
||||
|
||||
def available(self) -> bool:
|
||||
return True
|
||||
|
||||
def run_batch(
|
||||
self, workshop_ids: list[str], timeout_seconds: float | None = None
|
||||
) -> SteamCmdBatchResult:
|
||||
self.calls += 1
|
||||
self.last_timeout_seconds = timeout_seconds
|
||||
timestamp = utc_now()
|
||||
return SteamCmdBatchResult(
|
||||
workshop_ids=workshop_ids,
|
||||
started_at=timestamp,
|
||||
finished_at=timestamp,
|
||||
elapsed_seconds=0.001,
|
||||
exit_code=1,
|
||||
status="failed",
|
||||
failed_ids=set(workshop_ids),
|
||||
output_tail="Download failed",
|
||||
)
|
||||
|
||||
|
||||
class MultiModSteam:
|
||||
workshop_ids = ["100", "200", "300"]
|
||||
|
||||
def get_collections(
|
||||
self, collection_ids: list[str]
|
||||
) -> tuple[dict[str, list[str]], set[str]]:
|
||||
return (
|
||||
{
|
||||
collection_id: list(self.workshop_ids)
|
||||
for collection_id in collection_ids
|
||||
},
|
||||
set(),
|
||||
)
|
||||
|
||||
def get_item_details(self, workshop_ids: list[str]) -> dict[str, WorkshopDetails]:
|
||||
return {
|
||||
workshop_id: WorkshopDetails(
|
||||
workshop_id=workshop_id,
|
||||
title=f"Test mod {workshop_id}",
|
||||
time_updated=123,
|
||||
file_size=456,
|
||||
consumer_app_id=294100,
|
||||
result=1,
|
||||
)
|
||||
for workshop_id in workshop_ids
|
||||
}
|
||||
|
||||
|
||||
class CollectionReferenceSteam:
|
||||
def get_collections(
|
||||
self, collection_ids: list[str]
|
||||
) -> tuple[dict[str, list[str]], set[str]]:
|
||||
core, content, cosmetics = collection_ids
|
||||
return (
|
||||
{
|
||||
core: ["100", content, cosmetics, "999"],
|
||||
content: [core, "100", cosmetics],
|
||||
cosmetics: [core, content, "100"],
|
||||
},
|
||||
set(collection_ids),
|
||||
)
|
||||
|
||||
def get_item_details(self, workshop_ids: list[str]) -> dict[str, WorkshopDetails]:
|
||||
return {
|
||||
workshop_id: WorkshopDetails(
|
||||
workshop_id=workshop_id,
|
||||
title=(
|
||||
"Unavailable mod"
|
||||
if workshop_id == "999"
|
||||
else f"Test entry {workshop_id}"
|
||||
),
|
||||
time_updated=123,
|
||||
file_size=456,
|
||||
consumer_app_id=294100 if workshop_id != "999" else 0,
|
||||
result=9 if workshop_id == "999" else 1,
|
||||
)
|
||||
for workshop_id in workshop_ids
|
||||
}
|
||||
|
||||
|
||||
class TimeoutBlockingRunner:
|
||||
def __init__(self, content_dir: Path):
|
||||
self.content_dir = content_dir
|
||||
self.calls: list[list[str]] = []
|
||||
self.blocker_attempts = 0
|
||||
|
||||
def available(self) -> bool:
|
||||
return True
|
||||
|
||||
def _install(self, workshop_id: str) -> None:
|
||||
about = self.content_dir / workshop_id / "About"
|
||||
about.mkdir(parents=True)
|
||||
(about / "About.xml").write_text(
|
||||
"<ModMetaData><name>Test Mod</name>"
|
||||
"<packageId>Author.Test</packageId>"
|
||||
"<steamAppId>294100</steamAppId></ModMetaData>",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def run_batch(
|
||||
self, workshop_ids: list[str], timeout_seconds: float | None = None
|
||||
) -> SteamCmdBatchResult:
|
||||
del timeout_seconds
|
||||
self.calls.append(list(workshop_ids))
|
||||
failed_ids: set[str] = set()
|
||||
output = "Success"
|
||||
|
||||
if workshop_ids == ["100", "200", "300"]:
|
||||
self._install("100")
|
||||
failed_ids = {"200", "300"}
|
||||
output = "ERROR! Timeout downloading item 200"
|
||||
elif workshop_ids == ["200"]:
|
||||
self.blocker_attempts += 1
|
||||
if self.blocker_attempts == 1:
|
||||
failed_ids = {"200"}
|
||||
output = "ERROR! Timeout downloading item 200"
|
||||
else:
|
||||
self._install("200")
|
||||
else:
|
||||
for workshop_id in workshop_ids:
|
||||
self._install(workshop_id)
|
||||
|
||||
timestamp = utc_now()
|
||||
return SteamCmdBatchResult(
|
||||
workshop_ids=workshop_ids,
|
||||
started_at=timestamp,
|
||||
finished_at=timestamp,
|
||||
elapsed_seconds=0.001,
|
||||
exit_code=0,
|
||||
status="failed" if failed_ids else "completed",
|
||||
failed_ids=failed_ids,
|
||||
output_tail=output,
|
||||
)
|
||||
|
||||
|
||||
class BackendTests(unittest.TestCase):
|
||||
def test_collection_api_tracks_nested_collection_references(self) -> None:
|
||||
client = SteamWorkshopClient()
|
||||
response = Mock()
|
||||
response.json.return_value = {
|
||||
"response": {
|
||||
"collectiondetails": [
|
||||
{
|
||||
"publishedfileid": "1",
|
||||
"result": 1,
|
||||
"children": [
|
||||
{"publishedfileid": "100", "sortorder": 0, "filetype": 0},
|
||||
{"publishedfileid": "2", "sortorder": 1, "filetype": 2},
|
||||
],
|
||||
},
|
||||
{
|
||||
"publishedfileid": "2",
|
||||
"result": 1,
|
||||
"children": [
|
||||
{"publishedfileid": "1", "sortorder": 0},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
client.session.post = Mock(return_value=response)
|
||||
|
||||
collections, references = client.get_collections(["1", "2"])
|
||||
|
||||
self.assertEqual(collections, {"1": ["100", "2"], "2": ["1"]})
|
||||
self.assertEqual(references, {"1", "2"})
|
||||
response.raise_for_status.assert_called_once_with()
|
||||
|
||||
@patch("progression_backend.app.requests.get")
|
||||
def test_latest_windows_release_includes_prereleases(self, get: Mock) -> None:
|
||||
response = Mock()
|
||||
response.json.return_value = [
|
||||
{
|
||||
"tag_name": "0.2.2",
|
||||
"published_at": "2026-06-13T02:37:15Z",
|
||||
"draft": False,
|
||||
"prerelease": True,
|
||||
"assets": [
|
||||
{
|
||||
"name": "ProgressionLoader.exe",
|
||||
"browser_download_url": "https://git.hudsonriggs.systems/HRiggs/ProgressionMods/releases/download/0.2.2/ProgressionLoader.exe",
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
get.return_value = response
|
||||
|
||||
self.assertEqual(
|
||||
latest_windows_release_url(),
|
||||
"https://git.hudsonriggs.systems/HRiggs/ProgressionMods/releases/download/0.2.2/ProgressionLoader.exe",
|
||||
)
|
||||
response.raise_for_status.assert_called_once_with()
|
||||
|
||||
@patch("progression_backend.app.requests.get")
|
||||
def test_latest_windows_release_prefers_stable_build(self, get: Mock) -> None:
|
||||
response = Mock()
|
||||
response.json.return_value = [
|
||||
{
|
||||
"published_at": "2026-07-01T00:00:00Z",
|
||||
"draft": False,
|
||||
"prerelease": True,
|
||||
"assets": [
|
||||
{
|
||||
"name": "ProgressionLoader.exe",
|
||||
"browser_download_url": "https://git.hudsonriggs.systems/HRiggs/ProgressionMods/releases/download/0.3.0-rc1/ProgressionLoader.exe",
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"published_at": "2026-06-30T00:00:00Z",
|
||||
"draft": False,
|
||||
"prerelease": False,
|
||||
"assets": [
|
||||
{
|
||||
"name": "ProgressionLoader.exe",
|
||||
"browser_download_url": "https://git.hudsonriggs.systems/HRiggs/ProgressionMods/releases/download/0.2.3/ProgressionLoader.exe",
|
||||
}
|
||||
],
|
||||
},
|
||||
]
|
||||
get.return_value = response
|
||||
|
||||
self.assertEqual(
|
||||
latest_windows_release_url(),
|
||||
"https://git.hudsonriggs.systems/HRiggs/ProgressionMods/releases/download/0.2.3/ProgressionLoader.exe",
|
||||
)
|
||||
|
||||
def test_about_parser_normalizes_package_id(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
about = Path(directory) / "About"
|
||||
about.mkdir()
|
||||
(about / "About.xml").write_text(
|
||||
"<ModMetaData><name>A & B</name><packageId>Author.Mod</packageId></ModMetaData>",
|
||||
encoding="utf-8",
|
||||
)
|
||||
parsed = parse_about_xml(Path(directory))
|
||||
self.assertEqual(parsed.package_id, "author.mod")
|
||||
self.assertEqual(parsed.display_name, "A & B")
|
||||
|
||||
def test_steamcmd_runner_prepares_writable_workshop_path(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
content_dir = (
|
||||
Path(directory)
|
||||
/ "steamapps"
|
||||
/ "workshop"
|
||||
/ "content"
|
||||
/ "294100"
|
||||
)
|
||||
runner = SteamCmdRunner("steamcmd", content_dir, 60)
|
||||
runner.ensure_workshop_writable()
|
||||
self.assertTrue(content_dir.is_dir())
|
||||
self.assertFalse(
|
||||
(content_dir.parent.parent / ".progression-write-test").exists()
|
||||
)
|
||||
|
||||
@patch("progression_backend.workshop.subprocess.run")
|
||||
def test_steamcmd_runner_forces_mounted_install_root(self, run: Mock) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
executable = root / "steamcmd-bin" / "steamcmd"
|
||||
executable.parent.mkdir()
|
||||
executable.write_text("", encoding="utf-8")
|
||||
content_dir = (
|
||||
root
|
||||
/ "steam-library"
|
||||
/ "steamapps"
|
||||
/ "workshop"
|
||||
/ "content"
|
||||
/ "294100"
|
||||
)
|
||||
run.return_value = Mock(returncode=0, stdout="", stderr="")
|
||||
runner = SteamCmdRunner(str(executable), content_dir, 60)
|
||||
|
||||
runner.run_batch(["123456"])
|
||||
|
||||
command = run.call_args.args[0]
|
||||
self.assertEqual(
|
||||
command[:5],
|
||||
[
|
||||
str(executable.resolve()),
|
||||
"+force_install_dir",
|
||||
str(root / "steam-library"),
|
||||
"+login",
|
||||
"anonymous",
|
||||
],
|
||||
)
|
||||
|
||||
def test_steamcmd_io_recovery_preserves_completed_mods(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
content_dir = (
|
||||
Path(directory)
|
||||
/ "steamapps"
|
||||
/ "workshop"
|
||||
/ "content"
|
||||
/ "294100"
|
||||
)
|
||||
completed_mod = content_dir / "123456" / "About"
|
||||
completed_mod.mkdir(parents=True)
|
||||
(completed_mod / "About.xml").write_text("<ModMetaData />")
|
||||
workshop_dir = content_dir.parent.parent
|
||||
manifest = workshop_dir / "appworkshop_294100.acf"
|
||||
manifest.write_text("stale", encoding="utf-8")
|
||||
staging = workshop_dir / "downloads" / "294100"
|
||||
staging.mkdir(parents=True)
|
||||
(staging / "partial.bin").write_bytes(b"partial")
|
||||
runner = SteamCmdRunner("steamcmd", content_dir, 60)
|
||||
|
||||
moved = runner.recover_io_failure()
|
||||
|
||||
self.assertEqual(len(moved), 2)
|
||||
self.assertFalse(manifest.exists())
|
||||
self.assertFalse(staging.exists())
|
||||
self.assertTrue((completed_mod / "About.xml").is_file())
|
||||
self.assertEqual(runner.recover_io_failure(), [])
|
||||
|
||||
def test_rml_uses_workshop_id_and_escapes_names(self) -> None:
|
||||
payload = generate_rml(
|
||||
[
|
||||
*core_items(["royalty"]),
|
||||
ModListItem("author.mod", "123456", "A & B"),
|
||||
],
|
||||
"1.6",
|
||||
)
|
||||
root = ET.fromstring(payload)
|
||||
self.assertEqual(root.findtext("meta/gameVersion"), "1.6")
|
||||
self.assertEqual(
|
||||
[node.text for node in root.findall("meta/modSteamIds/li")],
|
||||
["0", "0", "123456"],
|
||||
)
|
||||
self.assertIn(b"A & B", payload)
|
||||
|
||||
def test_homebrew_parser_reads_active_mods(self) -> None:
|
||||
items = parse_active_mods(
|
||||
b"<ModsConfigData><activeMods><li>ludeon.rimworld</li>"
|
||||
b"<li>author.mod</li></activeMods></ModsConfigData>"
|
||||
)
|
||||
self.assertEqual(
|
||||
[item.package_id for item in items], ["ludeon.rimworld", "author.mod"]
|
||||
)
|
||||
|
||||
def test_refresh_persists_download_analytics(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
settings = Settings(
|
||||
host="127.0.0.1",
|
||||
port=8000,
|
||||
database_path=root / "progression.db",
|
||||
steamcmd_path="steamcmd",
|
||||
workshop_content_dir=root / "workshop" / "294100",
|
||||
steamcmd_batch_size=25,
|
||||
steamcmd_timeout_seconds=60,
|
||||
refresh_interval_seconds=3600,
|
||||
refresh_on_startup=False,
|
||||
initial_download_timeout_seconds=43200,
|
||||
game_version="1.6",
|
||||
admin_token=None,
|
||||
collections={"Core": "1", "Content": "2", "Cosmetics": "3"},
|
||||
static_dir=root,
|
||||
)
|
||||
database = Database(settings.database_path)
|
||||
database.initialize()
|
||||
runner = FakeRunner(settings.workshop_content_dir)
|
||||
service = RefreshService(
|
||||
settings,
|
||||
database,
|
||||
steam=FakeSteam(),
|
||||
runner=runner,
|
||||
)
|
||||
progress_events: list[dict] = []
|
||||
service.refresh(
|
||||
"test-run",
|
||||
"test",
|
||||
initial_download=True,
|
||||
overall_timeout_seconds=10,
|
||||
progress=progress_events.append,
|
||||
)
|
||||
|
||||
run = database.fetch_one(
|
||||
"SELECT * FROM refresh_runs WHERE id = ?", ("test-run",)
|
||||
)
|
||||
self.assertIsNotNone(run)
|
||||
self.assertEqual(run["status"], "completed", run)
|
||||
self.assertEqual(run["unique_items"], 1)
|
||||
self.assertEqual(run["downloads_succeeded"], 1)
|
||||
mod = database.fetch_one("SELECT * FROM mods WHERE workshop_id = '123456'")
|
||||
self.assertEqual(mod["package_id"], "author.test")
|
||||
self.assertEqual(mod["steam_app_id"], "294100")
|
||||
generated = workshop_items([mod])
|
||||
self.assertEqual(generated[0].workshop_id, "123456")
|
||||
self.assertIsNotNone(runner.last_timeout_seconds)
|
||||
self.assertLessEqual(runner.last_timeout_seconds or 0, 10)
|
||||
self.assertEqual(progress_events[-1]["stage"], "completed")
|
||||
self.assertEqual(progress_events[-1]["percent"], 100.0)
|
||||
|
||||
def test_initial_download_stops_at_overall_timeout(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
settings = Settings(
|
||||
host="127.0.0.1",
|
||||
port=8000,
|
||||
database_path=root / "progression.db",
|
||||
steamcmd_path="steamcmd",
|
||||
workshop_content_dir=root / "workshop" / "294100",
|
||||
steamcmd_batch_size=25,
|
||||
steamcmd_timeout_seconds=60,
|
||||
refresh_interval_seconds=3600,
|
||||
refresh_on_startup=False,
|
||||
initial_download_timeout_seconds=43200,
|
||||
game_version="1.6",
|
||||
admin_token=None,
|
||||
collections={"Core": "1", "Content": "2", "Cosmetics": "3"},
|
||||
static_dir=root,
|
||||
)
|
||||
database = Database(settings.database_path)
|
||||
database.initialize()
|
||||
runner = FailingRunner()
|
||||
service = RefreshService(
|
||||
settings, database, steam=FakeSteam(), runner=runner # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
service.refresh(
|
||||
"timeout-run",
|
||||
"startup",
|
||||
initial_download=True,
|
||||
overall_timeout_seconds=0.25, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
run = database.fetch_one(
|
||||
"SELECT * FROM refresh_runs WHERE id = ?", ("timeout-run",)
|
||||
)
|
||||
self.assertIsNotNone(run)
|
||||
self.assertEqual(run["status"], "failed")
|
||||
self.assertIn("initial Workshop download reached", run["error"])
|
||||
self.assertEqual(runner.calls, 1)
|
||||
self.assertLessEqual(runner.last_timeout_seconds or 0, 0.25)
|
||||
|
||||
def test_collection_references_and_unavailable_items_are_skipped(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
settings = Settings(
|
||||
host="127.0.0.1",
|
||||
port=8000,
|
||||
database_path=root / "progression.db",
|
||||
steamcmd_path="steamcmd",
|
||||
workshop_content_dir=root / "workshop" / "294100",
|
||||
steamcmd_batch_size=25,
|
||||
steamcmd_timeout_seconds=60,
|
||||
refresh_interval_seconds=3600,
|
||||
refresh_on_startup=False,
|
||||
initial_download_timeout_seconds=43200,
|
||||
game_version="1.6",
|
||||
admin_token=None,
|
||||
collections={"Core": "1", "Content": "2", "Cosmetics": "3"},
|
||||
static_dir=root,
|
||||
)
|
||||
database = Database(settings.database_path)
|
||||
database.initialize()
|
||||
service = RefreshService(
|
||||
settings,
|
||||
database,
|
||||
steam=CollectionReferenceSteam(),
|
||||
runner=FakeRunner(settings.workshop_content_dir),
|
||||
)
|
||||
progress_events: list[dict] = []
|
||||
|
||||
service.refresh(
|
||||
"collection-reference-run",
|
||||
"startup",
|
||||
initial_download=True,
|
||||
overall_timeout_seconds=60,
|
||||
progress=progress_events.append,
|
||||
)
|
||||
|
||||
run = database.fetch_one(
|
||||
"SELECT * FROM refresh_runs WHERE id = ?",
|
||||
("collection-reference-run",),
|
||||
)
|
||||
self.assertIsNotNone(run)
|
||||
self.assertEqual(run["status"], "completed", run)
|
||||
self.assertEqual(run["unique_items"], 5)
|
||||
self.assertEqual(run["downloads_requested"], 1)
|
||||
self.assertEqual(run["ready_items"], 1)
|
||||
dashboard = database.dashboard()
|
||||
self.assertEqual(dashboard["totals"]["total_mods"], 5)
|
||||
self.assertEqual(dashboard["totals"]["ready_mods"], 1)
|
||||
self.assertEqual(dashboard["totals"]["skipped_mods"], 4)
|
||||
skipped = database.fetch_all(
|
||||
"SELECT workshop_id FROM mods WHERE status = 'skipped' "
|
||||
"ORDER BY CAST(workshop_id AS INTEGER)"
|
||||
)
|
||||
self.assertEqual(
|
||||
[row["workshop_id"] for row in skipped], ["1", "2", "3", "999"]
|
||||
)
|
||||
self.assertFalse(service.needs_initial_download())
|
||||
self.assertEqual(progress_events[-1]["completed_mods"], 5)
|
||||
self.assertEqual(progress_events[-1]["total_mods"], 5)
|
||||
self.assertEqual(progress_events[-1]["percent"], 100.0)
|
||||
|
||||
with TestClient(create_app(settings)) as client:
|
||||
response = client.get("/api/modlists/vanilla")
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
self.assertIn(b"100", response.content)
|
||||
self.assertNotIn(b"999", response.content)
|
||||
|
||||
@patch("progression_backend.refresh.time.sleep", return_value=None)
|
||||
def test_initial_download_isolates_items_after_batch_timeout(
|
||||
self, sleep: Mock
|
||||
) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
settings = Settings(
|
||||
host="127.0.0.1",
|
||||
port=8000,
|
||||
database_path=root / "progression.db",
|
||||
steamcmd_path="steamcmd",
|
||||
workshop_content_dir=root / "workshop" / "294100",
|
||||
steamcmd_batch_size=25,
|
||||
steamcmd_timeout_seconds=60,
|
||||
refresh_interval_seconds=3600,
|
||||
refresh_on_startup=False,
|
||||
initial_download_timeout_seconds=43200,
|
||||
game_version="1.6",
|
||||
admin_token=None,
|
||||
collections={"Core": "1", "Content": "2", "Cosmetics": "3"},
|
||||
static_dir=root,
|
||||
)
|
||||
database = Database(settings.database_path)
|
||||
database.initialize()
|
||||
runner = TimeoutBlockingRunner(settings.workshop_content_dir)
|
||||
service = RefreshService(
|
||||
settings,
|
||||
database,
|
||||
steam=MultiModSteam(),
|
||||
runner=runner,
|
||||
)
|
||||
|
||||
service.refresh(
|
||||
"blocking-timeout-run",
|
||||
"startup",
|
||||
initial_download=True,
|
||||
overall_timeout_seconds=60,
|
||||
)
|
||||
|
||||
run = database.fetch_one(
|
||||
"SELECT * FROM refresh_runs WHERE id = ?",
|
||||
("blocking-timeout-run",),
|
||||
)
|
||||
self.assertIsNotNone(run)
|
||||
self.assertEqual(run["status"], "completed", run)
|
||||
self.assertEqual(run["ready_items"], 3)
|
||||
self.assertEqual(
|
||||
runner.calls,
|
||||
[["100", "200", "300"], ["200"], ["300"], ["200"]],
|
||||
)
|
||||
sleep.assert_called()
|
||||
|
||||
def test_api_serves_dashboard_and_reports_empty_modlist(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
project_root = Path(__file__).resolve().parents[1]
|
||||
settings = Settings(
|
||||
host="127.0.0.1",
|
||||
port=8000,
|
||||
database_path=root / "progression.db",
|
||||
steamcmd_path="missing-steamcmd",
|
||||
workshop_content_dir=root / "workshop" / "294100",
|
||||
steamcmd_batch_size=25,
|
||||
steamcmd_timeout_seconds=60,
|
||||
refresh_interval_seconds=3600,
|
||||
refresh_on_startup=False,
|
||||
initial_download_timeout_seconds=43200,
|
||||
game_version="1.6",
|
||||
admin_token=None,
|
||||
collections={"Core": "1", "Content": "2", "Cosmetics": "3"},
|
||||
static_dir=project_root / "progression_backend" / "static",
|
||||
)
|
||||
with TestClient(create_app(settings)) as client:
|
||||
self.assertEqual(client.get("/").status_code, 200)
|
||||
self.assertEqual(client.get("/art/GameTitle.png").status_code, 200)
|
||||
self.assertEqual(client.get("/api/health").json()["status"], "ok")
|
||||
dashboard = client.get("/api/status").json()
|
||||
self.assertIn("rml_updated_at", dashboard)
|
||||
self.assertIn("initial_download", dashboard["scheduler"])
|
||||
self.assertEqual(
|
||||
dashboard["scheduler"]["initial_download_timeout_seconds"],
|
||||
43200,
|
||||
)
|
||||
response = client.get("/api/modlists/vanilla")
|
||||
self.assertEqual(response.status_code, 409)
|
||||
|
||||
release_url = (
|
||||
"https://git.hudsonriggs.systems/HRiggs/ProgressionMods/"
|
||||
"releases/download/0.2.2/ProgressionLoader.exe"
|
||||
)
|
||||
with patch(
|
||||
"progression_backend.app.latest_windows_release_url",
|
||||
return_value=release_url,
|
||||
):
|
||||
response = client.get(
|
||||
"/api/downloads/windows", follow_redirects=False
|
||||
)
|
||||
self.assertEqual(response.status_code, 307)
|
||||
self.assertEqual(response.headers["location"], release_url)
|
||||
|
||||
|
||||
class SlowInitialService:
|
||||
def __init__(self) -> None:
|
||||
self.started = threading.Event()
|
||||
self.release = threading.Event()
|
||||
self.needs_download = True
|
||||
self.received_timeout: int | None = None
|
||||
|
||||
def needs_initial_download(self) -> bool:
|
||||
return self.needs_download
|
||||
|
||||
def refresh(self, run_id: str, trigger: str, **options: object) -> None:
|
||||
self.received_timeout = options.get("overall_timeout_seconds") # type: ignore[assignment]
|
||||
progress = options.get("progress")
|
||||
if callable(progress):
|
||||
progress(
|
||||
{
|
||||
"stage": "downloading",
|
||||
"completed_mods": 1,
|
||||
"total_mods": 4,
|
||||
"percent": 25.0,
|
||||
}
|
||||
)
|
||||
self.started.set()
|
||||
self.release.wait(timeout=2)
|
||||
self.needs_download = False
|
||||
|
||||
|
||||
class CoordinatorTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_initial_download_pauses_hourly_scheduler(self) -> None:
|
||||
service = SlowInitialService()
|
||||
coordinator = RefreshCoordinator(
|
||||
service, 3600, True, initial_download_timeout_seconds=43200 # type: ignore[arg-type]
|
||||
)
|
||||
await coordinator.start()
|
||||
started = await asyncio.to_thread(service.started.wait, 1)
|
||||
self.assertTrue(started)
|
||||
|
||||
active = coordinator.state()
|
||||
self.assertTrue(active["initial_download"])
|
||||
self.assertIsNone(active["next_refresh_at"])
|
||||
self.assertEqual(active["progress"]["percent"], 25.0)
|
||||
self.assertEqual(service.received_timeout, 43200)
|
||||
|
||||
service.release.set()
|
||||
if coordinator._refresh_task:
|
||||
await asyncio.wait_for(coordinator._refresh_task, timeout=1)
|
||||
await asyncio.sleep(0.01)
|
||||
completed = coordinator.state()
|
||||
self.assertFalse(completed["initial_download"])
|
||||
self.assertIsNotNone(completed["next_refresh_at"])
|
||||
await coordinator.stop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user