diff --git a/app/MainForm.cs b/app/MainForm.cs index 42d841e..aaba198 100644 --- a/app/MainForm.cs +++ b/app/MainForm.cs @@ -51,6 +51,7 @@ namespace SotfModLoader private string _kind; private bool _busy; private ModStatus _redloaderStatus; + private string _selectedRedLoader; private readonly Dictionary _statuses = new Dictionary(); public MainForm() @@ -124,8 +125,8 @@ namespace SotfModLoader Padding = new Padding(S(18), S(12), S(14), S(12)) }; _folderPanel.Paint += PaintFolderPanel; - _folderName = new Label { AutoSize = true, Font = _rowTitleFont, ForeColor = Fog, BackColor = Bark }; - _folderHelp = new Label { AutoSize = true, Font = _metaFont, ForeColor = Lichen, BackColor = Bark }; + _folderName = new Label { AutoSize = false, Font = _rowTitleFont, ForeColor = Fog, BackColor = Bark }; + _folderHelp = new Label { AutoSize = false, Font = _metaFont, ForeColor = Lichen, BackColor = Bark }; _folderButton = MakeButton("Choose folder", Style.Secondary, (s, e) => ChooseFolder()); _folderPanel.Controls.Add(_folderName); _folderPanel.Controls.Add(_folderHelp); @@ -305,8 +306,8 @@ namespace SotfModLoader var width = _folderPanel.ClientSize.Width; _folderButton.Location = new Point(width - _folderButton.Width - S(14), S(14)); var textWidth = Math.Max(S(160), _folderButton.Left - S(40)); - _folderName.MaximumSize = new Size(textWidth, 0); - _folderHelp.MaximumSize = new Size(textWidth, 0); + RowPanel.Fit(_folderName, textWidth); + RowPanel.Fit(_folderHelp, textWidth); _folderName.Location = new Point(S(20), S(12)); _folderHelp.Location = new Point(S(20), _folderName.Bottom + S(4)); _folderPanel.Height = Math.Max(_folderHelp.Bottom, _folderButton.Bottom) + S(14); @@ -394,25 +395,90 @@ namespace SotfModLoader return string.Join(" ", parts); } + private List RedLoaderVersions() + { + var versions = _manifest.redloaderVersions; + if (versions != null && versions.Count > 0) + return versions; + var rl = _manifest.redloader; + return new List { new RedLoaderVersion { version = rl.version, releaseUrl = rl.releaseUrl } }; + } + + private int SelectedRedLoaderIndex(List versions) + { + if (_selectedRedLoader == null) + return 0; + var index = versions.FindIndex(v => v.version == _selectedRedLoader); + return index < 0 ? 0 : index; + } + private Control RedLoaderRow() { var rl = _manifest.redloader; + var versions = RedLoaderVersions(); + var selectedIndex = SelectedRedLoaderIndex(versions); + var selected = versions[selectedIndex]; + var latest = versions[0]; var status = _gameDir != null ? _redloaderStatus : null; - var buttons = new List(); + + var controls = new List(); + if (status != null) + { + if (versions.Count > 1) + { + var combo = new ComboBox + { + DropDownStyle = ComboBoxStyle.DropDownList, + FlatStyle = FlatStyle.Flat, + BackColor = Bark, + ForeColor = Fog, + Font = _buttonFont, + Width = S(150), + Margin = new Padding(S(6), S(2), 0, 0), + Enabled = !_busy + }; + for (var i = 0; i < versions.Count; i++) + combo.Items.Add(i == 0 ? "Latest (" + versions[i].version + ")" : versions[i].version); + combo.SelectedIndex = selectedIndex; + combo.SelectedIndexChanged += (s, e) => + { + _selectedRedLoader = combo.SelectedIndex <= 0 ? null : versions[combo.SelectedIndex].version; + BeginInvoke((Action)Render); + }; + controls.Add(combo); + } + + Button button = null; + if (!status.Installed) + button = MakeButton("Install RedLoader", Style.Primary, (s, e) => InstallRedLoader(selected)); + else if (status.Version == selected.version) + button = MakeButton("Reinstall", Style.Secondary, (s, e) => InstallRedLoader(selected)); + else if (status.Version == null) + button = MakeButton("Install " + selected.version, Style.Secondary, (s, e) => InstallRedLoader(selected)); + else if (selectedIndex == 0) + button = MakeButton("Update", Style.Primary, (s, e) => InstallRedLoader(selected)); + else + button = MakeButton("Install " + selected.version, Style.Primary, (s, e) => InstallRedLoader(selected)); + button.Enabled = !_busy; + controls.Add(button); + } + + var parts = new List { "Latest " + latest.version }; if (status != null) { if (!status.Installed) - buttons.Add(MakeButton("Install RedLoader", Style.Primary, (s, e) => InstallRedLoader())); - else if (!status.Current) - buttons.Add(MakeButton(status.Version != null ? "Update" : "Reinstall " + rl.version, - status.Version != null ? Style.Primary : Style.Secondary, (s, e) => InstallRedLoader())); + parts.Add("Not installed"); + else if (status.Version == null) + parts.Add("Installed, version unknown"); + else if (status.Version == latest.version) + parts.Add("Installed, up to date"); + else + parts.Add("Installed " + status.Version); } - foreach (var b in buttons) - b.Enabled = !_busy; - var metaColor = status != null && status.Installed && !status.Current ? Flare : Lichen; + var metaColor = status != null && status.Installed && status.Version != latest.version ? Flare : Lichen; return new RowPanel("RedLoader", rl.repo, - "Every mod here runs on RedLoader. Install it before adding mods.", - VersionText(rl, status), metaColor, buttons, + "Every mod here runs on RedLoader. Install it before adding mods. Pick an older version only if a mod needs one.", + string.Join(" ", parts), metaColor, controls, _rowTitleFont, _bodyFont, _metaFont, Fog, Lichen, BarkLine, _scale); } @@ -543,14 +609,17 @@ namespace SotfModLoader } } - private async void InstallRedLoader() + private async void InstallRedLoader(RedLoaderVersion version) { var rl = _manifest.redloader; await Run(async progress => { - await ModInstaller.Install(_gameDir, rl, progress); - ModInstaller.WriteRedLoaderVersion(_gameDir, rl.version); - return "RedLoader " + rl.version + " installed. Launch the game once and wait for the main menu so it can finish setting up."; + if (!string.IsNullOrEmpty(version.downloadUrl)) + await ModInstaller.InstallZip(_gameDir, version.downloadUrl, "RedLoader " + version.version, progress); + else + await ModInstaller.Install(_gameDir, rl, progress); + ModInstaller.WriteRedLoaderVersion(_gameDir, version.version); + return "RedLoader " + version.version + " installed. Launch the game once and wait for the main menu so it can finish setting up."; }); } diff --git a/app/ModInstaller.cs b/app/ModInstaller.cs index 986cf25..5b5a633 100644 --- a/app/ModInstaller.cs +++ b/app/ModInstaller.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; +using System.IO.Compression; using System.Linq; using System.Net.Http; using System.Reflection; @@ -164,6 +165,56 @@ namespace SotfModLoader } } + public static async Task InstallZip(string gameDir, string url, string label, IProgress progress) + { + byte[] data; + using (var response = await Http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead)) + { + response.EnsureSuccessStatusCode(); + var total = response.Content.Headers.ContentLength ?? 0; + using (var stream = await response.Content.ReadAsStreamAsync()) + using (var memory = new MemoryStream()) + { + var buffer = new byte[81920]; + long read = 0; + var lastPercent = -1; + int count; + while ((count = await stream.ReadAsync(buffer, 0, buffer.Length)) > 0) + { + memory.Write(buffer, 0, count); + read += count; + if (total > 0) + { + var percent = (int)(read * 100 / total); + if (percent != lastPercent) + { + lastPercent = percent; + progress.Report(label + ": downloading " + percent + "%"); + } + } + } + data = memory.ToArray(); + } + } + + using (var zip = new ZipArchive(new MemoryStream(data), ZipArchiveMode.Read)) + { + var entries = zip.Entries + .Where(e => !string.IsNullOrEmpty(e.Name) && !e.FullName.EndsWith("/") && !e.FullName.EndsWith("\\")) + .ToList(); + for (var i = 0; i < entries.Count; i++) + { + var entry = entries[i]; + progress.Report(label + ": writing " + (i + 1) + " of " + entries.Count + " files"); + var target = FullPath(gameDir, entry.FullName.Replace('\\', '/')); + Directory.CreateDirectory(Path.GetDirectoryName(target)); + using (var input = entry.Open()) + using (var output = File.Create(target)) + input.CopyTo(output); + } + } + } + public static void Uninstall(string gameDir, Package pkg) { if (pkg.roots == null) diff --git a/app/Models.cs b/app/Models.cs index 1ade2e3..e1bbe13 100644 --- a/app/Models.cs +++ b/app/Models.cs @@ -26,6 +26,14 @@ namespace SotfModLoader public List files { get; set; } } + public class RedLoaderVersion + { + public string version { get; set; } + public string downloadUrl { get; set; } + public string releaseUrl { get; set; } + public long size { get; set; } + } + public class AppInfo { public string version { get; set; } @@ -37,6 +45,7 @@ namespace SotfModLoader public string generated { get; set; } public string owner { get; set; } public Package redloader { get; set; } + public List redloaderVersions { get; set; } public List mods { get; set; } public AppInfo app { get; set; } } diff --git a/app/Rows.cs b/app/Rows.cs index 7737d95..6255a61 100644 --- a/app/Rows.cs +++ b/app/Rows.cs @@ -89,8 +89,8 @@ namespace SotfModLoader if (!string.IsNullOrEmpty(url)) _title.LinkClicked += (s, e) => MainForm.OpenUrl(url); - _desc = new Label { Text = description ?? string.Empty, Font = bodyFont, AutoSize = true, ForeColor = textColor, BackColor = Color.Transparent }; - _meta = new Label { Text = meta ?? string.Empty, Font = metaFont, AutoSize = true, ForeColor = metaColor, BackColor = Color.Transparent }; + _desc = new Label { Text = description ?? string.Empty, Font = bodyFont, AutoSize = false, ForeColor = textColor, BackColor = Color.Transparent }; + _meta = new Label { Text = meta ?? string.Empty, Font = metaFont, AutoSize = false, ForeColor = metaColor, BackColor = Color.Transparent }; _actions = new FlowLayoutPanel { @@ -116,6 +116,13 @@ namespace SotfModLoader return (int)Math.Round(v * _scale); } + public static void Fit(Label label, int width) + { + var size = TextRenderer.MeasureText(label.Text.Length > 0 ? label.Text : " ", label.Font, + new Size(width, int.MaxValue), TextFormatFlags.WordBreak | TextFormatFlags.NoPadding); + label.Size = new Size(width, size.Height + 2); + } + public void Arrange(int width) { Width = width; @@ -124,11 +131,11 @@ namespace SotfModLoader var textWidth = Math.Max(S(160), width - actionsWidth - S(24)); _title.Location = new Point(0, S(14)); - _desc.MaximumSize = new Size(textWidth, 0); + Fit(_desc, textWidth); _desc.Location = new Point(0, _title.Bottom + S(2)); _desc.Visible = _desc.Text.Length > 0; var y = _desc.Visible ? _desc.Bottom : _title.Bottom; - _meta.MaximumSize = new Size(textWidth, 0); + Fit(_meta, textWidth); _meta.Location = new Point(0, y + S(6)); var bottom = Math.Max(_meta.Bottom, _actions.Controls.Count > 0 ? _actions.Bottom : 0); diff --git a/app/SotfModLoader.csproj b/app/SotfModLoader.csproj index 6ac3c69..3e5d357 100644 --- a/app/SotfModLoader.csproj +++ b/app/SotfModLoader.csproj @@ -6,7 +6,7 @@ 7.3 SotfModLoader SotfModLoader - 1.0.0 + 1.1.0 Sons of the Forest Mod Loader JustinOros JustinOros @@ -20,6 +20,7 @@ + diff --git a/build.py b/build.py index 17943e8..a264592 100644 --- a/build.py +++ b/build.py @@ -218,6 +218,31 @@ def build_redloader(): } +def build_redloader_versions(limit=10): + try: + releases = api(f"/repos/{REDLOADER_REPO}/releases?per_page=30") + except (urllib.error.URLError, OSError) as e: + print(f"warning: could not list RedLoader releases: {e}") + return [] + versions = [] + for release in releases: + if release.get("draft") or release.get("prerelease"): + continue + asset = zip_asset(release) + if not asset: + continue + versions.append({ + "version": release["tag_name"], + "downloadUrl": asset["browser_download_url"], + "releaseUrl": release["html_url"], + "size": asset.get("size", 0), + }) + if len(versions) >= limit: + break + print(f"RedLoader versions: {', '.join(v['version'] for v in versions)}") + return versions + + def build_app(): try: releases = api(f"/repos/{APP_REPO}/releases?per_page=30") @@ -285,6 +310,7 @@ def main(): "app": build_app(), "bundle": build_bundle(redloader, mods), "redloader": redloader, + "redloaderVersions": build_redloader_versions(), "mods": mods, } (OUT / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")