This commit is contained in:
@@ -0,0 +1,66 @@
|
|||||||
|
name: Build Windows app
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["app-v*"]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
actions: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
app:
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: "8.0.x"
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$version = "${{ github.ref_name }}" -replace '^app-v', ''
|
||||||
|
if ($version -notmatch '^\d+\.\d+\.\d+$') { $version = '0.0.0' }
|
||||||
|
dotnet build app/SotfModLoader.csproj -c Release -p:Version=$version -o out
|
||||||
|
if (-not (Test-Path out/SotfModLoader.exe)) { throw 'The build did not produce SotfModLoader.exe' }
|
||||||
|
|
||||||
|
- id: upload
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: SotfModLoader
|
||||||
|
path: out/SotfModLoader.exe
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Sign with SignPath
|
||||||
|
if: ${{ vars.SIGNPATH_ORGANIZATION_ID != '' }}
|
||||||
|
uses: signpath/github-action-submit-signing-request@v2
|
||||||
|
with:
|
||||||
|
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||||
|
organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }}
|
||||||
|
project-slug: ${{ vars.SIGNPATH_PROJECT_SLUG }}
|
||||||
|
signing-policy-slug: ${{ vars.SIGNPATH_POLICY_SLUG }}
|
||||||
|
github-artifact-id: ${{ steps.upload.outputs.artifact-id }}
|
||||||
|
wait-for-completion: true
|
||||||
|
wait-for-completion-timeout-in-seconds: 18000
|
||||||
|
output-artifact-directory: signed
|
||||||
|
|
||||||
|
- name: Collect
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
New-Item -ItemType Directory -Force release | Out-Null
|
||||||
|
$source = if (Test-Path signed/SotfModLoader.exe) { 'signed/SotfModLoader.exe' } else { 'out/SotfModLoader.exe' }
|
||||||
|
Copy-Item $source release/SotfModLoader.exe
|
||||||
|
$signature = Get-AuthenticodeSignature release/SotfModLoader.exe
|
||||||
|
Write-Host "Signature: $($signature.Status)"
|
||||||
|
|
||||||
|
- name: Publish release
|
||||||
|
if: startsWith(github.ref, 'refs/tags/app-v')
|
||||||
|
shell: pwsh
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
gh release create "${{ github.ref_name }}" release/SotfModLoader.exe -R "${{ github.repository }}" --title "Mod loader app ${{ github.ref_name }}" --notes "Windows app for installing, updating and removing Sons of the Forest mods."
|
||||||
|
gh workflow run pages.yml -R "${{ github.repository }}" --ref main
|
||||||
@@ -1,2 +1,5 @@
|
|||||||
_site/
|
_site/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
app/bin/
|
||||||
|
app/obj/
|
||||||
|
out/
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<artifact-configuration xmlns="http://signpath.io/artifact-configuration/v1">
|
||||||
|
<zip-file>
|
||||||
|
<pe-file path="SotfModLoader.exe" product-name="Sons of the Forest Mod Loader">
|
||||||
|
<authenticode-sign />
|
||||||
|
</pe-file>
|
||||||
|
</zip-file>
|
||||||
|
</artifact-configuration>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Justin Oros
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -1,26 +1,39 @@
|
|||||||
# sotf-modloader
|
# sotf-modloader
|
||||||
|
|
||||||
A web page for installing, updating and removing Sons of the Forest mods with one click.
|
Install, update and remove Sons of the Forest mods with one click.
|
||||||
|
|
||||||
Live site: https://justinoros.github.io/sotf-modloader/
|
Live site: https://justinoros.github.io/sotf-modloader/
|
||||||
|
|
||||||
## For players
|
## For players
|
||||||
|
|
||||||
1. Open the site in Chrome or Edge on Windows.
|
The site offers three ways to install:
|
||||||
2. Click **Choose game folder** and pick your Sons of the Forest install folder.
|
|
||||||
3. Install RedLoader if it isn't installed yet, then install the mods you want.
|
|
||||||
|
|
||||||
The page remembers your folder. Close the game before installing or updating.
|
1. **Windows app (recommended).** Download `SotfModLoader.exe` from the site and run it. It finds your game through Steam, including installs inside Program Files, and installs RedLoader and mods with one click.
|
||||||
|
2. **Zip download.** One zip with RedLoader and every mod. Extract it into your Sons of the Forest folder, the one that contains `SonsOfTheForest.exe`.
|
||||||
|
3. **Install from the page.** Works in Chrome or Edge when the game is not inside Program Files. Browsers are not allowed to open folders there.
|
||||||
|
|
||||||
|
Close the game before installing or updating.
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
A GitHub Actions workflow runs every 6 hours, on every push to `main`, and on demand. It:
|
The `Build and deploy` workflow runs every 6 hours, on every push to `main`, and on demand. It:
|
||||||
|
|
||||||
- finds every repo owned by the account with the `sotf-mod` topic and mirrors the zip from its latest release
|
- finds every repo owned by the account with the `sotf-mod` topic and mirrors the zip from its latest release
|
||||||
- mirrors the latest RedLoader release
|
- mirrors the latest RedLoader release
|
||||||
|
- builds `downloads/sotf-mods.zip` with RedLoader and every mod
|
||||||
- publishes everything with a `manifest.json` to GitHub Pages
|
- publishes everything with a `manifest.json` to GitHub Pages
|
||||||
|
|
||||||
The page writes files straight into the game folder using the browser's File System Access API.
|
The Windows app in `app/` reads the same `manifest.json`.
|
||||||
|
|
||||||
|
## Releasing the Windows app
|
||||||
|
|
||||||
|
Push a tag starting with `app-v`:
|
||||||
|
|
||||||
|
```
|
||||||
|
git tag app-v1.0.0 && git push origin app-v1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
The `Build Windows app` workflow builds `SotfModLoader.exe`, signs it with SignPath when signing is set up, publishes a GitHub release and refreshes the site.
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
@@ -34,4 +47,17 @@ The page writes files straight into the game folder using the browser's File Sys
|
|||||||
- `version` in `manifest.json` matches the release tag, so update detection works.
|
- `version` in `manifest.json` matches the release tag, so update detection works.
|
||||||
- `platform` in `manifest.json` is `Client`, `Server` or `Universal`.
|
- `platform` in `manifest.json` is `Client`, `Server` or `Universal`.
|
||||||
|
|
||||||
After publishing a mod release, run the workflow manually to update the site right away.
|
## Code signing policy
|
||||||
|
|
||||||
|
Free code signing provided by [SignPath.io](https://about.signpath.io), certificate by [SignPath Foundation](https://signpath.org).
|
||||||
|
|
||||||
|
- Committers and reviewers: [JustinOros](https://github.com/JustinOros)
|
||||||
|
- Approvers: [JustinOros](https://github.com/JustinOros)
|
||||||
|
|
||||||
|
## Privacy policy
|
||||||
|
|
||||||
|
This program will not transfer any information to other networked systems unless specifically requested by the user or the person installing or operating it. It downloads the mod list and mod files from this project's GitHub Pages site and GitHub releases.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT. RedLoader is by ToniMacaroni and is redistributed unmodified under the Apache License 2.0.
|
||||||
|
|||||||
@@ -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 |
@@ -13,6 +13,9 @@ OWNER = os.environ.get("OWNER", "JustinOros")
|
|||||||
TOKEN = os.environ.get("GITHUB_TOKEN", "")
|
TOKEN = os.environ.get("GITHUB_TOKEN", "")
|
||||||
REDLOADER_REPO = "ToniMacaroni/RedLoader"
|
REDLOADER_REPO = "ToniMacaroni/RedLoader"
|
||||||
MOD_TOPIC = "sotf-mod"
|
MOD_TOPIC = "sotf-mod"
|
||||||
|
APP_REPO = os.environ.get("GITHUB_REPOSITORY") or f"{OWNER}/sotf-modloader"
|
||||||
|
APP_TAG_PREFIX = "app-v"
|
||||||
|
BUNDLE_NAME = "sotf-mods.zip"
|
||||||
ROOT = Path(__file__).resolve().parent
|
ROOT = Path(__file__).resolve().parent
|
||||||
SITE_SRC = ROOT / "site"
|
SITE_SRC = ROOT / "site"
|
||||||
OUT = ROOT / "_site"
|
OUT = ROOT / "_site"
|
||||||
@@ -203,6 +206,50 @@ def build_redloader():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_app():
|
||||||
|
try:
|
||||||
|
releases = api(f"/repos/{APP_REPO}/releases?per_page=30")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(f"warning: could not list app releases: {e}")
|
||||||
|
return None
|
||||||
|
for release in releases:
|
||||||
|
tag = release.get("tag_name", "")
|
||||||
|
if release.get("draft") or release.get("prerelease") or not tag.startswith(APP_TAG_PREFIX):
|
||||||
|
continue
|
||||||
|
for asset in release.get("assets", []):
|
||||||
|
if asset["name"].lower().endswith(".exe"):
|
||||||
|
print(f"app {tag}")
|
||||||
|
return {
|
||||||
|
"version": tag[len(APP_TAG_PREFIX):],
|
||||||
|
"url": asset["browser_download_url"],
|
||||||
|
"size": asset.get("size", 0),
|
||||||
|
"releaseUrl": release["html_url"],
|
||||||
|
}
|
||||||
|
print("warning: no app release found")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def build_bundle(redloader, mods):
|
||||||
|
target = OUT / "downloads" / BUNDLE_NAME
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
seen = set()
|
||||||
|
packages = ([redloader] if redloader else []) + mods
|
||||||
|
with zipfile.ZipFile(target, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
for pkg in packages:
|
||||||
|
src = OUT.joinpath(*PurePosixPath(pkg["base"]).parts)
|
||||||
|
for f in pkg["files"]:
|
||||||
|
key = f["path"].lower()
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
zf.write(src.joinpath(*PurePosixPath(f["path"]).parts), f["path"])
|
||||||
|
if redloader:
|
||||||
|
zf.writestr("sotf-modloader.json", json.dumps({"redloader": redloader["version"]}, indent=2))
|
||||||
|
size = target.stat().st_size
|
||||||
|
print(f"bundle {BUNDLE_NAME} ({size} bytes)")
|
||||||
|
return {"url": f"downloads/{BUNDLE_NAME}", "size": size}
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
if OUT.exists():
|
if OUT.exists():
|
||||||
shutil.rmtree(OUT)
|
shutil.rmtree(OUT)
|
||||||
@@ -218,6 +265,8 @@ def main():
|
|||||||
manifest = {
|
manifest = {
|
||||||
"generated": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
"generated": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||||
"owner": OWNER,
|
"owner": OWNER,
|
||||||
|
"app": build_app(),
|
||||||
|
"bundle": build_bundle(redloader, mods),
|
||||||
"redloader": redloader,
|
"redloader": redloader,
|
||||||
"mods": mods,
|
"mods": mods,
|
||||||
}
|
}
|
||||||
|
|||||||
+39
-7
@@ -336,6 +336,39 @@ function versionLine(pkg, status) {
|
|||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes) {
|
||||||
|
if (!bytes) return "";
|
||||||
|
if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(1)} MB`;
|
||||||
|
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGet() {
|
||||||
|
const node = $("get");
|
||||||
|
const { app, bundle } = state.manifest;
|
||||||
|
const cards = [];
|
||||||
|
if (app) {
|
||||||
|
cards.push(
|
||||||
|
el("div", { class: "get-card" },
|
||||||
|
el("h2", {}, "Windows app"),
|
||||||
|
el("p", {}, "Finds your game automatically, even inside Program Files, and installs RedLoader and mods with one click."),
|
||||||
|
el("a", { class: "button primary", href: app.url }, `Download the app (${formatSize(app.size)})`),
|
||||||
|
el("p", { class: "get-note" }, `Version ${app.version}. If Windows shows a warning, click More info, then Run anyway.`),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (bundle) {
|
||||||
|
cards.push(
|
||||||
|
el("div", { class: "get-card" },
|
||||||
|
el("h2", {}, "Zip download"),
|
||||||
|
el("p", {}, "RedLoader and every mod in one zip. Extract it into your Sons of the Forest folder, the one that contains SonsOfTheForest.exe."),
|
||||||
|
el("a", { class: "button secondary", href: bundle.url, download: "sotf-mods.zip" }, `Download zip (${formatSize(bundle.size)})`),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
node.hidden = !cards.length;
|
||||||
|
node.replaceChildren(...cards);
|
||||||
|
}
|
||||||
|
|
||||||
function renderFolder() {
|
function renderFolder() {
|
||||||
const node = $("folder");
|
const node = $("folder");
|
||||||
node.replaceChildren();
|
node.replaceChildren();
|
||||||
@@ -344,8 +377,8 @@ function renderFolder() {
|
|||||||
if (!supportsFs) {
|
if (!supportsFs) {
|
||||||
node.append(
|
node.append(
|
||||||
el("div", { class: "folder-text" },
|
el("div", { class: "folder-text" },
|
||||||
el("p", { class: "folder-name" }, "This browser can't install mods"),
|
el("p", { class: "folder-name" }, "Install from this page"),
|
||||||
el("p", { class: "folder-help" }, "Open this page in Chrome or Edge on Windows to install mods with one click. You can still download each mod from its release page below."),
|
el("p", { class: "folder-help" }, "This browser can't write to your game folder. Use the Windows app or the zip download above."),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -378,15 +411,13 @@ function renderFolder() {
|
|||||||
|
|
||||||
node.append(
|
node.append(
|
||||||
el("div", { class: "folder-text" },
|
el("div", { class: "folder-text" },
|
||||||
el("p", { class: "folder-name" }, "Choose your game folder"),
|
el("p", { class: "folder-name" }, "Install from this page"),
|
||||||
el("p", { class: "folder-help" },
|
el("p", { class: "folder-help" },
|
||||||
state.folderError ? `${state.folderError} ` : "",
|
state.folderError ? `${state.folderError} ` : "",
|
||||||
"On Steam it's usually ",
|
"Chrome and Edge can't open folders inside Program Files, which is where Steam installs games by default. If Chrome says the folder contains system files, use the Windows app instead.",
|
||||||
el("code", {}, "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Sons Of The Forest"),
|
|
||||||
". In Steam you can find it with Manage, Browse local files.",
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
el("button", { class: "primary", disabled: state.busy, onclick: chooseFolder }, "Choose game folder"),
|
el("button", { class: "secondary", disabled: state.busy, onclick: chooseFolder }, "Choose game folder"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,6 +517,7 @@ function renderFooter() {
|
|||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
if (!state.manifest) return;
|
if (!state.manifest) return;
|
||||||
|
renderGet();
|
||||||
renderFolder();
|
renderFolder();
|
||||||
renderRedloader();
|
renderRedloader();
|
||||||
renderMods();
|
renderMods();
|
||||||
|
|||||||
+3
-2
@@ -15,9 +15,10 @@
|
|||||||
<main class="page">
|
<main class="page">
|
||||||
<header class="masthead">
|
<header class="masthead">
|
||||||
<h1>Sons of the Forest mod loader</h1>
|
<h1>Sons of the Forest mod loader</h1>
|
||||||
<p class="lede">Pick your game folder once, then install, update and remove mods with one click.</p>
|
<p class="lede">Install, update and remove Sons of the Forest mods with one click.</p>
|
||||||
</header>
|
</header>
|
||||||
<section id="folder" class="folder" aria-label="Game folder"></section>
|
<section id="get" class="get" aria-label="Ways to install" hidden></section>
|
||||||
|
<section id="folder" class="folder" aria-label="Install from this page"></section>
|
||||||
<section id="redloader" class="block" aria-labelledby="redloader-title" hidden></section>
|
<section id="redloader" class="block" aria-labelledby="redloader-title" hidden></section>
|
||||||
<section id="mods" class="block" aria-labelledby="mods-title"></section>
|
<section id="mods" class="block" aria-labelledby="mods-title"></section>
|
||||||
<p id="status" class="status" role="status" aria-live="polite"></p>
|
<p id="status" class="status" role="status" aria-live="polite"></p>
|
||||||
|
|||||||
+72
-1
@@ -74,13 +74,84 @@ a:hover {
|
|||||||
font-size: 1.15rem;
|
font-size: 1.15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.get {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 2.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.get-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 1.35rem 1.5rem 1.4rem;
|
||||||
|
background: var(--bark);
|
||||||
|
border: 1px solid var(--bark-line);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.get-card:first-child {
|
||||||
|
border-top: 5px solid var(--flare);
|
||||||
|
}
|
||||||
|
|
||||||
|
.get-card h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--display);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.get-card p {
|
||||||
|
margin: 0.4rem 0 1.1rem;
|
||||||
|
color: var(--lichen);
|
||||||
|
}
|
||||||
|
|
||||||
|
.get-card .get-note {
|
||||||
|
margin: 0.8rem 0 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.button {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: auto;
|
||||||
|
padding: 0.6rem 1.1rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.button.primary {
|
||||||
|
background: var(--flare);
|
||||||
|
color: var(--flare-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
a.button.primary:hover {
|
||||||
|
background: var(--flare-hover);
|
||||||
|
color: var(--flare-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
a.button.secondary {
|
||||||
|
color: var(--fog);
|
||||||
|
border-color: var(--bark-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
a.button.secondary:hover {
|
||||||
|
border-color: var(--lichen);
|
||||||
|
color: var(--fog);
|
||||||
|
}
|
||||||
|
|
||||||
.folder {
|
.folder {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 1rem 1.5rem;
|
gap: 1rem 1.5rem;
|
||||||
margin-top: 2.75rem;
|
margin-top: 1rem;
|
||||||
padding: 1.25rem 1.5rem;
|
padding: 1.25rem 1.5rem;
|
||||||
background: var(--bark);
|
background: var(--bark);
|
||||||
border: 1px solid var(--bark-line);
|
border: 1px solid var(--bark-line);
|
||||||
|
|||||||
Reference in New Issue
Block a user