Remove in-browser install panel, keep app and zip downloads

This commit is contained in:
Justin Oros
2026-09-28 14:31:08 -07:00
parent a22512b506
commit d076d584dc
4 changed files with 24 additions and 556 deletions
+1 -2
View File
@@ -6,11 +6,10 @@ Live site: https://justinoros.github.io/sotf-modloader/
## For players
The site offers three ways to install:
The site offers two ways to install:
1. **Windows app (recommended).** Download `SotfModLoader.exe` from the site and run it. It finds your game through Steam, including installs inside Program Files, and installs RedLoader and mods with one click.
2. **Zip download.** One zip with RedLoader and every mod. Extract it into your Sons of the Forest folder, the one that contains `SonsOfTheForest.exe`.
3. **Install from the page.** Works in Chrome or Edge when the game is not inside Program Files. Browsers are not allowed to open folders there.
Close the game before installing or updating.
+23 -442
View File
@@ -1,18 +1,4 @@
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 state = { manifest: null };
const $ = (id) => document.getElementById(id);
@@ -36,312 +22,16 @@ function setStatus(message, kind = "") {
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 countDownload(mod) {
if (mod.downloadUrl) fetch(mod.downloadUrl, { mode: "no-cors", cache: "no-store" }).catch(() => {});
}
function installMod(mod) {
return run(async () => {
const before = state.statuses.get(mod.id);
await installPackage(mod, mod.roots);
countDownload(mod);
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);
countDownload(mod);
}
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 formatSize(bytes) {
if (!bytes) return "";
if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(1)} MB`;
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
}
function link(href, text) {
return el("a", { href, target: "_blank", rel: "noopener" }, text);
}
function renderGet() {
const node = $("get");
const { app, bundle } = state.manifest;
@@ -369,161 +59,62 @@ function renderGet() {
node.replaceChildren(...cards);
}
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" }, "Install from this page"),
el("p", { class: "folder-help" }, "This browser can't write to your game folder. Use the Windows app or the zip download above."),
),
);
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" }, "Install from this page"),
el("p", { class: "folder-help" },
state.folderError ? `${state.folderError} ` : "",
"Chrome and Edge can't open folders inside Program Files, which is where Steam installs games by default. If Chrome says the folder contains system files, use the Windows app instead.",
),
),
el("button", { class: "secondary", 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("h3", {}, link(rl.repo, "RedLoader")),
el("p", { class: "desc" }, "Every mod on this page runs on RedLoader. The app and the zip include it."),
el("p", { class: "meta" }, el("span", {}, `Latest ${rl.version}`), link(rl.releaseUrl, "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)),
el("h3", {}, link(mod.repo, mod.name)),
mod.description ? el("p", { class: "desc" }, mod.description) : null,
el("p", { class: "meta" },
versionLine(mod, status),
el("span", {}, `Latest ${mod.version}`),
typeof mod.downloads === "number" ? el("span", {}, `${mod.downloads.toLocaleString()} downloads`) : null,
el("a", { href: mod.releaseUrl, target: "_blank", rel: "noopener" }, supportsFs ? "Release notes" : "Download"),
link(mod.releaseUrl, "Release notes"),
),
),
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 mods = [...state.manifest.mods].sort((a, b) => a.name.localeCompare(b.name));
const notes = mods.length ? [] : [el("p", { class: "block-note" }, "No mods are published yet.")];
$("mods").replaceChildren(
el("div", { class: "block-head" }, el("h2", { id: "mods-title" }, "Mods")),
...notes,
el("ul", { class: "rows" }, mods.map(modRow)),
);
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 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), "."),
el("p", {}, "Mods by ", link(`https://github.com/${owner}`, 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"), ".")
link(`${redloader.repo}/blob/main/LICENSE.md`, "Apache License 2.0"), ".")
: null,
el("p", {}, `Mod list updated ${updated}.`),
);
}
function render() {
if (!state.manifest) return;
renderGet();
renderFolder();
renderRedloader();
renderMods();
renderFooter();
}
async function init() {
try {
const res = await fetch("manifest.json", { cache: "no-cache" });
@@ -533,20 +124,10 @@ async function init() {
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();
renderGet();
renderRedloader();
renderMods();
renderFooter();
}
init();
-1
View File
@@ -18,7 +18,6 @@
<p class="lede">Install, update and remove Sons of the Forest mods with one click.</p>
</header>
<section id="get" class="get" aria-label="Ways to install" hidden></section>
<section id="folder" class="folder" aria-label="Install from this page"></section>
<section id="redloader" class="block" aria-labelledby="redloader-title" hidden></section>
<section id="mods" class="block" aria-labelledby="mods-title"></section>
<p id="status" class="status" role="status" aria-live="polite"></p>
-111
View File
@@ -145,54 +145,6 @@ a.button.secondary:hover {
color: var(--fog);
}
.folder {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 1rem 1.5rem;
margin-top: 1rem;
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;
}
@@ -266,66 +218,6 @@ a.button.secondary:hover {
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;
}
.status {
position: sticky;
bottom: 0;
@@ -366,7 +258,4 @@ button:disabled {
grid-template-columns: minmax(0, 1fr);
}
.actions {
justify-content: flex-start;
}
}