public site and containers for that.
Build and Publish Container / Publish Linux container (push) Failing after 1m52s

This commit is contained in:
2026-10-02 12:57:22 -04:00
parent 86a187b08a
commit 7ac541def5
25 changed files with 3265 additions and 4 deletions
+16 -1
View File
@@ -4,4 +4,19 @@ CONTENT_COLLECTION_URL=steam://openurl/https://steamcommunity.com/sharedfiles/fi
COSMETICS_COLLECTION_URL=steam://openurl/https://steamcommunity.com/sharedfiles/filedetails/?id=3637541646
# ModsConfig.xml Path Template
MODSCONFIG_PATH_TEMPLATE=%USERPROFILE%\AppData\LocalLow\Ludeon Studios\RimWorld by Ludeon Studios\Config\ModsConfig.xml
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
MODLIST_GAME_VERSION=1.6
# Optional bearer token required by POST /api/refresh when set.
PROGRESSION_ADMIN_TOKEN=
+79
View File
@@ -0,0 +1,79 @@
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: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ steps.image.outputs.registry }}
username: ${{ secrets.REGISTRY_USERNAME || github.actor }}
password: ${{ secrets.REGISTRY_TOKEN || secrets.GITHUB_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
+5 -1
View File
@@ -200,4 +200,8 @@ art/thumbnails/
# Steam Workshop related (if any cache files are generated)
workshop_cache/
*.cache
*.cache
# Progression web backend runtime data
.data/
.steamcmd/
+37
View File
@@ -0,0 +1,37 @@
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 \
&& 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
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
USER progression
ENV PROGRESSION_HOST=0.0.0.0 \
PROGRESSION_PORT=8000 \
PROGRESSION_DB_PATH=.data/progression.db \
STEAMCMD_PATH=/opt/steamcmd/steamcmd.sh \
STEAMCMD_WORKSHOP_DIR=/opt/steamcmd/steamapps/workshop/content/294100 \
REFRESH_INTERVAL_SECONDS=3600 \
REFRESH_ON_STARTUP=true
VOLUME ["/srv/progression/.data", "/opt/steamcmd/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
CMD ["/srv/venv/bin/python", "-m", "progression_backend"]
+69 -1
View File
@@ -15,4 +15,72 @@ 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
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>`. The workflow uses the Gitea action token by default. If the
server does not grant that token package-write access, configure repository
secrets named `REGISTRY_USERNAME` and `REGISTRY_TOKEN`.
### 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.
+17
View File
@@ -0,0 +1,17 @@
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}
volumes:
- progression-data:/srv/progression/.data
- progression-workshop:/opt/steamcmd/steamapps
volumes:
progression-data:
progression-workshop:
+23
View File
@@ -0,0 +1,23 @@
# 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
+17
View File
@@ -0,0 +1,17 @@
# 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.
+33
View File
@@ -0,0 +1,33 @@
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: /opt/steamcmd/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}
volumes:
- progression-data:/srv/progression/.data
- progression-workshop:/opt/steamcmd/steamapps
volumes:
progression-data:
progression-workshop:
+3
View File
@@ -0,0 +1,3 @@
"""Progression web backend."""
__version__ = "0.1.0"
+18
View File
@@ -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()
+284
View File
@@ -0,0 +1,284 @@
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
)
@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)$"),
) -> 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") != "ready"]
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()
+89
View File
@@ -0,0 +1,89 @@
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
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),
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",
)
+335
View File
@@ -0,0 +1,335 @@
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,
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}
+110
View File
@@ -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()
+335
View File
@@ -0,0 +1,335 @@
from __future__ import annotations
import asyncio
import time
import uuid
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
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 _store_metadata(
self,
detail: WorkshopDetails,
previous: dict[str, Any] | None,
stale: bool,
) -> dict[str, Any]:
invalid_reason = None
if detail.result != 1:
invalid_reason = f"Steam returned result {detail.result}"
elif 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": "failed"
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) -> None:
started_at = utc_now()
started = time.monotonic()
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,
}
try:
collection_ids = list(self.settings.collections.values())
response = 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": "failed",
"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()
)
metadata_rows.append(self._store_metadata(detail, old, stale))
if (
detail.result == 1
and detail.consumer_app_id in (0, 294100)
and stale
):
targets.append(workshop_id)
self.database.upsert_mods(metadata_rows)
metrics["downloads_requested"] = len(targets)
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:
for batch_number, batch in enumerate(
_batches(targets, self.settings.steamcmd_batch_size), start=1
):
result = self.runner.run_batch(batch)
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 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
else:
metrics["downloads_failed"] += 1
self.database.upsert_mods(inspection_rows)
# 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"
)
metrics["status"] = (
"completed"
if metrics["ready_items"] == metrics["unique_items"]
else "partial"
)
if metrics["status"] == "partial" and not metrics["error"]:
metrics["error"] = (
f"{metrics['unique_items'] - metrics['ready_items']} items are not ready"
)
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"]
finally:
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)
class RefreshCoordinator:
def __init__(
self, service: RefreshService, interval_seconds: int, refresh_on_startup: bool
):
self.service = service
self.interval_seconds = interval_seconds
self.refresh_on_startup = refresh_on_startup
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()
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
async def run() -> None:
try:
await asyncio.to_thread(self.service.refresh, run_id, trigger)
finally:
self.current_run_id = None
self._refresh_task = asyncio.create_task(run())
return run_id, True
async def _scheduler_loop(self) -> None:
if self.refresh_on_startup:
await self.trigger("startup")
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:
await self.trigger("scheduled")
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,
}
+360
View File
@@ -0,0 +1,360 @@
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);
element.className = "service-state";
if (data.scheduler?.running) {
element.classList.add("working");
label.textContent = "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 (ready < 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 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;
$("#last-updated").textContent = data.scheduler?.running ? "Updating now" : formatRelativeDate(updatedAt);
$("#last-updated").title = formatDate(updatedAt);
state.rmlReady = total > 0 && ready === total && !data.scheduler?.running;
const nextRefresh = data.scheduler?.next_refresh_at;
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);
+254
View File
@@ -0,0 +1,254 @@
<!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>
<div class="footer-meta">
<span id="footer-status">Workshop data refreshed every hour</span>
<span class="art-credit">Art by Ferny</span>
</div>
<a class="hr-link" href="https://hudsonriggs.systems" target="_blank" rel="noopener noreferrer" aria-label="Visit Hudson Riggs Systems">
<span>Hudson Riggs Systems</span>
<img src="/art/hudsonriggssystems.png" alt="">
</a>
</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>
+623
View File
@@ -0,0 +1,623 @@
@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 {
min-width: 320px;
min-height: 100vh;
margin: 0;
background:
radial-gradient(circle at 85% 8%, rgba(84, 114, 79, .12), transparent 27rem),
linear-gradient(180deg, #171717 0, #101214 100%);
color: var(--text);
font-family: var(--font-ui);
font-size: 16px;
line-height: 1.5;
}
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 {
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;
text-transform: uppercase;
}
.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-meta { 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 {
display: inline-flex;
align-items: center;
gap: 10px;
color: #f16365;
text-decoration: none;
transition: color .16s ease, transform .16s ease;
}
.hr-link:hover { color: #ff7b7d; transform: translateY(-1px); }
.hr-link img { width: 42px; height: 42px; 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; }
.brand { width: min(67vw, 330px); }
.service-state { font-size: 0; }
.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; }
.hr-link span { display: none; }
.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-meta { align-items: flex-start; flex-direction: column; gap: 5px; }
.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; }
}
+94
View File
@@ -0,0 +1,94 @@
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]) -> dict[str, list[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]] = {}
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)),
)
result[collection_id] = [
str(child["publishedfileid"]) for child in children
]
return result
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
+197
View File
@@ -0,0 +1,197 @@
from __future__ import annotations
import re
import shutil
import subprocess
import time
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
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
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 run_batch(self, workshop_ids: list[str]) -> 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)}")
command = [executable, "+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 = ""
try:
completed = subprocess.run(
command,
cwd=str(Path(executable).resolve().parent),
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=self.timeout_seconds,
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:],
)
+2
View File
@@ -0,0 +1,2 @@
-r requirements-web.txt
httpx2>=0.1.0
+5
View File
@@ -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
+4 -1
View File
@@ -4,4 +4,7 @@ pyglet>=1.5.0
Pillow>=8.0.0
packaging>=21.0
customtkinter>=5.0.0
rich>=13.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
+256
View File
@@ -0,0 +1,256 @@
from __future__ import annotations
import tempfile
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 RefreshService
from progression_backend.steam_api import WorkshopDetails
from progression_backend.workshop import SteamCmdBatchResult, parse_about_xml, utc_now
class FakeSteam:
def get_collections(self, collection_ids: list[str]) -> dict[str, list[str]]:
return {collection_id: ["123456"] for collection_id in collection_ids}
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
def available(self) -> bool:
return True
def run_batch(self, workshop_ids: list[str]) -> SteamCmdBatchResult:
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 &amp; 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 BackendTests(unittest.TestCase):
@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 &amp; 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_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 &amp; 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,
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=FakeSteam(),
runner=FakeRunner(settings.workshop_content_dir),
)
service.refresh("test-run", "test")
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")
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,
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")
self.assertIn("rml_updated_at", client.get("/api/status").json())
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)
if __name__ == "__main__":
unittest.main()