Initial sotf-modloader site with mod install, update and remove
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
_site/
|
||||
__pycache__/
|
||||
@@ -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 `<Name>.dll` and `<Name>/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.
|
||||
@@ -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()
|
||||
+534
@@ -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();
|
||||
@@ -0,0 +1,29 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sons of the Forest Mod Loader</title>
|
||||
<meta name="description" content="Install, update and remove Sons of the Forest mods straight into your game folder.">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🪓</text></svg>">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Barlow:wght@400;500;600&family=Barlow+Condensed:wght@600;700&display=swap">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="page">
|
||||
<header class="masthead">
|
||||
<h1>Sons of the Forest mod loader</h1>
|
||||
<p class="lede">Pick your game folder once, then install, update and remove mods with one click.</p>
|
||||
</header>
|
||||
<section id="folder" class="folder" aria-label="Game folder"></section>
|
||||
<section id="redloader" class="block" aria-labelledby="redloader-title" hidden></section>
|
||||
<section id="mods" class="block" aria-labelledby="mods-title"></section>
|
||||
<section id="tools" class="block" aria-labelledby="tools-title" hidden></section>
|
||||
<p id="status" class="status" role="status" aria-live="polite"></p>
|
||||
<footer id="footer" class="footer"></footer>
|
||||
</main>
|
||||
<script type="module" src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+305
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user