Add Windows app, zip download and SignPath signing
Build Windows app / app (push) Waiting to run

This commit is contained in:
Justin Oros
2026-09-28 14:24:42 -07:00
parent 079caa599d
commit a22512b506
18 changed files with 1494 additions and 18 deletions
+133
View File
@@ -0,0 +1,133 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.RegularExpressions;
using Microsoft.Win32;
namespace SotfModLoader
{
public static class GameFolder
{
public const string ClientExe = "SonsOfTheForest.exe";
public const string ServerExe = "SonsOfTheForestDS.exe";
private static string SettingsDir
{
get { return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "SotfModLoader"); }
}
private static string SettingsFile
{
get { return Path.Combine(SettingsDir, "gamedir.txt"); }
}
public static string Kind(string dir)
{
if (string.IsNullOrEmpty(dir))
return null;
if (File.Exists(Path.Combine(dir, ClientExe)))
return "client";
if (File.Exists(Path.Combine(dir, ServerExe)))
return "server";
return null;
}
public static string Load()
{
try
{
if (File.Exists(SettingsFile))
{
var saved = File.ReadAllText(SettingsFile).Trim();
if (Kind(saved) != null)
return saved;
}
}
catch
{
}
return Find();
}
public static void Save(string dir)
{
try
{
Directory.CreateDirectory(SettingsDir);
File.WriteAllText(SettingsFile, dir);
}
catch
{
}
}
public static string Find()
{
foreach (var library in SteamLibraries())
{
var candidate = Path.Combine(library, "steamapps", "common", "Sons Of The Forest");
if (Kind(candidate) != null)
return candidate;
}
return null;
}
private static List<string> SteamLibraries()
{
var roots = new List<string>();
AddRegistry(roots, Registry.CurrentUser, @"Software\Valve\Steam", "SteamPath");
AddRegistry(roots, Registry.LocalMachine, @"SOFTWARE\WOW6432Node\Valve\Steam", "InstallPath");
AddRegistry(roots, Registry.LocalMachine, @"SOFTWARE\Valve\Steam", "InstallPath");
var libraries = new List<string>();
foreach (var root in roots)
{
AddUnique(libraries, root);
var vdf = Path.Combine(root, "steamapps", "libraryfolders.vdf");
if (!File.Exists(vdf))
continue;
string text;
try
{
text = File.ReadAllText(vdf);
}
catch
{
continue;
}
foreach (Match match in Regex.Matches(text, "\"path\"\\s+\"(.+?)\""))
AddUnique(libraries, match.Groups[1].Value.Replace("\\\\", "\\"));
}
return libraries;
}
private static void AddRegistry(List<string> list, RegistryKey hive, string path, string name)
{
try
{
using (var key = hive.OpenSubKey(path))
{
var value = key == null ? null : key.GetValue(name) as string;
if (!string.IsNullOrEmpty(value))
list.Add(value.Replace('/', '\\'));
}
}
catch
{
}
}
private static void AddUnique(List<string> list, string path)
{
if (string.IsNullOrWhiteSpace(path))
return;
path = path.TrimEnd('\\');
foreach (var existing in list)
{
if (string.Equals(existing, path, StringComparison.OrdinalIgnoreCase))
return;
}
list.Add(path);
}
}
}
+597
View File
@@ -0,0 +1,597 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.Drawing;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Security.Principal;
using System.Threading.Tasks;
using System.Windows.Forms;
namespace SotfModLoader
{
public class MainForm : Form
{
private static readonly Color Pine = ColorTranslator.FromHtml("#131c17");
private static readonly Color Bark = ColorTranslator.FromHtml("#1d2821");
private static readonly Color BarkLine = ColorTranslator.FromHtml("#34463b");
private static readonly Color Lichen = ColorTranslator.FromHtml("#9aae97");
private static readonly Color Fog = ColorTranslator.FromHtml("#e2e7df");
private static readonly Color Flare = ColorTranslator.FromHtml("#e0a63a");
private static readonly Color FlareInk = ColorTranslator.FromHtml("#1b1305");
private static readonly Color Ember = ColorTranslator.FromHtml("#e38b7f");
private enum Style
{
Primary,
Secondary,
Danger
}
private readonly float _scale;
private readonly Font _titleFont;
private readonly Font _sectionFont;
private readonly Font _rowTitleFont;
private readonly Font _bodyFont;
private readonly Font _metaFont;
private readonly Font _buttonFont;
private readonly Panel _folderPanel;
private readonly Label _folderName;
private readonly Label _folderHelp;
private readonly Button _folderButton;
private readonly LinkLabel _updateLink;
private readonly FlowLayoutPanel _list;
private readonly Label _status;
private Manifest _manifest;
private string _gameDir;
private string _kind;
private bool _busy;
private ModStatus _redloaderStatus;
private readonly Dictionary<string, ModStatus> _statuses = new Dictionary<string, ModStatus>();
public MainForm()
{
using (var g = CreateGraphics())
_scale = g.DpiX / 96f;
Text = "Sons of the Forest Mod Loader";
BackColor = Pine;
ForeColor = Fog;
Font = new Font("Segoe UI", 10f);
AutoScaleMode = AutoScaleMode.None;
ClientSize = new Size(S(860), S(760));
MinimumSize = new Size(S(620), S(480));
StartPosition = FormStartPosition.CenterScreen;
try
{
Icon = Icon.ExtractAssociatedIcon(Application.ExecutablePath);
}
catch
{
}
_titleFont = new Font("Segoe UI Semibold", 22f);
_sectionFont = new Font("Segoe UI Semibold", 15f);
_rowTitleFont = new Font("Segoe UI Semibold", 12.5f);
_bodyFont = new Font("Segoe UI", 10f);
_metaFont = new Font("Segoe UI", 9f);
_buttonFont = new Font("Segoe UI Semibold", 9.5f);
var root = new TableLayoutPanel
{
Dock = DockStyle.Fill,
ColumnCount = 1,
RowCount = 5,
Padding = new Padding(S(28), S(20), S(28), S(16)),
BackColor = Pine
};
root.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100f));
root.RowStyles.Add(new RowStyle(SizeType.AutoSize));
root.RowStyles.Add(new RowStyle(SizeType.AutoSize));
root.RowStyles.Add(new RowStyle(SizeType.AutoSize));
root.RowStyles.Add(new RowStyle(SizeType.Percent, 100f));
root.RowStyles.Add(new RowStyle(SizeType.AutoSize));
var title = new Label
{
Text = "Sons of the Forest mod loader",
Font = _titleFont,
AutoSize = true,
ForeColor = Fog,
Margin = new Padding(0, 0, 0, S(6))
};
_updateLink = new LinkLabel
{
AutoSize = true,
Visible = false,
LinkColor = Flare,
ActiveLinkColor = Fog,
Font = _bodyFont,
Margin = new Padding(0, 0, 0, S(8))
};
_folderPanel = new Panel
{
Dock = DockStyle.Top,
BackColor = Bark,
Height = S(96),
Margin = new Padding(0, S(6), 0, S(4)),
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 };
_folderButton = MakeButton("Choose folder", Style.Secondary, (s, e) => ChooseFolder());
_folderPanel.Controls.Add(_folderName);
_folderPanel.Controls.Add(_folderHelp);
_folderPanel.Controls.Add(_folderButton);
_folderPanel.Resize += (s, e) => ArrangeFolderPanel();
_list = new FlowLayoutPanel
{
Dock = DockStyle.Fill,
FlowDirection = FlowDirection.TopDown,
WrapContents = false,
AutoScroll = true,
BackColor = Pine,
Margin = new Padding(0, S(4), 0, 0)
};
_list.Resize += (s, e) => ArrangeList();
_status = new Label
{
AutoSize = true,
Font = _bodyFont,
ForeColor = Lichen,
Margin = new Padding(0, S(10), 0, 0),
Text = "Loading the mod list..."
};
root.Controls.Add(title, 0, 0);
root.Controls.Add(_updateLink, 0, 1);
root.Controls.Add(_folderPanel, 0, 2);
root.Controls.Add(_list, 0, 3);
root.Controls.Add(_status, 0, 4);
Controls.Add(root);
}
private int S(float v)
{
return (int)Math.Round(v * _scale);
}
public static void OpenUrl(string url)
{
try
{
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
}
catch
{
}
}
protected override async void OnShown(EventArgs e)
{
base.OnShown(e);
try
{
_manifest = await ModInstaller.LoadManifest();
}
catch
{
SetStatus("The mod list couldn't be loaded. Check your internet connection and reopen the mod loader.", true);
return;
}
ShowUpdateNotice();
var dir = GameFolder.Load();
if (dir != null)
SetGameDir(dir);
SetStatus(_gameDir == null ? string.Empty : "Ready.", false);
Render();
}
private void ShowUpdateNotice()
{
var app = _manifest.app;
Version latest;
if (app == null || string.IsNullOrEmpty(app.url) || !Version.TryParse(app.version, out latest))
return;
var current = ModInstaller.AppVersion;
if (latest <= new Version(current.Major, current.Minor, Math.Max(current.Build, 0)))
return;
_updateLink.Text = "Version " + app.version + " of the mod loader is available. Download it";
_updateLink.LinkClicked += (s, e) => OpenUrl(app.url);
_updateLink.Visible = true;
}
private void SetGameDir(string dir)
{
_gameDir = dir;
_kind = GameFolder.Kind(dir);
GameFolder.Save(dir);
RefreshStatuses();
}
private void RefreshStatuses()
{
_statuses.Clear();
_redloaderStatus = null;
if (_manifest == null || _gameDir == null)
return;
if (_manifest.redloader != null)
_redloaderStatus = ModInstaller.RedLoaderStatus(_gameDir, _manifest.redloader);
foreach (var mod in _manifest.mods ?? new List<Package>())
_statuses[mod.id] = ModInstaller.Status(_gameDir, mod);
}
private void SetStatus(string text, bool error)
{
_status.Text = text;
_status.ForeColor = error ? Ember : Lichen;
}
private Button MakeButton(string text, Style style, EventHandler click)
{
var button = new Button
{
Text = text,
AutoSize = true,
AutoSizeMode = AutoSizeMode.GrowAndShrink,
FlatStyle = FlatStyle.Flat,
Font = _buttonFont,
Cursor = Cursors.Hand,
Padding = new Padding(S(10), S(3), S(10), S(3)),
Margin = new Padding(S(6), 0, 0, 0),
UseVisualStyleBackColor = false
};
button.FlatAppearance.BorderSize = 1;
switch (style)
{
case Style.Primary:
button.BackColor = Flare;
button.ForeColor = FlareInk;
button.FlatAppearance.BorderColor = Flare;
break;
case Style.Secondary:
button.BackColor = Pine;
button.ForeColor = Fog;
button.FlatAppearance.BorderColor = BarkLine;
break;
default:
button.BackColor = Pine;
button.ForeColor = Ember;
button.FlatAppearance.BorderColor = Pine;
break;
}
button.Click += click;
return button;
}
private void PaintFolderPanel(object sender, PaintEventArgs e)
{
var color = _gameDir != null ? Flare : BarkLine;
using (var brush = new SolidBrush(color))
e.Graphics.FillRectangle(brush, 0, 0, S(5), _folderPanel.Height);
}
private void RenderFolder()
{
if (_gameDir != null)
{
_folderName.Text = _gameDir;
_folderHelp.Text = _kind == "server" ? "Dedicated server folder" : "Game folder, found automatically or chosen by you";
_folderButton.Text = "Change folder";
}
else
{
_folderName.Text = "Game folder not found";
_folderHelp.Text = "Click Choose folder and pick the folder that contains SonsOfTheForest.exe.";
_folderButton.Text = "Choose folder";
}
_folderButton.Enabled = !_busy;
ArrangeFolderPanel();
_folderPanel.Invalidate();
}
private void ArrangeFolderPanel()
{
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);
_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);
}
private void Render()
{
RenderFolder();
_list.SuspendLayout();
foreach (Control control in _list.Controls.Cast<Control>().ToList())
{
_list.Controls.Remove(control);
control.Dispose();
}
if (_manifest != null)
{
if (_manifest.redloader != null)
{
_list.Controls.Add(new HeaderPanel("Mod framework", null, _sectionFont, Fog, _scale));
_list.Controls.Add(RedLoaderRow());
}
var mods = VisibleMods();
var outdated = mods.Where(m =>
{
ModStatus status;
return _statuses.TryGetValue(m.id, out status) && status.Installed && !status.Current;
}).ToList();
Control updateAll = null;
if (_gameDir != null && outdated.Count > 1)
updateAll = MakeButton("Update all (" + outdated.Count + ")", Style.Primary, (s, e) => UpdateAll(outdated));
_list.Controls.Add(new HeaderPanel("Mods", updateAll, _sectionFont, Fog, _scale));
foreach (var mod in mods)
_list.Controls.Add(ModRow(mod));
}
_list.ResumeLayout();
ArrangeList();
}
private void ArrangeList()
{
var width = _list.ClientSize.Width - SystemInformation.VerticalScrollBarWidth - S(4);
if (width <= 0)
return;
_list.SuspendLayout();
foreach (Control control in _list.Controls)
{
var arrangeable = control as IArrangeable;
if (arrangeable != null)
arrangeable.Arrange(width);
}
_list.ResumeLayout();
}
private List<Package> VisibleMods()
{
var mods = (_manifest.mods ?? new List<Package>()).OrderBy(m => m.name, StringComparer.OrdinalIgnoreCase).ToList();
if (_kind == null)
return mods;
return mods.Where(m =>
{
var platform = (m.platform ?? "Client").ToLowerInvariant();
return platform == "universal" || platform == _kind;
}).ToList();
}
private string VersionText(Package pkg, ModStatus status)
{
var parts = new List<string> { "Latest " + pkg.version };
if (status != null)
{
if (!status.Installed)
parts.Add("Not installed");
else if (status.Current)
parts.Add("Installed, up to date");
else if (status.Version != null)
parts.Add("Installed " + status.Version + ", update available");
else
parts.Add("Installed, version unknown");
}
if (pkg.downloads.HasValue)
parts.Add(pkg.downloads.Value.ToString("N0") + " downloads");
return string.Join(" ", parts);
}
private Control RedLoaderRow()
{
var rl = _manifest.redloader;
var status = _gameDir != null ? _redloaderStatus : null;
var buttons = new List<Control>();
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()));
}
foreach (var b in buttons)
b.Enabled = !_busy;
var metaColor = status != null && status.Installed && !status.Current ? Flare : Lichen;
return new RowPanel("RedLoader", rl.repo,
"Every mod here runs on RedLoader. Install it before adding mods.",
VersionText(rl, status), metaColor, buttons,
_rowTitleFont, _bodyFont, _metaFont, Fog, Lichen, BarkLine, _scale);
}
private Control ModRow(Package mod)
{
ModStatus status = null;
if (_gameDir != null)
_statuses.TryGetValue(mod.id, out status);
var buttons = new List<Control>();
if (status != null)
{
if (!status.Installed)
{
buttons.Add(MakeButton("Install", Style.Primary, (s, e) => InstallMod(mod)));
}
else
{
if (!status.Current)
buttons.Add(MakeButton(status.Version != null ? "Update" : "Reinstall", Style.Primary, (s, e) => InstallMod(mod)));
buttons.Add(MakeButton("Remove", Style.Danger, (s, e) => RemoveMod(mod)));
}
}
foreach (var b in buttons)
b.Enabled = !_busy;
var metaColor = status != null && status.Installed && !status.Current ? Flare : Lichen;
return new RowPanel(mod.name, mod.repo, mod.description, VersionText(mod, status), metaColor, buttons,
_rowTitleFont, _bodyFont, _metaFont, Fog, Lichen, BarkLine, _scale);
}
private void ChooseFolder()
{
using (var dialog = new FolderBrowserDialog())
{
dialog.Description = "Select your Sons Of The Forest folder, the one that contains SonsOfTheForest.exe";
dialog.ShowNewFolderButton = false;
if (_gameDir != null)
dialog.SelectedPath = _gameDir;
if (dialog.ShowDialog(this) != DialogResult.OK)
return;
if (GameFolder.Kind(dialog.SelectedPath) == null)
{
SetStatus(dialog.SelectedPath + " doesn't contain SonsOfTheForest.exe. Choose the folder the game is installed in.", true);
return;
}
SetGameDir(dialog.SelectedPath);
SetStatus("Using " + _gameDir + ".", false);
Render();
}
}
private async Task Run(Func<IProgress<string>, Task<string>> task)
{
await Task.Yield();
if (_busy || _gameDir == null)
return;
if (ModInstaller.IsGameRunning())
{
SetStatus("Close Sons of the Forest first, then try again.", true);
return;
}
_busy = true;
Render();
var progress = new Progress<string>(message => SetStatus(message, false));
var needsAdmin = false;
try
{
var message = await task(progress);
SetStatus(message, false);
}
catch (UnauthorizedAccessException)
{
needsAdmin = true;
}
catch (Exception e)
{
SetStatus(Explain(e), true);
}
_busy = false;
RefreshStatuses();
Render();
if (needsAdmin)
OfferAdmin();
}
private static string Explain(Exception e)
{
if (e is HttpRequestException || e is TaskCanceledException)
return "A download failed. Check your internet connection and try again.";
if (e is IOException)
return "A file is in use. Close Sons of the Forest and try again.";
return e.Message;
}
private static bool IsElevated()
{
try
{
return new WindowsPrincipal(WindowsIdentity.GetCurrent()).IsInRole(WindowsBuiltInRole.Administrator);
}
catch
{
return false;
}
}
private void OfferAdmin()
{
if (IsElevated())
{
SetStatus("Windows blocked writing to the game folder, even as administrator.", true);
return;
}
SetStatus("Windows blocked writing to the game folder.", true);
var answer = MessageBox.Show(this,
"Windows blocked writing to the game folder.\n\nRestart the mod loader as administrator and try again?",
"Permission needed", MessageBoxButtons.YesNo, MessageBoxIcon.Warning);
if (answer != DialogResult.Yes)
return;
try
{
Process.Start(new ProcessStartInfo(Application.ExecutablePath) { UseShellExecute = true, Verb = "runas" });
Close();
}
catch (Win32Exception)
{
}
}
private async void InstallRedLoader()
{
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.";
});
}
private async void InstallMod(Package mod)
{
ModStatus before;
_statuses.TryGetValue(mod.id, out before);
await Run(async progress =>
{
await ModInstaller.Install(_gameDir, mod, progress);
ModInstaller.CountDownload(mod);
return before != null && before.Installed
? mod.name + " updated to " + mod.version + "."
: mod.name + " " + mod.version + " installed.";
});
}
private async void RemoveMod(Package mod)
{
var answer = MessageBox.Show(this, "Remove " + mod.name + " from your game?", "Remove mod",
MessageBoxButtons.YesNo, MessageBoxIcon.Question);
if (answer != DialogResult.Yes)
return;
await Run(progress =>
{
ModInstaller.Uninstall(_gameDir, mod);
return Task.FromResult(mod.name + " removed.");
});
}
private async void UpdateAll(List<Package> mods)
{
await Run(async progress =>
{
foreach (var mod in mods)
{
await ModInstaller.Install(_gameDir, mod, progress);
ModInstaller.CountDownload(mod);
}
return "Updated " + mods.Count + " mod" + (mods.Count == 1 ? "" : "s") + ".";
});
}
}
}
+202
View File
@@ -0,0 +1,202 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using System.Web.Script.Serialization;
namespace SotfModLoader
{
public static class ModInstaller
{
public const string SiteUrl = "https://justinoros.github.io/sotf-modloader/";
public const string StateFile = "sotf-modloader.json";
private const int Concurrency = 6;
private static readonly HttpClient Http = CreateClient();
public static Version AppVersion
{
get { return Assembly.GetExecutingAssembly().GetName().Version; }
}
private static HttpClient CreateClient()
{
var client = new HttpClient();
client.Timeout = TimeSpan.FromMinutes(10);
client.DefaultRequestHeaders.UserAgent.ParseAdd("SotfModLoader/" + AppVersion.ToString(3));
return client;
}
private static JavaScriptSerializer Json()
{
return new JavaScriptSerializer { MaxJsonLength = int.MaxValue };
}
public static async Task<Manifest> LoadManifest()
{
var text = await Http.GetStringAsync(SiteUrl + "manifest.json?t=" + DateTime.UtcNow.Ticks);
return Json().Deserialize<Manifest>(text);
}
public static bool IsGameRunning()
{
return Process.GetProcessesByName("SonsOfTheForest").Length > 0 ||
Process.GetProcessesByName("SonsOfTheForestDS").Length > 0;
}
public static string FullPath(string gameDir, string relative)
{
var root = Path.GetFullPath(gameDir).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
var full = Path.GetFullPath(Path.Combine(root, relative.Replace('/', Path.DirectorySeparatorChar)));
if (!full.StartsWith(root, StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("The mod list contains an unsafe path: " + relative);
return full;
}
public static ModStatus Status(string gameDir, Package mod)
{
if (string.IsNullOrEmpty(mod.dll) || !File.Exists(FullPath(gameDir, mod.dll)))
return new ModStatus();
string version = null;
if (!string.IsNullOrEmpty(mod.manifestPath))
version = ReadValue(FullPath(gameDir, mod.manifestPath), "version");
return new ModStatus { Installed = true, Version = version, Current = version == mod.version };
}
public static ModStatus RedLoaderStatus(string gameDir, Package redloader)
{
var installed = File.Exists(Path.Combine(gameDir, "version.dll")) &&
Directory.Exists(Path.Combine(gameDir, "_Redloader"));
if (!installed)
return new ModStatus();
var version = ReadValue(Path.Combine(gameDir, StateFile), "redloader");
return new ModStatus { Installed = true, Version = version, Current = version == redloader.version };
}
private static string ReadValue(string file, string key)
{
try
{
if (!File.Exists(file))
return null;
var data = Json().DeserializeObject(File.ReadAllText(file)) as Dictionary<string, object>;
object value;
if (data != null && data.TryGetValue(key, out value) && value != null)
return value.ToString();
}
catch
{
}
return null;
}
public static void WriteRedLoaderVersion(string gameDir, string version)
{
var file = Path.Combine(gameDir, StateFile);
Dictionary<string, object> data = null;
try
{
if (File.Exists(file))
data = Json().DeserializeObject(File.ReadAllText(file)) as Dictionary<string, object>;
}
catch
{
}
if (data == null)
data = new Dictionary<string, object>();
data["redloader"] = version;
File.WriteAllText(file, Json().Serialize(data));
}
private static string FileUrl(Package pkg, string path)
{
return SiteUrl + pkg.@base + string.Join("/", path.Split('/').Select(Uri.EscapeDataString));
}
public static async Task Install(string gameDir, Package pkg, IProgress<string> progress)
{
var label = pkg.name + " " + pkg.version;
var total = pkg.files.Count;
var data = new byte[total][];
var done = 0;
using (var gate = new SemaphoreSlim(Concurrency))
{
var tasks = new List<Task>();
for (var i = 0; i < total; i++)
{
var index = i;
tasks.Add(Task.Run(async () =>
{
await gate.WaitAsync().ConfigureAwait(false);
try
{
data[index] = await Http.GetByteArrayAsync(FileUrl(pkg, pkg.files[index].path)).ConfigureAwait(false);
var count = Interlocked.Increment(ref done);
progress.Report(label + ": downloading " + count + " of " + total + " files");
}
finally
{
gate.Release();
}
}));
}
await Task.WhenAll(tasks);
}
if (pkg.roots != null)
{
foreach (var root in pkg.roots)
Remove(gameDir, root);
}
for (var i = 0; i < total; i++)
{
progress.Report(label + ": writing " + (i + 1) + " of " + total + " files");
var target = FullPath(gameDir, pkg.files[i].path);
Directory.CreateDirectory(Path.GetDirectoryName(target));
File.WriteAllBytes(target, data[i]);
}
}
public static void Uninstall(string gameDir, Package pkg)
{
if (pkg.roots == null)
return;
foreach (var root in pkg.roots)
Remove(gameDir, root);
}
private static void Remove(string gameDir, string relative)
{
var full = FullPath(gameDir, relative);
if (Directory.Exists(full))
Directory.Delete(full, true);
else if (File.Exists(full))
File.Delete(full);
}
public static void CountDownload(Package pkg)
{
if (string.IsNullOrEmpty(pkg.downloadUrl))
return;
Task.Run(async () =>
{
try
{
using (await Http.GetAsync(pkg.downloadUrl, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false))
{
}
}
catch
{
}
});
}
}
}
+50
View File
@@ -0,0 +1,50 @@
using System.Collections.Generic;
namespace SotfModLoader
{
public class PackageFile
{
public string path { get; set; }
public long size { get; set; }
}
public class Package
{
public string id { get; set; }
public string name { get; set; }
public string description { get; set; }
public string version { get; set; }
public string platform { get; set; }
public string repo { get; set; }
public string releaseUrl { get; set; }
public string downloadUrl { get; set; }
public string @base { get; set; }
public string dll { get; set; }
public string manifestPath { get; set; }
public long? downloads { get; set; }
public List<string> roots { get; set; }
public List<PackageFile> files { get; set; }
}
public class AppInfo
{
public string version { get; set; }
public string url { get; set; }
}
public class Manifest
{
public string generated { get; set; }
public string owner { get; set; }
public Package redloader { get; set; }
public List<Package> mods { get; set; }
public AppInfo app { get; set; }
}
public class ModStatus
{
public bool Installed { get; set; }
public string Version { get; set; }
public bool Current { get; set; }
}
}
+18
View File
@@ -0,0 +1,18 @@
using System;
using System.Net;
using System.Windows.Forms;
namespace SotfModLoader
{
internal static class Program
{
[STAThread]
private static void Main()
{
ServicePointManager.SecurityProtocol |= SecurityProtocolType.Tls12;
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.Run(new MainForm());
}
}
}
+146
View File
@@ -0,0 +1,146 @@
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Windows.Forms;
namespace SotfModLoader
{
public interface IArrangeable
{
void Arrange(int width);
}
public class HeaderPanel : Panel, IArrangeable
{
private readonly Label _title;
private readonly Control _action;
private readonly float _scale;
private readonly Color _line;
public HeaderPanel(string title, Control action, Font font, Color line, float scale)
{
_scale = scale;
_line = line;
_action = action;
DoubleBuffered = true;
Margin = Padding.Empty;
_title = new Label { Text = title, Font = font, AutoSize = true, BackColor = Color.Transparent };
Controls.Add(_title);
if (action != null)
Controls.Add(action);
}
private int S(float v)
{
return (int)Math.Round(v * _scale);
}
public void Arrange(int width)
{
Width = width;
_title.Location = new Point(0, S(22));
var bottom = _title.Bottom;
if (_action != null)
{
_action.Location = new Point(width - _action.Width, S(18));
bottom = Math.Max(bottom, _action.Bottom);
}
Height = bottom + S(10);
Invalidate();
}
protected override void OnPaint(PaintEventArgs e)
{
base.OnPaint(e);
using (var pen = new Pen(_line, Math.Max(1, S(2))))
e.Graphics.DrawLine(pen, 0, Height - S(1), Width, Height - S(1));
}
}
public class RowPanel : Panel, IArrangeable
{
private readonly LinkLabel _title;
private readonly Label _desc;
private readonly Label _meta;
private readonly FlowLayoutPanel _actions;
private readonly float _scale;
private readonly Color _line;
public RowPanel(string title, string url, string description, string meta, Color metaColor,
IEnumerable<Control> buttons, Font titleFont, Font bodyFont, Font metaFont,
Color textColor, Color mutedColor, Color line, float scale)
{
_scale = scale;
_line = line;
DoubleBuffered = true;
Margin = Padding.Empty;
_title = new LinkLabel
{
Text = title,
Font = titleFont,
AutoSize = true,
LinkColor = textColor,
ActiveLinkColor = textColor,
VisitedLinkColor = textColor,
LinkBehavior = LinkBehavior.HoverUnderline,
BackColor = Color.Transparent
};
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 };
_actions = new FlowLayoutPanel
{
AutoSize = true,
AutoSizeMode = AutoSizeMode.GrowAndShrink,
FlowDirection = FlowDirection.LeftToRight,
WrapContents = false,
BackColor = Color.Transparent,
Margin = Padding.Empty,
Padding = Padding.Empty
};
foreach (var button in buttons)
_actions.Controls.Add(button);
Controls.Add(_title);
Controls.Add(_desc);
Controls.Add(_meta);
Controls.Add(_actions);
}
private int S(float v)
{
return (int)Math.Round(v * _scale);
}
public void Arrange(int width)
{
Width = width;
var actionsWidth = _actions.Controls.Count > 0 ? _actions.PreferredSize.Width : 0;
_actions.Location = new Point(width - actionsWidth, S(16));
var textWidth = Math.Max(S(160), width - actionsWidth - S(24));
_title.Location = new Point(0, S(14));
_desc.MaximumSize = new Size(textWidth, 0);
_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);
_meta.Location = new Point(0, y + S(6));
var bottom = Math.Max(_meta.Bottom, _actions.Controls.Count > 0 ? _actions.Bottom : 0);
Height = bottom + S(14);
Invalidate();
}
protected override void OnPaint(PaintEventArgs e)
{
base.OnPaint(e);
using (var pen = new Pen(_line))
e.Graphics.DrawLine(pen, 0, Height - 1, Width, Height - 1);
}
}
}
+32
View File
@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net48</TargetFramework>
<LangVersion>7.3</LangVersion>
<AssemblyName>SotfModLoader</AssemblyName>
<RootNamespace>SotfModLoader</RootNamespace>
<Version>1.0.0</Version>
<Product>Sons of the Forest Mod Loader</Product>
<Company>JustinOros</Company>
<Authors>JustinOros</Authors>
<Description>Install, update and remove Sons of the Forest mods</Description>
<Copyright>Copyright (c) 2026 Justin Oros</Copyright>
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>icon.ico</ApplicationIcon>
<DebugType>none</DebugType>
<GenerateResourceUsePreserializedResources>false</GenerateResourceUsePreserializedResources>
</PropertyGroup>
<ItemGroup>
<Reference Include="System.Drawing" />
<Reference Include="System.Net.Http" />
<Reference Include="System.Web.Extensions" />
<Reference Include="System.Windows.Forms" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.3" PrivateAssets="all" />
</ItemGroup>
</Project>
+21
View File
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="SotfModLoader.app" />
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware>
</windowsSettings>
</application>
</assembly>
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB