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 _statuses = new Dictionary(); 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()) _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().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 VisibleMods() { var mods = (_manifest.mods ?? new List()).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 { "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(); 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(); 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, Task> 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(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 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") + "."; }); } } }