commit 85ad3dde82cf6c69b01a05d5fb6f5123ab18e5af Author: Justin Oros Date: Mon Sep 28 10:33:34 2026 -0700 Initial sotf-modloader site with mod install, update and remove diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..5e850a0 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,45 @@ +name: Build and deploy + +on: + push: + branches: [main] + workflow_dispatch: + schedule: + - cron: "17 */6 * * *" + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Build site + run: python build.py + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OWNER: ${{ github.repository_owner }} + - uses: actions/configure-pages@v5 + - uses: actions/upload-pages-artifact@v3 + with: + path: _site + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6f3eff8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +_site/ +__pycache__/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..0865501 --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# sotf-modloader + +A web page for installing, updating and removing Sons of the Forest mods with one click. + +Live site: https://justinoros.github.io/sotf-modloader/ + +## For players + +1. Open the site in Chrome or Edge on Windows. +2. Click **Choose game folder** and pick your Sons of the Forest install folder. +3. Install RedLoader if it isn't installed yet, then install the mods you want. + +The page remembers your folder. Close the game before installing or updating. + +## How it works + +A GitHub Actions workflow runs every 6 hours, on every push to `main`, and on demand. It: + +- finds every repo owned by the account with the `sotf-mod` topic and mirrors the zip from its latest release +- mirrors the latest RedLoader release +- lists repos with the `sotf-tool` topic under Tools +- publishes everything with a `manifest.json` to GitHub Pages + +The page writes files straight into the game folder using the browser's File System Access API. + +## Setup + +1. Settings, Pages, Source: **GitHub Actions**. +2. Add the `sotf-mod` topic to each mod repo and `sotf-tool` to each tool repo. +3. Actions, **Build and deploy**, **Run workflow**. + +## Mod release requirements + +- The latest release has a `.zip` asset containing `.dll` and `/manifest.json`. +- `version` in `manifest.json` matches the release tag, so update detection works. +- `platform` in `manifest.json` is `Client`, `Server` or `Universal`. + +After publishing a mod release, run the workflow manually to update the site right away. diff --git a/build.py b/build.py new file mode 100644 index 0000000..0c5574c --- /dev/null +++ b/build.py @@ -0,0 +1,235 @@ +import io +import json +import os +import shutil +import urllib.error +import urllib.request +import zipfile +from datetime import datetime, timezone +from pathlib import Path, PurePosixPath + +API = "https://api.github.com" +OWNER = os.environ.get("OWNER", "JustinOros") +TOKEN = os.environ.get("GITHUB_TOKEN", "") +REDLOADER_REPO = "ToniMacaroni/RedLoader" +MOD_TOPIC = "sotf-mod" +TOOL_TOPIC = "sotf-tool" +ROOT = Path(__file__).resolve().parent +SITE_SRC = ROOT / "site" +OUT = ROOT / "_site" + + +def request(url, accept="application/vnd.github+json"): + headers = {"Accept": accept, "User-Agent": "sotf-modloader-build"} + if TOKEN and url.startswith(API): + headers["Authorization"] = f"Bearer {TOKEN}" + req = urllib.request.Request(url, headers=headers) + with urllib.request.urlopen(req, timeout=300) as resp: + return resp.read() + + +def api(path): + return json.loads(request(API + path)) + + +def list_repos(): + repos = [] + page = 1 + while True: + batch = api(f"/users/{OWNER}/repos?per_page=100&page={page}&type=owner") + if not batch: + break + repos.extend(batch) + page += 1 + return [r for r in repos if not r.get("archived")] + + +def latest_release(full_name): + try: + return api(f"/repos/{full_name}/releases/latest") + except urllib.error.HTTPError as e: + if e.code == 404: + return None + raise + + +def zip_asset(release): + for asset in release.get("assets", []): + if asset["name"].lower().endswith(".zip"): + return asset + return None + + +def slug(value): + return "".join(c if c.isalnum() or c in "._-" else "-" for c in str(value)) or "x" + + +def safe_parts(name): + parts = [p for p in name.replace("\\", "/").split("/") if p not in ("", ".")] + if not parts or any(p == ".." or ":" in p for p in parts): + return None + return parts + + +def file_entries(zf): + for info in zf.infolist(): + if info.is_dir() or info.filename.endswith(("/", "\\")): + continue + parts = safe_parts(info.filename) + if parts: + yield info, parts + + +def extract(data, dest, prefix=()): + files = [] + with zipfile.ZipFile(io.BytesIO(data)) as zf: + for info, parts in file_entries(zf): + rel = PurePosixPath(*prefix, *parts) + target = dest.joinpath(*rel.parts) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(zf.read(info)) + files.append({"path": str(rel), "size": info.file_size}) + files.sort(key=lambda f: f["path"].lower()) + return files + + +def inspect_mod_zip(data): + meta = {} + tops = set() + with zipfile.ZipFile(io.BytesIO(data)) as zf: + for info, parts in file_entries(zf): + tops.add(parts[0].lower()) + if not meta and parts[-1].lower() == "manifest.json" and len(parts) >= 2: + try: + meta = json.loads(zf.read(info).decode("utf-8-sig")) + except ValueError: + meta = {} + prefix = () if tops == {"mods"} else ("Mods",) + return meta, prefix + + +def mod_layout(files): + paths = [f["path"] for f in files] + dll = next((p for p in paths if p.count("/") == 1 and p.lower().endswith(".dll")), None) + manifest_path = next((p for p in paths if p.count("/") == 2 and p.lower().endswith("/manifest.json")), None) + roots = sorted({"/".join(p.split("/")[:2]) for p in paths}, key=str.lower) + return dll, manifest_path, roots + + +def build_mod(repo): + release = latest_release(repo["full_name"]) + if not release: + print(f"skip {repo['name']}: no release") + return None + asset = zip_asset(release) + if not asset: + print(f"skip {repo['name']}: latest release has no zip") + return None + data = request(asset["browser_download_url"], accept="application/octet-stream") + meta, prefix = inspect_mod_zip(data) + tag_version = release["tag_name"].lstrip("vV") + mod_id = meta.get("id") or repo["name"].removeprefix("sotf-") + version = meta.get("version") or tag_version + if version != tag_version: + print(f"warning {repo['name']}: manifest.json version {version} does not match tag {release['tag_name']}") + base = PurePosixPath("packages", slug(mod_id), slug(version)) + files = extract(data, OUT.joinpath(*base.parts), prefix) + dll, manifest_path, roots = mod_layout(files) + if not dll: + print(f"skip {repo['name']}: no DLL at the top of the zip") + shutil.rmtree(OUT.joinpath(*base.parts), ignore_errors=True) + return None + print(f"mod {mod_id} {version} ({len(files)} files)") + return { + "id": mod_id, + "name": meta.get("name") or mod_id, + "description": meta.get("description") or repo.get("description") or "", + "author": meta.get("author") or OWNER, + "version": version, + "tag": release["tag_name"], + "platform": meta.get("platform") or "Client", + "gameVersion": meta.get("gameVersion") or "", + "repo": repo["html_url"], + "releaseUrl": release["html_url"], + "downloadUrl": asset["browser_download_url"], + "published": release.get("published_at"), + "base": f"{base}/", + "dll": dll, + "manifestPath": manifest_path, + "roots": roots, + "files": files, + } + + +def build_redloader(): + try: + release = latest_release(REDLOADER_REPO) + asset = zip_asset(release) if release else None + if not asset: + print("warning: RedLoader release zip not found") + return None + data = request(asset["browser_download_url"], accept="application/octet-stream") + except (urllib.error.URLError, OSError) as e: + print(f"warning: RedLoader download failed: {e}") + return None + version = release["tag_name"] + base = PurePosixPath("packages", "redloader", slug(version)) + files = extract(data, OUT.joinpath(*base.parts)) + print(f"RedLoader {version} ({len(files)} files)") + return { + "name": "RedLoader", + "version": version, + "repo": f"https://github.com/{REDLOADER_REPO}", + "releaseUrl": release["html_url"], + "downloadUrl": asset["browser_download_url"], + "license": "Apache-2.0", + "base": f"{base}/", + "files": files, + } + + +def build_tool(repo): + if repo.get("homepage"): + url = repo["homepage"] + elif repo.get("has_pages"): + url = f"https://{OWNER.lower()}.github.io/{repo['name']}/" + else: + url = repo["html_url"] + print(f"tool {repo['name']}") + return { + "name": repo["name"], + "description": repo.get("description") or "", + "url": url, + "repo": repo["html_url"], + } + + +def main(): + if OUT.exists(): + shutil.rmtree(OUT) + shutil.copytree(SITE_SRC, OUT) + mods = [] + tools = [] + for repo in sorted(list_repos(), key=lambda r: r["name"].lower()): + topics = repo.get("topics") or [] + if MOD_TOPIC in topics: + entry = build_mod(repo) + if entry: + mods.append(entry) + elif TOOL_TOPIC in topics: + tools.append(build_tool(repo)) + redloader = build_redloader() + manifest = { + "generated": datetime.now(timezone.utc).isoformat(timespec="seconds"), + "owner": OWNER, + "redloader": redloader, + "mods": mods, + "tools": tools, + } + (OUT / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8") + (OUT / ".nojekyll").write_text("", encoding="utf-8") + print(f"done: {len(mods)} mods, {len(tools)} tools") + + +if __name__ == "__main__": + main() diff --git a/site/app.js b/site/app.js new file mode 100644 index 0000000..5ad0ba3 --- /dev/null +++ b/site/app.js @@ -0,0 +1,534 @@ +const STATE_FILE = "sotf-modloader.json"; +const DB_NAME = "sotf-modloader"; +const CONCURRENCY = 6; +const supportsFs = "showDirectoryPicker" in window; + +const state = { + manifest: null, + root: null, + kind: null, + needsPermission: false, + folderError: null, + busy: false, + redloader: null, + statuses: new Map(), +}; + +const $ = (id) => document.getElementById(id); + +function el(tag, props = {}, ...children) { + const node = document.createElement(tag); + for (const [key, value] of Object.entries(props)) { + if (value === null || value === undefined || value === false) continue; + if (key.startsWith("on")) node.addEventListener(key.slice(2), value); + else if (key === "class") node.className = value; + else node.setAttribute(key, value === true ? "" : value); + } + for (const child of children.flat()) { + if (child !== null && child !== undefined && child !== false) node.append(child); + } + return node; +} + +function setStatus(message, kind = "") { + const node = $("status"); + node.textContent = message; + node.dataset.kind = kind; +} + +function idb(mode, fn) { + return new Promise((resolve, reject) => { + const open = indexedDB.open(DB_NAME, 1); + open.onupgradeneeded = () => open.result.createObjectStore("kv"); + open.onerror = () => reject(open.error); + open.onsuccess = () => { + const tx = open.result.transaction("kv", mode); + const req = fn(tx.objectStore("kv")); + tx.oncomplete = () => resolve(req.result); + tx.onerror = () => reject(tx.error); + }; + }); +} + +const kvGet = (key) => idb("readonly", (store) => store.get(key)); +const kvSet = (key, value) => idb("readwrite", (store) => store.put(value, key)); + +function splitPath(path) { + const parts = path.split("/"); + const name = parts.pop(); + return { parts, name }; +} + +async function getDir(root, parts, create) { + let dir = root; + for (const part of parts) dir = await dir.getDirectoryHandle(part, { create }); + return dir; +} + +async function exists(root, path) { + const { parts, name } = splitPath(path); + let dir; + try { + dir = await getDir(root, parts, false); + } catch { + return false; + } + try { + await dir.getFileHandle(name); + return true; + } catch {} + try { + await dir.getDirectoryHandle(name); + return true; + } catch { + return false; + } +} + +async function readText(root, path) { + const { parts, name } = splitPath(path); + try { + const dir = await getDir(root, parts, false); + const file = await (await dir.getFileHandle(name)).getFile(); + return (await file.text()).replace(/^\uFEFF/, ""); + } catch { + return null; + } +} + +async function readJson(root, path) { + const text = await readText(root, path); + if (text === null) return null; + try { + return JSON.parse(text); + } catch { + return null; + } +} + +async function writeFile(root, path, data) { + const { parts, name } = splitPath(path); + const dir = await getDir(root, parts, true); + const handle = await dir.getFileHandle(name, { create: true }); + const writable = await handle.createWritable(); + try { + await writable.write(data); + await writable.close(); + } catch (e) { + await writable.abort().catch(() => {}); + throw e; + } +} + +async function removePath(root, path) { + const { parts, name } = splitPath(path); + try { + const dir = await getDir(root, parts, false); + await dir.removeEntry(name, { recursive: true }); + } catch (e) { + if (e.name !== "NotFoundError") throw e; + } +} + +function fileUrl(pkg, path) { + return pkg.base + path.split("/").map(encodeURIComponent).join("/"); +} + +async function downloadPackage(pkg, onProgress) { + const total = pkg.files.length; + const blobs = new Array(total); + let next = 0; + let done = 0; + async function worker() { + while (next < total) { + const index = next++; + const file = pkg.files[index]; + const res = await fetch(fileUrl(pkg, file.path), { cache: "no-cache" }); + if (!res.ok) throw new Error(`Download failed for ${file.path} (HTTP ${res.status}).`); + blobs[index] = await res.blob(); + done++; + onProgress(`Downloading ${done} of ${total} files`); + } + } + await Promise.all(Array.from({ length: Math.min(CONCURRENCY, total) }, worker)); + return blobs; +} + +async function installPackage(pkg, cleanRoots) { + const label = `${pkg.name} ${pkg.version}`; + const blobs = await downloadPackage(pkg, (msg) => setStatus(`${label}: ${msg}`)); + for (const root of cleanRoots) await removePath(state.root, root); + for (let i = 0; i < pkg.files.length; i++) { + setStatus(`${label}: Writing ${i + 1} of ${pkg.files.length} files`); + await writeFile(state.root, pkg.files[i].path, blobs[i]); + } +} + +async function detectKind(root) { + if (await exists(root, "SonsOfTheForest.exe")) return "client"; + if (await exists(root, "SonsOfTheForestDS.exe")) return "server"; + return null; +} + +async function modStatus(mod) { + if (!(await exists(state.root, mod.dll))) return { installed: false }; + const meta = mod.manifestPath ? await readJson(state.root, mod.manifestPath) : null; + const version = meta && meta.version ? String(meta.version) : null; + return { installed: true, version, current: version === mod.version }; +} + +async function redloaderStatus(rl) { + const installed = (await exists(state.root, "version.dll")) && (await exists(state.root, "_Redloader")); + if (!installed) return { installed: false }; + const saved = await readJson(state.root, STATE_FILE); + const version = saved && saved.redloader ? String(saved.redloader) : null; + return { installed: true, version, current: version === rl.version }; +} + +async function refresh() { + if (!state.root || !state.kind) return; + const { mods, redloader } = state.manifest; + state.redloader = redloader ? await redloaderStatus(redloader) : null; + state.statuses.clear(); + for (const mod of mods) state.statuses.set(mod.id, await modStatus(mod)); +} + +function explain(e) { + const name = e && e.name; + if (name === "NoModificationAllowedError" || name === "InvalidModificationError") { + return "A file is in use. Close Sons of the Forest and try again."; + } + if (name === "NotAllowedError" || name === "SecurityError") { + return "The browser blocked access to the game folder. Reconnect the folder and try again."; + } + if (name === "AbortError") { + return "The browser stopped the write, possibly a download safety check. Try again, or use the release page link to install manually."; + } + return (e && e.message) || String(e); +} + +async function run(task) { + if (state.busy) return; + state.busy = true; + render(); + try { + await task(); + } catch (e) { + setStatus(explain(e), "error"); + } finally { + try { + await refresh(); + } catch {} + state.busy = false; + render(); + } +} + +async function connect(handle) { + const kind = await detectKind(handle); + if (!kind) { + state.folderError = `${handle.name} doesn't contain SonsOfTheForest.exe. Choose the folder the game is installed in.`; + return false; + } + state.root = handle; + state.kind = kind; + state.needsPermission = false; + state.folderError = null; + await kvSet("root", handle).catch(() => {}); + await refresh(); + return true; +} + +async function chooseFolder() { + let handle; + try { + handle = await window.showDirectoryPicker({ id: "sotf-game", mode: "readwrite" }); + } catch (e) { + if (e.name !== "AbortError") setStatus(explain(e), "error"); + return; + } + await run(async () => { + if (await connect(handle)) setStatus(`Connected to ${handle.name}.`, "ok"); + else setStatus(state.folderError, "error"); + }); +} + +async function reconnect() { + const permission = await state.root.requestPermission({ mode: "readwrite" }); + if (permission !== "granted") { + setStatus("Access wasn't granted. Choose the game folder to continue.", "error"); + return; + } + await run(async () => { + if (await connect(state.root)) setStatus(`Connected to ${state.root.name}.`, "ok"); + }); +} + +function installRedloader() { + const rl = state.manifest.redloader; + return run(async () => { + await installPackage(rl, []); + const saved = (await readJson(state.root, STATE_FILE)) || {}; + saved.redloader = rl.version; + await writeFile(state.root, STATE_FILE, JSON.stringify(saved, null, 2)); + setStatus(`RedLoader ${rl.version} installed. Launch the game once so it can finish setting up.`, "ok"); + }); +} + +function installMod(mod) { + return run(async () => { + const before = state.statuses.get(mod.id); + await installPackage(mod, mod.roots); + const message = before && before.installed ? `${mod.name} updated to ${mod.version}.` : `${mod.name} ${mod.version} installed.`; + setStatus(message, "ok"); + }); +} + +function removeMod(mod) { + if (!window.confirm(`Remove ${mod.name} from your game?`)) return; + return run(async () => { + for (const root of mod.roots) await removePath(state.root, root); + setStatus(`${mod.name} removed.`, "ok"); + }); +} + +function updateAll(mods) { + return run(async () => { + for (const mod of mods) await installPackage(mod, mod.roots); + setStatus(`Updated ${mods.length} mod${mods.length === 1 ? "" : "s"}.`, "ok"); + }); +} + +function visibleMods() { + const mods = [...state.manifest.mods].sort((a, b) => a.name.localeCompare(b.name)); + if (!state.kind) return mods; + return mods.filter((mod) => { + const platform = (mod.platform || "Client").toLowerCase(); + return platform === "universal" || platform === state.kind; + }); +} + +function connected() { + return Boolean(state.root && state.kind && !state.needsPermission); +} + +function versionLine(pkg, status) { + const items = [el("span", {}, `Latest ${pkg.version}`)]; + if (status && status.installed) { + if (status.current) { + items.push(el("span", { class: "state-current" }, "Installed, up to date")); + } else if (status.version) { + items.push(el("span", { class: "state-update" }, `Installed ${status.version}, update available`)); + } else { + items.push(el("span", { class: "state-update" }, "Installed, version unknown")); + } + } else if (status) { + items.push(el("span", {}, "Not installed")); + } + return items; +} + +function renderFolder() { + const node = $("folder"); + node.replaceChildren(); + node.dataset.state = state.folderError ? "error" : connected() ? "connected" : "idle"; + + if (!supportsFs) { + node.append( + el("div", { class: "folder-text" }, + el("p", { class: "folder-name" }, "This browser can't install mods"), + el("p", { class: "folder-help" }, "Open this page in Chrome or Edge on Windows to install mods with one click. You can still download each mod from its release page below."), + ), + ); + return; + } + + if (connected()) { + node.append( + el("div", { class: "folder-text" }, + el("p", { class: "folder-name" }, state.root.name), + el("p", { class: "folder-help" }, state.kind === "server" ? "Dedicated server folder" : "Game folder"), + ), + el("button", { class: "secondary", disabled: state.busy, onclick: chooseFolder }, "Change folder"), + ); + return; + } + + if (state.root && state.needsPermission) { + node.append( + el("div", { class: "folder-text" }, + el("p", { class: "folder-name" }, state.root.name), + el("p", { class: "folder-help" }, "Allow access to this folder again to manage your mods."), + ), + el("div", { class: "actions" }, + el("button", { class: "secondary", disabled: state.busy, onclick: chooseFolder }, "Choose another"), + el("button", { class: "primary", disabled: state.busy, onclick: reconnect }, "Reconnect"), + ), + ); + return; + } + + node.append( + el("div", { class: "folder-text" }, + el("p", { class: "folder-name" }, "Choose your game folder"), + el("p", { class: "folder-help" }, + state.folderError ? `${state.folderError} ` : "", + "On Steam it's usually ", + el("code", {}, "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Sons Of The Forest"), + ". In Steam you can find it with Manage, Browse local files.", + ), + ), + el("button", { class: "primary", disabled: state.busy, onclick: chooseFolder }, "Choose game folder"), + ); +} + +function renderRedloader() { + const node = $("redloader"); + const rl = state.manifest.redloader; + node.hidden = !rl; + if (!rl) return; + const status = connected() ? state.redloader : null; + const actions = []; + if (connected() && status) { + if (!status.installed) { + actions.push(el("button", { class: "primary", disabled: state.busy, onclick: installRedloader }, "Install RedLoader")); + } else if (!status.current) { + actions.push(el("button", { class: status.version ? "primary" : "secondary", disabled: state.busy, onclick: installRedloader }, status.version ? "Update" : `Reinstall ${rl.version}`)); + } + } + node.replaceChildren( + el("div", { class: "block-head" }, el("h2", { id: "redloader-title" }, "Mod framework")), + el("ul", { class: "rows" }, + el("li", { class: "row" }, + el("div", {}, + el("h3", {}, el("a", { href: rl.repo, target: "_blank", rel: "noopener" }, "RedLoader")), + el("p", { class: "desc" }, "Every mod on this page runs on RedLoader. Install it before adding mods."), + el("p", { class: "meta" }, versionLine(rl, status), el("a", { href: rl.releaseUrl, target: "_blank", rel: "noopener" }, "Release notes")), + ), + el("div", { class: "actions" }, actions), + ), + ), + ); +} + +function modRow(mod) { + const status = connected() ? state.statuses.get(mod.id) : null; + const actions = []; + if (connected() && status) { + if (!status.installed) { + actions.push(el("button", { class: "primary", disabled: state.busy, onclick: () => installMod(mod) }, "Install")); + } else { + if (!status.current) { + actions.push(el("button", { class: "primary", disabled: state.busy, onclick: () => installMod(mod) }, status.version ? "Update" : "Reinstall")); + } + actions.push(el("button", { class: "danger", disabled: state.busy, onclick: () => removeMod(mod) }, "Remove")); + } + } + return el("li", { class: "row" }, + el("div", {}, + el("h3", {}, el("a", { href: mod.repo, target: "_blank", rel: "noopener" }, mod.name)), + mod.description ? el("p", { class: "desc" }, mod.description) : null, + el("p", { class: "meta" }, + versionLine(mod, status), + el("a", { href: mod.releaseUrl, target: "_blank", rel: "noopener" }, supportsFs ? "Release notes" : "Download"), + ), + ), + el("div", { class: "actions" }, actions), + ); +} + +function renderMods() { + const node = $("mods"); + const mods = visibleMods(); + const outdated = connected() + ? mods.filter((mod) => { + const status = state.statuses.get(mod.id); + return status && status.installed && !status.current; + }) + : []; + const head = el("div", { class: "block-head" }, + el("h2", { id: "mods-title" }, "Mods"), + outdated.length > 1 + ? el("button", { class: "primary", disabled: state.busy, onclick: () => updateAll(outdated) }, `Update all (${outdated.length})`) + : null, + ); + const notes = []; + if (connected() && state.redloader && !state.redloader.installed && state.manifest.redloader) { + notes.push(el("p", { class: "block-note" }, "RedLoader isn't installed yet. Mods won't load until it is.")); + } + if (!mods.length) { + notes.push(el("p", { class: "block-note" }, state.kind === "server" ? "No mods here run on a dedicated server yet." : "No mods are published yet.")); + } + node.replaceChildren(head, ...notes, el("ul", { class: "rows" }, mods.map(modRow))); +} + +function renderTools() { + const node = $("tools"); + const tools = state.manifest.tools || []; + node.hidden = !tools.length; + if (!tools.length) return; + node.classList.add("tools"); + node.replaceChildren( + el("div", { class: "block-head" }, el("h2", { id: "tools-title" }, "Tools")), + el("ul", { class: "rows" }, + tools.map((tool) => + el("li", { class: "row" }, + el("div", {}, + el("h3", {}, el("a", { href: tool.url, target: "_blank", rel: "noopener" }, tool.name)), + tool.description ? el("p", { class: "desc" }, tool.description) : null, + el("p", { class: "meta" }, el("a", { href: tool.repo, target: "_blank", rel: "noopener" }, "Source")), + ), + ), + ), + ), + ); +} + +function renderFooter() { + const { owner, generated, redloader } = state.manifest; + const updated = generated ? new Date(generated).toLocaleString() : "unknown"; + $("footer").replaceChildren( + el("p", {}, "Mods by ", el("a", { href: `https://github.com/${owner}`, target: "_blank", rel: "noopener" }, owner), "."), + redloader + ? el("p", {}, "RedLoader by ToniMacaroni, redistributed unmodified under the ", + el("a", { href: `${redloader.repo}/blob/main/LICENSE.md`, target: "_blank", rel: "noopener" }, "Apache License 2.0"), ".") + : null, + el("p", {}, `Mod list updated ${updated}.`), + ); +} + +function render() { + if (!state.manifest) return; + renderFolder(); + renderRedloader(); + renderMods(); + renderTools(); + renderFooter(); +} + +async function init() { + try { + const res = await fetch("manifest.json", { cache: "no-cache" }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + state.manifest = await res.json(); + } catch { + setStatus("The mod list couldn't be loaded. Refresh the page to try again.", "error"); + return; + } + if (supportsFs) { + const saved = await kvGet("root").catch(() => null); + if (saved) { + state.root = saved; + const permission = await saved.queryPermission({ mode: "readwrite" }).catch(() => "denied"); + if (permission === "granted") { + const ok = await connect(saved).catch(() => false); + if (!ok) state.root = null; + } else { + state.needsPermission = true; + } + } + } + render(); +} + +init(); diff --git a/site/index.html b/site/index.html new file mode 100644 index 0000000..f7780b6 --- /dev/null +++ b/site/index.html @@ -0,0 +1,29 @@ + + + + + +Sons of the Forest Mod Loader + + + + + + + + +
+
+

