10 Commits
Author SHA1 Message Date
Justin Oros 5be9ff4862 Shorten Windows app card text
Build and deploy / build (push) Waiting to run
Build and deploy / deploy (push) Blocked by required conditions
2026-09-28 16:46:43 -07:00
Justin Oros c1bbfa06d5 List PowerShell scripts as the third install option in the README 2026-09-28 16:36:18 -07:00
Justin Oros 375a2a54fc Credit SignPath for code signing in the site footer 2026-09-28 16:19:10 -07:00
Justin Oros 8057ba9a09 Reword page intro 2026-09-28 16:04:17 -07:00
Justin Oros 952895f359 Say my mods in the page intro 2026-09-28 15:57:56 -07:00
Justin Oros c3726f7af5 Label option 1 as Recommended in parentheses 2026-09-28 15:55:45 -07:00
Justin Oros 967bd58f00 Show the three install options as numbered cards 2026-09-28 15:53:46 -07:00
Justin Oros 9bac1f221e Add RedLoader version picker to the Windows app
Build Windows app / app (push) Waiting to run
2026-09-28 15:05:19 -07:00
Justin Oros b5b1a48153 Add copy PowerShell install button and version stamp site assets 2026-09-28 14:48:14 -07:00
Justin Oros d076d584dc Remove in-browser install panel, keep app and zip downloads 2026-09-28 14:31:08 -07:00
10 changed files with 336 additions and 608 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ The site offers three 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.
3. **PowerShell scripts.** Each mod has a **Copy PowerShell install** button on the site. Paste the command into PowerShell to install that mod, plus RedLoader if needed. Run it again to update.
Close the game before installing or updating.
+87 -18
View File
@@ -51,6 +51,7 @@ namespace SotfModLoader
private string _kind;
private bool _busy;
private ModStatus _redloaderStatus;
private string _selectedRedLoader;
private readonly Dictionary<string, ModStatus> _statuses = new Dictionary<string, ModStatus>();
public MainForm()
@@ -124,8 +125,8 @@ namespace SotfModLoader
Padding = new Padding(S(18), S(12), S(14), S(12))
};
_folderPanel.Paint += PaintFolderPanel;
_folderName = new Label { AutoSize = true, Font = _rowTitleFont, ForeColor = Fog, BackColor = Bark };
_folderHelp = new Label { AutoSize = true, Font = _metaFont, ForeColor = Lichen, BackColor = Bark };
_folderName = new Label { AutoSize = false, Font = _rowTitleFont, ForeColor = Fog, BackColor = Bark };
_folderHelp = new Label { AutoSize = false, Font = _metaFont, ForeColor = Lichen, BackColor = Bark };
_folderButton = MakeButton("Choose folder", Style.Secondary, (s, e) => ChooseFolder());
_folderPanel.Controls.Add(_folderName);
_folderPanel.Controls.Add(_folderHelp);
@@ -305,8 +306,8 @@ namespace SotfModLoader
var width = _folderPanel.ClientSize.Width;
_folderButton.Location = new Point(width - _folderButton.Width - S(14), S(14));
var textWidth = Math.Max(S(160), _folderButton.Left - S(40));
_folderName.MaximumSize = new Size(textWidth, 0);
_folderHelp.MaximumSize = new Size(textWidth, 0);
RowPanel.Fit(_folderName, textWidth);
RowPanel.Fit(_folderHelp, textWidth);
_folderName.Location = new Point(S(20), S(12));
_folderHelp.Location = new Point(S(20), _folderName.Bottom + S(4));
_folderPanel.Height = Math.Max(_folderHelp.Bottom, _folderButton.Bottom) + S(14);
@@ -394,25 +395,90 @@ namespace SotfModLoader
return string.Join(" ", parts);
}
private List<RedLoaderVersion> RedLoaderVersions()
{
var versions = _manifest.redloaderVersions;
if (versions != null && versions.Count > 0)
return versions;
var rl = _manifest.redloader;
return new List<RedLoaderVersion> { new RedLoaderVersion { version = rl.version, releaseUrl = rl.releaseUrl } };
}
private int SelectedRedLoaderIndex(List<RedLoaderVersion> versions)
{
if (_selectedRedLoader == null)
return 0;
var index = versions.FindIndex(v => v.version == _selectedRedLoader);
return index < 0 ? 0 : index;
}
private Control RedLoaderRow()
{
var rl = _manifest.redloader;
var versions = RedLoaderVersions();
var selectedIndex = SelectedRedLoaderIndex(versions);
var selected = versions[selectedIndex];
var latest = versions[0];
var status = _gameDir != null ? _redloaderStatus : null;
var buttons = new List<Control>();
var controls = new List<Control>();
if (status != null)
{
if (versions.Count > 1)
{
var combo = new ComboBox
{
DropDownStyle = ComboBoxStyle.DropDownList,
FlatStyle = FlatStyle.Flat,
BackColor = Bark,
ForeColor = Fog,
Font = _buttonFont,
Width = S(150),
Margin = new Padding(S(6), S(2), 0, 0),
Enabled = !_busy
};
for (var i = 0; i < versions.Count; i++)
combo.Items.Add(i == 0 ? "Latest (" + versions[i].version + ")" : versions[i].version);
combo.SelectedIndex = selectedIndex;
combo.SelectedIndexChanged += (s, e) =>
{
_selectedRedLoader = combo.SelectedIndex <= 0 ? null : versions[combo.SelectedIndex].version;
BeginInvoke((Action)Render);
};
controls.Add(combo);
}
Button button = null;
if (!status.Installed)
button = MakeButton("Install RedLoader", Style.Primary, (s, e) => InstallRedLoader(selected));
else if (status.Version == selected.version)
button = MakeButton("Reinstall", Style.Secondary, (s, e) => InstallRedLoader(selected));
else if (status.Version == null)
button = MakeButton("Install " + selected.version, Style.Secondary, (s, e) => InstallRedLoader(selected));
else if (selectedIndex == 0)
button = MakeButton("Update", Style.Primary, (s, e) => InstallRedLoader(selected));
else
button = MakeButton("Install " + selected.version, Style.Primary, (s, e) => InstallRedLoader(selected));
button.Enabled = !_busy;
controls.Add(button);
}
var parts = new List<string> { "Latest " + latest.version };
if (status != null)
{
if (!status.Installed)
buttons.Add(MakeButton("Install RedLoader", Style.Primary, (s, e) => InstallRedLoader()));
else if (!status.Current)
buttons.Add(MakeButton(status.Version != null ? "Update" : "Reinstall " + rl.version,
status.Version != null ? Style.Primary : Style.Secondary, (s, e) => InstallRedLoader()));
parts.Add("Not installed");
else if (status.Version == null)
parts.Add("Installed, version unknown");
else if (status.Version == latest.version)
parts.Add("Installed, up to date");
else
parts.Add("Installed " + status.Version);
}
foreach (var b in buttons)
b.Enabled = !_busy;
var metaColor = status != null && status.Installed && !status.Current ? Flare : Lichen;
var metaColor = status != null && status.Installed && status.Version != latest.version ? Flare : Lichen;
return new RowPanel("RedLoader", rl.repo,
"Every mod here runs on RedLoader. Install it before adding mods.",
VersionText(rl, status), metaColor, buttons,
"Every mod here runs on RedLoader. Install it before adding mods. Pick an older version only if a mod needs one.",
string.Join(" ", parts), metaColor, controls,
_rowTitleFont, _bodyFont, _metaFont, Fog, Lichen, BarkLine, _scale);
}
@@ -543,14 +609,17 @@ namespace SotfModLoader
}
}
private async void InstallRedLoader()
private async void InstallRedLoader(RedLoaderVersion version)
{
var rl = _manifest.redloader;
await Run(async progress =>
{
await ModInstaller.Install(_gameDir, rl, progress);
ModInstaller.WriteRedLoaderVersion(_gameDir, rl.version);
return "RedLoader " + rl.version + " installed. Launch the game once and wait for the main menu so it can finish setting up.";
if (!string.IsNullOrEmpty(version.downloadUrl))
await ModInstaller.InstallZip(_gameDir, version.downloadUrl, "RedLoader " + version.version, progress);
else
await ModInstaller.Install(_gameDir, rl, progress);
ModInstaller.WriteRedLoaderVersion(_gameDir, version.version);
return "RedLoader " + version.version + " installed. Launch the game once and wait for the main menu so it can finish setting up.";
});
}
+51
View File
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Net.Http;
using System.Reflection;
@@ -164,6 +165,56 @@ namespace SotfModLoader
}
}
public static async Task InstallZip(string gameDir, string url, string label, IProgress<string> progress)
{
byte[] data;
using (var response = await Http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead))
{
response.EnsureSuccessStatusCode();
var total = response.Content.Headers.ContentLength ?? 0;
using (var stream = await response.Content.ReadAsStreamAsync())
using (var memory = new MemoryStream())
{
var buffer = new byte[81920];
long read = 0;
var lastPercent = -1;
int count;
while ((count = await stream.ReadAsync(buffer, 0, buffer.Length)) > 0)
{
memory.Write(buffer, 0, count);
read += count;
if (total > 0)
{
var percent = (int)(read * 100 / total);
if (percent != lastPercent)
{
lastPercent = percent;
progress.Report(label + ": downloading " + percent + "%");
}
}
}
data = memory.ToArray();
}
}
using (var zip = new ZipArchive(new MemoryStream(data), ZipArchiveMode.Read))
{
var entries = zip.Entries
.Where(e => !string.IsNullOrEmpty(e.Name) && !e.FullName.EndsWith("/") && !e.FullName.EndsWith("\\"))
.ToList();
for (var i = 0; i < entries.Count; i++)
{
var entry = entries[i];
progress.Report(label + ": writing " + (i + 1) + " of " + entries.Count + " files");
var target = FullPath(gameDir, entry.FullName.Replace('\\', '/'));
Directory.CreateDirectory(Path.GetDirectoryName(target));
using (var input = entry.Open())
using (var output = File.Create(target))
input.CopyTo(output);
}
}
}
public static void Uninstall(string gameDir, Package pkg)
{
if (pkg.roots == null)
+9
View File
@@ -26,6 +26,14 @@ namespace SotfModLoader
public List<PackageFile> files { get; set; }
}
public class RedLoaderVersion
{
public string version { get; set; }
public string downloadUrl { get; set; }
public string releaseUrl { get; set; }
public long size { get; set; }
}
public class AppInfo
{
public string version { get; set; }
@@ -37,6 +45,7 @@ namespace SotfModLoader
public string generated { get; set; }
public string owner { get; set; }
public Package redloader { get; set; }
public List<RedLoaderVersion> redloaderVersions { get; set; }
public List<Package> mods { get; set; }
public AppInfo app { get; set; }
}
+11 -4
View File
@@ -89,8 +89,8 @@ namespace SotfModLoader
if (!string.IsNullOrEmpty(url))
_title.LinkClicked += (s, e) => MainForm.OpenUrl(url);
_desc = new Label { Text = description ?? string.Empty, Font = bodyFont, AutoSize = true, ForeColor = textColor, BackColor = Color.Transparent };
_meta = new Label { Text = meta ?? string.Empty, Font = metaFont, AutoSize = true, ForeColor = metaColor, BackColor = Color.Transparent };
_desc = new Label { Text = description ?? string.Empty, Font = bodyFont, AutoSize = false, ForeColor = textColor, BackColor = Color.Transparent };
_meta = new Label { Text = meta ?? string.Empty, Font = metaFont, AutoSize = false, ForeColor = metaColor, BackColor = Color.Transparent };
_actions = new FlowLayoutPanel
{
@@ -116,6 +116,13 @@ namespace SotfModLoader
return (int)Math.Round(v * _scale);
}
public static void Fit(Label label, int width)
{
var size = TextRenderer.MeasureText(label.Text.Length > 0 ? label.Text : " ", label.Font,
new Size(width, int.MaxValue), TextFormatFlags.WordBreak | TextFormatFlags.NoPadding);
label.Size = new Size(width, size.Height + 2);
}
public void Arrange(int width)
{
Width = width;
@@ -124,11 +131,11 @@ namespace SotfModLoader
var textWidth = Math.Max(S(160), width - actionsWidth - S(24));
_title.Location = new Point(0, S(14));
_desc.MaximumSize = new Size(textWidth, 0);
Fit(_desc, textWidth);
_desc.Location = new Point(0, _title.Bottom + S(2));
_desc.Visible = _desc.Text.Length > 0;
var y = _desc.Visible ? _desc.Bottom : _title.Bottom;
_meta.MaximumSize = new Size(textWidth, 0);
Fit(_meta, textWidth);
_meta.Location = new Point(0, y + S(6));
var bottom = Math.Max(_meta.Bottom, _actions.Controls.Count > 0 ? _actions.Bottom : 0);
+2 -1
View File
@@ -6,7 +6,7 @@
<LangVersion>7.3</LangVersion>
<AssemblyName>SotfModLoader</AssemblyName>
<RootNamespace>SotfModLoader</RootNamespace>
<Version>1.0.0</Version>
<Version>1.1.0</Version>
<Product>Sons of the Forest Mod Loader</Product>
<Company>JustinOros</Company>
<Authors>JustinOros</Authors>
@@ -20,6 +20,7 @@
<ItemGroup>
<Reference Include="System.Drawing" />
<Reference Include="System.IO.Compression" />
<Reference Include="System.Net.Http" />
<Reference Include="System.Web.Extensions" />
<Reference Include="System.Windows.Forms" />
+44 -1
View File
@@ -69,6 +69,16 @@ def total_downloads(full_name):
return total
def install_command(repo):
branch = repo.get("default_branch") or "main"
url = f"https://raw.githubusercontent.com/{repo['full_name']}/{branch}/Install.ps1"
try:
request(url, accept="text/plain")
except (urllib.error.URLError, OSError):
return None
return f"irm {url} | iex"
def zip_asset(release):
for asset in release.get("assets", []):
if asset["name"].lower().endswith(".zip"):
@@ -156,7 +166,8 @@ def build_mod(repo):
shutil.rmtree(OUT.joinpath(*base.parts), ignore_errors=True)
return None
downloads = total_downloads(repo["full_name"])
print(f"mod {mod_id} {version} ({len(files)} files, {downloads} downloads)")
command = install_command(repo)
print(f"mod {mod_id} {version} ({len(files)} files, {downloads} downloads, installer {'yes' if command else 'no'})")
return {
"id": mod_id,
"name": meta.get("name") or mod_id,
@@ -171,6 +182,7 @@ def build_mod(repo):
"downloadUrl": asset["browser_download_url"],
"published": release.get("published_at"),
"downloads": downloads,
"installCommand": command,
"base": f"{base}/",
"dll": dll,
"manifestPath": manifest_path,
@@ -206,6 +218,31 @@ def build_redloader():
}
def build_redloader_versions(limit=10):
try:
releases = api(f"/repos/{REDLOADER_REPO}/releases?per_page=30")
except (urllib.error.URLError, OSError) as e:
print(f"warning: could not list RedLoader releases: {e}")
return []
versions = []
for release in releases:
if release.get("draft") or release.get("prerelease"):
continue
asset = zip_asset(release)
if not asset:
continue
versions.append({
"version": release["tag_name"],
"downloadUrl": asset["browser_download_url"],
"releaseUrl": release["html_url"],
"size": asset.get("size", 0),
})
if len(versions) >= limit:
break
print(f"RedLoader versions: {', '.join(v['version'] for v in versions)}")
return versions
def build_app():
try:
releases = api(f"/repos/{APP_REPO}/releases?per_page=30")
@@ -254,6 +291,11 @@ def main():
if OUT.exists():
shutil.rmtree(OUT)
shutil.copytree(SITE_SRC, OUT)
stamp = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
index = OUT / "index.html"
html = index.read_text(encoding="utf-8")
html = html.replace('src="app.js"', f'src="app.js?v={stamp}"').replace('href="style.css"', f'href="style.css?v={stamp}"')
index.write_text(html, encoding="utf-8")
mods = []
for repo in sorted(list_repos(), key=lambda r: r["name"].lower()):
topics = repo.get("topics") or []
@@ -268,6 +310,7 @@ def main():
"app": build_app(),
"bundle": build_bundle(redloader, mods),
"redloader": redloader,
"redloaderVersions": build_redloader_versions(),
"mods": mods,
}
(OUT / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
+91 -460
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,388 +22,53 @@ 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 renderGet() {
const node = $("get");
const { app, bundle } = state.manifest;
const cards = [];
if (app) {
cards.push(
el("div", { class: "get-card" },
el("h2", {}, "Windows app"),
el("p", {}, "Finds your game automatically, even inside Program Files, and installs RedLoader and mods with one click."),
el("a", { class: "button primary", href: app.url }, `Download the app (${formatSize(app.size)})`),
el("p", { class: "get-note" }, `Version ${app.version}. If Windows shows a warning, click More info, then Run anyway.`),
),
);
}
if (bundle) {
cards.push(
el("div", { class: "get-card" },
el("h2", {}, "Zip download"),
el("p", {}, "RedLoader and every mod in one zip. Extract it into your Sons of the Forest folder, the one that contains SonsOfTheForest.exe."),
el("a", { class: "button secondary", href: bundle.url, download: "sotf-mods.zip" }, `Download zip (${formatSize(bundle.size)})`),
),
);
}
node.hidden = !cards.length;
node.replaceChildren(...cards);
function link(href, text) {
return el("a", { href, target: "_blank", rel: "noopener" }, text);
}
function renderFolder() {
const node = $("folder");
node.replaceChildren();
node.dataset.state = state.folderError ? "error" : connected() ? "connected" : "idle";
function optionCard(number, title, text, action, note, recommended) {
return el("div", { class: recommended ? "get-card recommended" : "get-card" },
el("p", { class: "get-option" }, recommended ? `Option ${number} (Recommended)` : `Option ${number}`),
el("h3", {}, title),
el("p", {}, text),
action,
note ? el("p", { class: "get-note" }, note) : null,
);
}
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;
function renderGet() {
const node = $("get");
const { app, bundle, mods } = state.manifest;
const options = [];
if (app) {
options.push((n) => optionCard(n, "Windows app",
"Finds your game automatically. Installs, updates and removes RedLoader and mods with one click.",
el("a", { class: "button primary", href: app.url }, `Download app (${formatSize(app.size)})`),
`Version ${app.version}. If Windows shows a warning, click More info, then Run anyway.`,
true));
}
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 (bundle) {
options.push((n) => optionCard(n, "Zip download",
"RedLoader and every mod in one zip. Extract it into your game folder, the one with SonsOfTheForest.exe.",
el("a", { class: "button secondary", href: bundle.url, download: "sotf-mods.zip" }, `Download zip (${formatSize(bundle.size)})`),
"Download it again to update. Remove mods by deleting them from the Mods folder."));
}
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;
if ((mods || []).some((mod) => mod.installCommand)) {
options.push((n) => optionCard(n, "PowerShell scripts",
"Install one mod at a time. Copy a mod's command from the list below and paste it into PowerShell.",
el("a", { class: "button secondary", href: "#mods" }, "Go to the mod list"),
"Installs RedLoader too if needed. Run the command again to update."));
}
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"),
node.hidden = !options.length;
node.replaceChildren(
el("div", { class: "block-head" }, el("h2", { id: "get-title" }, "Choose how to install")),
el("div", { class: "get-grid" }, options.map((make, i) => make(i + 1))),
);
}
@@ -426,104 +77,94 @@ function renderRedloader() {
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. All three install options 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"));
}
async function copyText(text) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
const area = el("textarea", { readonly: true, style: "position:fixed;opacity:0" });
area.value = text;
document.body.append(area);
area.select();
let ok = false;
try {
ok = document.execCommand("copy");
} catch {}
area.remove();
return ok;
}
}
const COPY_ICON = '<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
function copyButton(command) {
const label = el("span", {}, "Copy PowerShell install");
const button = el("button", { type: "button", class: "copy", title: command }, label);
button.insertAdjacentHTML("afterbegin", COPY_ICON);
button.addEventListener("click", async () => {
const ok = await copyText(command);
label.textContent = ok ? "Copied" : "Copy failed";
setTimeout(() => {
label.textContent = "Copy PowerShell install";
}, 2000);
});
return button;
}
function modRow(mod) {
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"),
mod.installCommand ? copyButton(mod.installCommand) : null,
),
),
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", {}, "Free code signing provided by ", link("https://about.signpath.io", "SignPath.io"),
", certificate by ", link("https://signpath.org", "SignPath Foundation"), "."),
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 +174,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();
+2 -3
View File
@@ -15,10 +15,9 @@
<main class="page">
<header class="masthead">
<h1>Sons of the Forest mod loader</h1>
<p class="lede">Install, update and remove Sons of the Forest mods with one click.</p>
<p class="lede">A one-stop shop to install, update or remove my Sons of the Forest mods. Choose an option below.</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="get" class="block get" aria-labelledby="get-title" hidden></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>
+38 -120
View File
@@ -52,7 +52,7 @@ a:hover {
}
.page {
max-width: 54rem;
max-width: 62rem;
margin: 0 auto;
padding: clamp(2.5rem, 7vw, 5rem) clamp(1.25rem, 4vw, 2.5rem) 2rem;
}
@@ -74,32 +74,44 @@ a:hover {
font-size: 1.15rem;
}
.get {
.get-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
gap: 1rem;
margin-top: 2.75rem;
margin-top: 1.25rem;
}
.get-card {
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 1.35rem 1.5rem 1.4rem;
padding: 1.25rem 1.35rem 1.35rem;
background: var(--bark);
border: 1px solid var(--bark-line);
border-top: 5px solid var(--bark-line);
border-radius: 6px;
}
.get-card:first-child {
border-top: 5px solid var(--flare);
.get-card.recommended {
border-top-color: var(--flare);
}
.get-card h2 {
.get-card .get-option {
margin: 0;
color: var(--lichen);
font-size: 0.9rem;
}
.get-card.recommended .get-option {
color: var(--flare);
font-weight: 600;
}
.get-card h3 {
margin: 0.15rem 0 0;
font-family: var(--display);
font-weight: 700;
font-size: 1.75rem;
font-size: 1.65rem;
line-height: 1.1;
}
@@ -123,6 +135,7 @@ a.button {
font-size: 0.95rem;
line-height: 1.2;
text-decoration: none;
white-space: nowrap;
}
a.button.primary {
@@ -145,54 +158,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;
}
@@ -215,6 +180,22 @@ a.button.secondary:hover {
line-height: 1.1;
}
button.copy {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0;
border: 0;
background: none;
color: var(--flare);
font: inherit;
cursor: pointer;
}
button.copy:hover {
color: var(--fog);
}
.block-note {
margin: 1rem 0 0;
color: var(--lichen);
@@ -266,66 +247,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 +287,4 @@ button:disabled {
grid-template-columns: minmax(0, 1fr);
}
.actions {
justify-content: flex-start;
}
}