diff --git a/README.md b/README.md index e74b990..c8dcf35 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/site/app.js b/site/app.js index 578efcc..0cf88f5 100644 --- a/site/app.js +++ b/site/app.js @@ -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(); diff --git a/site/index.html b/site/index.html index 5d637c3..f61d606 100644 --- a/site/index.html +++ b/site/index.html @@ -18,7 +18,6 @@
Install, update and remove Sons of the Forest mods with one click.
- diff --git a/site/style.css b/site/style.css index f993a0f..007319c 100644 --- a/site/style.css +++ b/site/style.css @@ -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; - } }