Sons of the Forest mod loader

+

Pick your game folder once, then install, update and remove mods with one click.

+
+
+ +
+ +

+
+
+ + + diff --git a/site/style.css b/site/style.css new file mode 100644 index 0000000..0e0a01e --- /dev/null +++ b/site/style.css @@ -0,0 +1,305 @@ +:root { + color-scheme: dark; + --pine: #131c17; + --bark: #1d2821; + --bark-line: #34463b; + --lichen: #9aae97; + --fog: #e2e7df; + --flare: #e0a63a; + --flare-hover: #ecbb5e; + --flare-ink: #1b1305; + --ember: #e38b7f; + --display: "Barlow Condensed", "Arial Narrow", "Roboto Condensed", sans-serif; + --body: "Barlow", "Segoe UI", system-ui, sans-serif; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + background: var(--pine); +} + +body { + margin: 0; + background: var(--pine); + color: var(--fog); + font-family: var(--body); + font-size: 1.0625rem; + line-height: 1.55; + -webkit-font-smoothing: antialiased; +} + +a { + color: var(--flare); + text-underline-offset: 0.2em; +} + +a:hover { + color: var(--fog); +} + +:focus-visible { + outline: 2px solid var(--flare); + outline-offset: 3px; +} + +[hidden] { + display: none !important; +} + +.page { + max-width: 54rem; + margin: 0 auto; + padding: clamp(2.5rem, 7vw, 5rem) clamp(1.25rem, 4vw, 2.5rem) 2rem; +} + +.masthead h1 { + margin: 0; + max-width: 11ch; + font-family: var(--display); + font-weight: 700; + font-size: clamp(3rem, 10vw, 5.75rem); + line-height: 0.9; + letter-spacing: -0.015em; +} + +.lede { + max-width: 36rem; + margin: 1.25rem 0 0; + color: var(--lichen); + font-size: 1.15rem; +} + +.folder { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 1rem 1.5rem; + margin-top: 2.75rem; + padding: 1.25rem 1.5rem; + background: var(--bark); + border: 1px solid var(--bark-line); + border-left: 5px solid var(--bark-line); + border-radius: 6px; +} + +.folder[data-state="connected"] { + border-left-color: var(--flare); +} + +.folder[data-state="error"] { + border-left-color: var(--ember); +} + +.folder-text { + flex: 1 1 22rem; + min-width: 0; +} + +.folder-name { + margin: 0; + font-family: var(--display); + font-weight: 600; + font-size: 1.5rem; + line-height: 1.15; + overflow-wrap: anywhere; +} + +.folder-help { + margin: 0.25rem 0 0; + color: var(--lichen); + font-size: 0.95rem; +} + +.folder-help code { + color: var(--fog); + font-size: 0.9em; + overflow-wrap: anywhere; +} + +.block { + margin-top: 3.25rem; +} + +.block-head { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: 0.75rem 1rem; + padding-bottom: 0.6rem; + border-bottom: 2px solid var(--fog); +} + +.block-head h2 { + margin: 0; + font-family: var(--display); + font-weight: 700; + font-size: 2rem; + line-height: 1.1; +} + +.block-note { + margin: 1rem 0 0; + color: var(--lichen); +} + +.rows { + margin: 0; + padding: 0; + list-style: none; +} + +.row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.75rem 1.5rem; + align-items: start; + padding: 1.35rem 0; + border-bottom: 1px solid var(--bark-line); +} + +.row h3 { + margin: 0; + font-family: var(--display); + font-weight: 600; + font-size: 1.5rem; + line-height: 1.15; +} + +.row h3 a { + color: inherit; + text-decoration: none; +} + +.row h3 a:hover { + text-decoration: underline; +} + +.desc { + max-width: 34rem; + margin: 0.2rem 0 0; +} + +.meta { + display: flex; + flex-wrap: wrap; + gap: 0.2rem 1.1rem; + margin: 0.55rem 0 0; + color: var(--lichen); + font-size: 0.9rem; +} + +.meta .state-current { + color: var(--fog); +} + +.meta .state-update { + color: var(--flare); + font-weight: 600; +} + +.actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.5rem; +} + +button { + padding: 0.55rem 1.05rem; + border: 1px solid transparent; + border-radius: 4px; + font: inherit; + font-weight: 600; + font-size: 0.95rem; + line-height: 1.2; + cursor: pointer; +} + +button.primary { + background: var(--flare); + color: var(--flare-ink); +} + +button.primary:hover:not(:disabled) { + background: var(--flare-hover); +} + +button.secondary { + background: transparent; + color: var(--fog); + border-color: var(--bark-line); +} + +button.secondary:hover:not(:disabled) { + border-color: var(--lichen); +} + +button.danger { + background: transparent; + color: var(--ember); +} + +button.danger:hover:not(:disabled) { + border-color: var(--ember); +} + +button:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.tools .row { + grid-template-columns: minmax(0, 1fr); +} + +.status { + position: sticky; + bottom: 0; + margin: 2rem 0 0; + padding: 0.85rem 1rem; + background: var(--bark); + border: 1px solid var(--bark-line); + border-radius: 6px; + color: var(--fog); + font-size: 0.95rem; +} + +.status:empty { + display: none; +} + +.status[data-kind="error"] { + border-color: var(--ember); + color: var(--ember); +} + +.status[data-kind="ok"] { + border-color: var(--flare); +} + +.footer { + margin-top: 4rem; + color: var(--lichen); + font-size: 0.875rem; +} + +.footer p { + margin: 0.3rem 0 0; +} + +@media (max-width: 36rem) { + .row { + grid-template-columns: minmax(0, 1fr); + } + + .actions { + justify-content: flex-start; + } +}