Add RedLoader version picker to the Windows app
Build Windows app / app (push) Waiting to run

This commit is contained in:
Justin Oros
2026-09-28 15:05:19 -07:00
parent b5b1a48153
commit 9bac1f221e
6 changed files with 186 additions and 23 deletions
+87 -18
View File
@@ -51,6 +51,7 @@ namespace SotfModLoader
private string _kind;
private bool _busy;
private ModStatus _redloaderStatus;
private string _selectedRedLoader;
private readonly Dictionary<string, ModStatus> _statuses = new Dictionary<string, ModStatus>();
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<RedLoaderVersion> RedLoaderVersions()
{
var versions = _manifest.redloaderVersions;
if (versions != null && versions.Count > 0)
return versions;
var rl = _manifest.redloader;
return new List<RedLoaderVersion> { new RedLoaderVersion { version = rl.version, releaseUrl = rl.releaseUrl } };
}
private int SelectedRedLoaderIndex(List<RedLoaderVersion> 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<Control>();
var controls = new List<Control>();
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<string> { "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.";
});
}
+51
View File
@@ -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<string> 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)
+9
View File
@@ -26,6 +26,14 @@ namespace SotfModLoader
public List<PackageFile> 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<RedLoaderVersion> redloaderVersions { get; set; }
public List<Package> mods { get; set; }
public AppInfo app { get; set; }
}
+11 -4
View File
@@ -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);
+2 -1
View File
@@ -6,7 +6,7 @@
<LangVersion>7.3</LangVersion>
<AssemblyName>SotfModLoader</AssemblyName>
<RootNamespace>SotfModLoader</RootNamespace>
<Version>1.0.0</Version>
<Version>1.1.0</Version>
<Product>Sons of the Forest Mod Loader</Product>
<Company>JustinOros</Company>
<Authors>JustinOros</Authors>
@@ -20,6 +20,7 @@
<ItemGroup>
<Reference Include="System.Drawing" />
<Reference Include="System.IO.Compression" />
<Reference Include="System.Net.Http" />
<Reference Include="System.Web.Extensions" />
<Reference Include="System.Windows.Forms" />
+26
View File
@@ -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")