Files
sotf-modloader/app/MainForm.cs
T
2026-09-28 14:24:42 -07:00

598 lines
22 KiB
C#

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") + ".";
});
}
}
}