From a22512b506fc9f717d7903faf89dba5a1bb2f84d Mon Sep 17 00:00:00 2001 From: Justin Oros Date: Mon, 28 Sep 2026 14:24:42 -0700 Subject: [PATCH] Add Windows app, zip download and SignPath signing --- .github/workflows/app.yml | 66 ++ .gitignore | 3 + .signpath/artifact-configurations/default.xml | 8 + LICENSE | 21 + README.md | 42 +- app/GameFolder.cs | 133 ++++ app/MainForm.cs | 597 ++++++++++++++++++ app/ModInstaller.cs | 202 ++++++ app/Models.cs | 50 ++ app/Program.cs | 18 + app/Rows.cs | 146 +++++ app/SotfModLoader.csproj | 32 + app/app.manifest | 21 + app/icon.ico | Bin 0 -> 23819 bytes build.py | 49 ++ site/app.js | 46 +- site/index.html | 5 +- site/style.css | 73 ++- 18 files changed, 1494 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/app.yml create mode 100644 .signpath/artifact-configurations/default.xml create mode 100644 LICENSE create mode 100644 app/GameFolder.cs create mode 100644 app/MainForm.cs create mode 100644 app/ModInstaller.cs create mode 100644 app/Models.cs create mode 100644 app/Program.cs create mode 100644 app/Rows.cs create mode 100644 app/SotfModLoader.csproj create mode 100644 app/app.manifest create mode 100644 app/icon.ico diff --git a/.github/workflows/app.yml b/.github/workflows/app.yml new file mode 100644 index 0000000..fc0b608 --- /dev/null +++ b/.github/workflows/app.yml @@ -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 diff --git a/.gitignore b/.gitignore index 6f3eff8..abc0c1a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ _site/ __pycache__/ +app/bin/ +app/obj/ +out/ diff --git a/.signpath/artifact-configurations/default.xml b/.signpath/artifact-configurations/default.xml new file mode 100644 index 0000000..5946d26 --- /dev/null +++ b/.signpath/artifact-configurations/default.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a57161e --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md index 0901404..e74b990 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,39 @@ # 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/ ## For players -1. Open the site in Chrome or Edge on Windows. -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 site offers three ways to install: -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 -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 - mirrors the latest RedLoader release +- builds `downloads/sotf-mods.zip` with RedLoader and every mod - 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 @@ -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. - `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. diff --git a/app/GameFolder.cs b/app/GameFolder.cs new file mode 100644 index 0000000..cb8a403 --- /dev/null +++ b/app/GameFolder.cs @@ -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 SteamLibraries() + { + var roots = new List(); + 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(); + 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 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 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); + } + } +} diff --git a/app/MainForm.cs b/app/MainForm.cs new file mode 100644 index 0000000..42d841e --- /dev/null +++ b/app/MainForm.cs @@ -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 _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") + "."; + }); + } + } +} diff --git a/app/ModInstaller.cs b/app/ModInstaller.cs new file mode 100644 index 0000000..986cf25 --- /dev/null +++ b/app/ModInstaller.cs @@ -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 LoadManifest() + { + var text = await Http.GetStringAsync(SiteUrl + "manifest.json?t=" + DateTime.UtcNow.Ticks); + return Json().Deserialize(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; + 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 data = null; + try + { + if (File.Exists(file)) + data = Json().DeserializeObject(File.ReadAllText(file)) as Dictionary; + } + catch + { + } + if (data == null) + data = new Dictionary(); + 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 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(); + 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 + { + } + }); + } + } +} diff --git a/app/Models.cs b/app/Models.cs new file mode 100644 index 0000000..1ade2e3 --- /dev/null +++ b/app/Models.cs @@ -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 roots { get; set; } + public List 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 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; } + } +} diff --git a/app/Program.cs b/app/Program.cs new file mode 100644 index 0000000..bce0c27 --- /dev/null +++ b/app/Program.cs @@ -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()); + } + } +} diff --git a/app/Rows.cs b/app/Rows.cs new file mode 100644 index 0000000..7737d95 --- /dev/null +++ b/app/Rows.cs @@ -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 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); + } + } +} diff --git a/app/SotfModLoader.csproj b/app/SotfModLoader.csproj new file mode 100644 index 0000000..6ac3c69 --- /dev/null +++ b/app/SotfModLoader.csproj @@ -0,0 +1,32 @@ + + + + WinExe + net48 + 7.3 + SotfModLoader + SotfModLoader + 1.0.0 + Sons of the Forest Mod Loader + JustinOros + JustinOros + Install, update and remove Sons of the Forest mods + Copyright (c) 2026 Justin Oros + app.manifest + icon.ico + none + false + + + + + + + + + + + + + + diff --git a/app/app.manifest b/app/app.manifest new file mode 100644 index 0000000..47919ba --- /dev/null +++ b/app/app.manifest @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + true + + + diff --git a/app/icon.ico b/app/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..052d18345b56e946639a6b1aa5284eecc399a84d GIT binary patch literal 23819 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 C9&kbc literal 0 HcmV?d00001 diff --git a/build.py b/build.py index b287579..280f1f6 100644 --- a/build.py +++ b/build.py @@ -13,6 +13,9 @@ OWNER = os.environ.get("OWNER", "JustinOros") TOKEN = os.environ.get("GITHUB_TOKEN", "") REDLOADER_REPO = "ToniMacaroni/RedLoader" 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 SITE_SRC = 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(): if OUT.exists(): shutil.rmtree(OUT) @@ -218,6 +265,8 @@ def main(): manifest = { "generated": datetime.now(timezone.utc).isoformat(timespec="seconds"), "owner": OWNER, + "app": build_app(), + "bundle": build_bundle(redloader, mods), "redloader": redloader, "mods": mods, } diff --git a/site/app.js b/site/app.js index eef8a37..578efcc 100644 --- a/site/app.js +++ b/site/app.js @@ -336,6 +336,39 @@ function versionLine(pkg, status) { 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() { const node = $("folder"); node.replaceChildren(); @@ -344,8 +377,8 @@ function renderFolder() { if (!supportsFs) { node.append( el("div", { class: "folder-text" }, - el("p", { class: "folder-name" }, "This browser can't install mods"), - 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-name" }, "Install from this page"), + el("p", { class: "folder-help" }, "This browser can't write to your game folder. Use the Windows app or the zip download above."), ), ); return; @@ -378,15 +411,13 @@ function renderFolder() { node.append( 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" }, state.folderError ? `${state.folderError} ` : "", - "On Steam it's usually ", - el("code", {}, "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Sons Of The Forest"), - ". In Steam you can find it with Manage, Browse local files.", + "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("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() { if (!state.manifest) return; + renderGet(); renderFolder(); renderRedloader(); renderMods(); diff --git a/site/index.html b/site/index.html index 25d9a08..5d637c3 100644 --- a/site/index.html +++ b/site/index.html @@ -15,9 +15,10 @@

Sons of the Forest mod loader

-

Pick your game folder once, then install, update and remove mods with one click.

+

Install, update and remove Sons of the Forest mods with one click.

-
+ +

diff --git a/site/style.css b/site/style.css index 1f72321..f993a0f 100644 --- a/site/style.css +++ b/site/style.css @@ -74,13 +74,84 @@ a:hover { 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 { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 1rem 1.5rem; - margin-top: 2.75rem; + margin-top: 1rem; padding: 1.25rem 1.5rem; background: var(--bark); border: 1px solid var(--bark-line);