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

This commit is contained in:
justin committed 2026-09-28 10:33:34 -07:00
commit 85ad3dde82
7 files changed
+1188

No files matched your search

+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();