Initial sotf-modloader site with mod install, update and remove

This commit is contained in:
Justin Oros
2026-09-28 10:33:34 -07:00
commit 85ad3dde82
7 changed files with 1188 additions and 0 deletions
+45
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
_site/
__pycache__/
+38
View File
@@ -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.
+235
View File
@@ -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
View File
@@ -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();
+29
View File
@@ -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
View File
@@ -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;
}
}