This commit is contained in:
1 parent
079caa599d
commit
a22512b506
18 files changed
+1494
-18
No files matched your search
@@ -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
@@ -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") + ".";
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
Reference in new issue
Block a user