Compare commits
10
Commits
app-v1.0.0
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5be9ff4862 | ||
|
|
c1bbfa06d5 | ||
|
|
375a2a54fc | ||
|
|
8057ba9a09 | ||
|
|
952895f359 | ||
|
|
c3726f7af5 | ||
|
|
967bd58f00 | ||
|
|
9bac1f221e | ||
|
|
b5b1a48153 | ||
|
|
d076d584dc |
@@ -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
@@ -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.";
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user