Initial commit: Brass & Sigil monorepo

Self-hosted Minecraft modpack distribution + administration system.

- launcher/  Avalonia 12 desktop client; single-file win-x64 publish.
             Microsoft auth via XboxAuthNet, manifest+SHA-1 mod sync,
             portable install path, sidecar settings.
- server/    brass-sigil-server daemon (.NET 8, linux-x64). Wraps the
             MC subprocess, embedded Kestrel admin panel with cookie
             auth + rate limiting, RCON bridge, scheduled backups,
             BlueMap CLI integration with player markers + skin proxy,
             friend-side whitelist request flow, world wipe with seed
             selection (keep current / random / custom).
- pack/      pack.lock.json (Modrinth + manual CurseForge entries),
             data-only tweak source under tweaks/, build outputs in
             overrides/ (gitignored).
- scripts/   Build-Pack / Build-Tweaks / Update-Pack / Check-Updates
             plus Deploy-Brass.ps1 unified one-shot deploy with
             version-bump pre-flight and daemon-state detection.
This commit is contained in:
Matt Sijbers
2026-05-05 00:19:05 +01:00
commit a1331212cb
99 changed files with 12640 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="ModpackLauncher.App"
RequestedThemeVariant="Dark">
<Application.Styles>
<FluentTheme />
</Application.Styles>
</Application>
+23
View File
@@ -0,0 +1,23 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
namespace ModpackLauncher;
public partial class App : Application
{
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = new MainWindow();
}
base.OnFrameworkInitializationCompleted();
}
}
+98
View File
@@ -0,0 +1,98 @@
#requires -Version 5
# One-shot helper: produces a multi-resolution icon.ico from icon.png.
# Run only when the source icon changes; commit the resulting icon.ico.
Add-Type -AssemblyName System.Drawing
Add-Type -AssemblyName PresentationCore
Add-Type -AssemblyName WindowsBase
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
$srcPath = Join-Path $here 'icon.png'
$icoPath = Join-Path $here 'icon.ico'
if (-not (Test-Path $srcPath)) { throw "icon.png not found at $srcPath" }
# Detect if the file is actually a different format renamed to .png (e.g. WebP from AI tools).
# If so, transcode via WPF's WIC pipeline to a real PNG before feeding GDI+.
$head = [System.IO.File]::ReadAllBytes($srcPath)[0..3]
$isPng = $head[0] -eq 0x89 -and $head[1] -eq 0x50 -and $head[2] -eq 0x4E -and $head[3] -eq 0x47
if (-not $isPng) {
Write-Host "Source file is not a PNG (likely WebP from AI tool). Transcoding via WIC..."
$bytes = [System.IO.File]::ReadAllBytes($srcPath)
$stream = New-Object System.IO.MemoryStream(,$bytes)
$decoder = [System.Windows.Media.Imaging.BitmapDecoder]::Create(
$stream,
[System.Windows.Media.Imaging.BitmapCreateOptions]::PreservePixelFormat,
[System.Windows.Media.Imaging.BitmapCacheOption]::OnLoad)
$frame = $decoder.Frames[0]
# Force BGRA32 so GDI+ can later handle it cleanly with alpha
$converted = New-Object System.Windows.Media.Imaging.FormatConvertedBitmap(
$frame,
[System.Windows.Media.PixelFormats]::Bgra32,
$null,
0)
$encoder = New-Object System.Windows.Media.Imaging.PngBitmapEncoder
$encoder.Frames.Add([System.Windows.Media.Imaging.BitmapFrame]::Create($converted))
$outStream = New-Object System.IO.MemoryStream
$encoder.Save($outStream)
[System.IO.File]::WriteAllBytes($srcPath, $outStream.ToArray())
$outStream.Dispose()
$stream.Dispose()
Write-Host "Transcoded to real PNG ($($outStream.Length) bytes)."
}
$sizes = 16, 32, 48, 64, 128, 256
$src = [System.Drawing.Image]::FromFile($srcPath)
$frames = @{}
foreach ($size in $sizes) {
$bmp = New-Object System.Drawing.Bitmap $size, $size, ([System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
$g = [System.Drawing.Graphics]::FromImage($bmp)
$g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
$g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::HighQuality
$g.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality
$g.CompositingQuality = [System.Drawing.Drawing2D.CompositingQuality]::HighQuality
$g.DrawImage($src, 0, 0, $size, $size)
$g.Dispose()
$ms = New-Object System.IO.MemoryStream
$bmp.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
$frames[$size] = $ms.ToArray()
$bmp.Dispose()
$ms.Dispose()
}
$out = New-Object System.IO.MemoryStream
$bw = New-Object System.IO.BinaryWriter($out)
# ICONDIR header
$bw.Write([UInt16]0)
$bw.Write([UInt16]1)
$bw.Write([UInt16]$sizes.Count)
$dataOffset = 6 + (16 * $sizes.Count)
foreach ($size in $sizes) {
$bytes = $frames[$size]
$w = if ($size -ge 256) { [byte]0 } else { [byte]$size }
$h = if ($size -ge 256) { [byte]0 } else { [byte]$size }
$bw.Write([byte]$w)
$bw.Write([byte]$h)
$bw.Write([byte]0)
$bw.Write([byte]0)
$bw.Write([UInt16]1)
$bw.Write([UInt16]32)
$bw.Write([UInt32]$bytes.Length)
$bw.Write([UInt32]$dataOffset)
$dataOffset += $bytes.Length
}
foreach ($size in $sizes) {
$bw.Write($frames[$size])
}
[System.IO.File]::WriteAllBytes($icoPath, $out.ToArray())
$bw.Dispose()
$out.Dispose()
$src.Dispose()
"Wrote: $icoPath ($((Get-Item $icoPath).Length) bytes, $($sizes.Count) sizes)"
+43
View File
@@ -0,0 +1,43 @@
#requires -Version 5
# One-shot helper: generates a subtle warm-tinted tileable noise texture
# at Assets/noise.png. Run only when you want to regenerate the texture.
Add-Type -AssemblyName System.Drawing
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
$outPath = Join-Path $here 'noise.png'
$size = 128
$bmp = New-Object System.Drawing.Bitmap $size, $size, ([System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
$rng = New-Object System.Random 1337
# Lock bits for fast pixel access
$rect = New-Object System.Drawing.Rectangle 0, 0, $size, $size
$data = $bmp.LockBits($rect, [System.Drawing.Imaging.ImageLockMode]::WriteOnly,
[System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
$bytes = New-Object byte[] ($data.Stride * $size)
function Clamp([double]$v, [double]$lo, [double]$hi) {
if ($v -lt $lo) { return $lo }
if ($v -gt $hi) { return $hi }
return $v
}
for ($y = 0; $y -lt $size; $y++) {
for ($x = 0; $x -lt $size; $x++) {
$offset = ($y * $data.Stride) + ($x * 4)
# Cool dark grain to overlay on a navy backdrop -- gives subtle metallic noise
$n = ($rng.NextDouble() - 0.5) * 2.0 # -1.0 .. 1.0
$bytes[$offset] = [byte](Clamp (110 + ($n * 50)) 0 255) # B
$bytes[$offset + 1] = [byte](Clamp (105 + ($n * 50)) 0 255) # G
$bytes[$offset + 2] = [byte](Clamp (95 + ($n * 50)) 0 255) # R
$bytes[$offset + 3] = 28 # A (~11%)
}
}
[System.Runtime.InteropServices.Marshal]::Copy($bytes, 0, $data.Scan0, $bytes.Length)
$bmp.UnlockBits($data)
$bmp.Save($outPath, [System.Drawing.Imaging.ImageFormat]::Png)
$bmp.Dispose()
"Wrote: $outPath ($((Get-Item $outPath).Length) bytes, ${size}x${size})"
Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 634 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+395
View File
@@ -0,0 +1,395 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="600"
x:Class="ModpackLauncher.MainWindow"
Title="Modpack Launcher"
Width="900" Height="640"
MinWidth="720" MinHeight="540"
Background="Transparent"
TransparencyLevelHint="Transparent"
WindowDecorations="None"
Icon="/Assets/icon.png"
WindowStartupLocation="CenterScreen">
<Window.Resources>
<!-- Palette: dark navy base + brass trim + cyan magic accent -->
<!-- Brass gradient (lit from top, polished) -->
<LinearGradientBrush x:Key="BrassTrim" StartPoint="0%,0%" EndPoint="0%,100%">
<GradientStop Offset="0" Color="#F5D88A" />
<GradientStop Offset="0.35" Color="#E8B95C" />
<GradientStop Offset="0.65" Color="#A37A2E" />
<GradientStop Offset="1" Color="#5C4519" />
</LinearGradientBrush>
<LinearGradientBrush x:Key="BrassFill" StartPoint="0%,0%" EndPoint="0%,100%">
<GradientStop Offset="0" Color="#F5D88A" />
<GradientStop Offset="0.5" Color="#D4A24C" />
<GradientStop Offset="1" Color="#8C6829" />
</LinearGradientBrush>
<LinearGradientBrush x:Key="BrassFillHover" StartPoint="0%,0%" EndPoint="0%,100%">
<GradientStop Offset="0" Color="#FBE3A0" />
<GradientStop Offset="0.5" Color="#E8B95C" />
<GradientStop Offset="1" Color="#A37A2E" />
</LinearGradientBrush>
<!-- Rivet (radial gradient, lit from upper-left) -->
<RadialGradientBrush x:Key="RivetFill" Center="35%,35%" GradientOrigin="35%,35%" RadiusX="0.55" RadiusY="0.55">
<GradientStop Offset="0" Color="#F8DC95" />
<GradientStop Offset="0.5" Color="#C99843" />
<GradientStop Offset="1" Color="#3F2E10" />
</RadialGradientBrush>
<!-- Card fill (deep navy gradient) -->
<LinearGradientBrush x:Key="CardFill" StartPoint="0%,0%" EndPoint="0%,100%">
<GradientStop Offset="0" Color="#13192A" />
<GradientStop Offset="1" Color="#0A0F1A" />
</LinearGradientBrush>
<!-- Title bar metallic -->
<LinearGradientBrush x:Key="TitleBarFill" StartPoint="0%,0%" EndPoint="0%,100%">
<GradientStop Offset="0" Color="#0F1626" />
<GradientStop Offset="1" Color="#070B16" />
</LinearGradientBrush>
<!-- Magic cyan glow (radial) -->
<RadialGradientBrush x:Key="MagicGlow" Center="50%,50%" GradientOrigin="50%,50%" RadiusX="0.8" RadiusY="0.8">
<GradientStop Offset="0" Color="#605DD4E8" />
<GradientStop Offset="0.5" Color="#205DD4E8" />
<GradientStop Offset="1" Color="#005DD4E8" />
</RadialGradientBrush>
<!-- Tileable noise overlay -->
<ImageBrush x:Key="NoiseBrush" Source="/Assets/noise.png"
TileMode="Tile" Stretch="None"
DestinationRect="0,0,128,128" />
</Window.Resources>
<Window.Styles>
<!-- Reusable "brass panel" with metal trim + corner rivets -->
<Style Selector="ContentControl.brass-panel">
<Setter Property="Padding" Value="20" />
<Setter Property="Template">
<ControlTemplate>
<Grid>
<!-- Outer brass trim -->
<Border BorderBrush="{StaticResource BrassTrim}" BorderThickness="2" CornerRadius="6"
Background="{StaticResource CardFill}">
<ContentPresenter Content="{TemplateBinding Content}"
Margin="{TemplateBinding Padding}" />
</Border>
<!-- Corner rivets -->
<Ellipse Width="6" Height="6" HorizontalAlignment="Left" VerticalAlignment="Top"
Margin="6" Fill="{StaticResource RivetFill}" />
<Ellipse Width="6" Height="6" HorizontalAlignment="Right" VerticalAlignment="Top"
Margin="6" Fill="{StaticResource RivetFill}" />
<Ellipse Width="6" Height="6" HorizontalAlignment="Left" VerticalAlignment="Bottom"
Margin="6" Fill="{StaticResource RivetFill}" />
<Ellipse Width="6" Height="6" HorizontalAlignment="Right" VerticalAlignment="Bottom"
Margin="6" Fill="{StaticResource RivetFill}" />
</Grid>
</ControlTemplate>
</Setter>
</Style>
<!-- Primary button: brass gradient + cyan magic hover glow -->
<Style Selector="Button.primary">
<Setter Property="Background" Value="{StaticResource BrassFill}" />
<Setter Property="Foreground" Value="#1A140F" />
<Setter Property="Padding" Value="26 12" />
<Setter Property="FontSize" Value="16" />
<Setter Property="FontWeight" Value="Bold" />
<Setter Property="CornerRadius" Value="6" />
<Setter Property="BorderBrush" Value="#5C4519" />
<Setter Property="BorderThickness" Value="1" />
</Style>
<Style Selector="Button.primary:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource BrassFillHover}" />
</Style>
<Style Selector="Button.primary:disabled /template/ ContentPresenter">
<Setter Property="Background" Value="#3A2D1E" />
<Setter Property="Foreground" Value="#6B5F4A" />
</Style>
<!-- Secondary button -->
<Style Selector="Button.secondary">
<Setter Property="Background" Value="#1B233A" />
<Setter Property="Foreground" Value="#E8DFC8" />
<Setter Property="Padding" Value="14 8" />
<Setter Property="CornerRadius" Value="4" />
<Setter Property="BorderBrush" Value="#A37A2E" />
<Setter Property="BorderThickness" Value="1" />
</Style>
<Style Selector="Button.secondary:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="#26314D" />
<Setter Property="BorderBrush" Value="#E8B95C" />
</Style>
<Style Selector="TextBlock.h1">
<Setter Property="FontSize" Value="22" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Foreground" Value="#E8DFC8" />
</Style>
<Style Selector="TextBlock.muted">
<Setter Property="Foreground" Value="#7A8497" />
<Setter Property="FontSize" Value="13" />
</Style>
<!-- Brass progress bar -->
<Style Selector="ProgressBar.brass">
<Setter Property="Foreground" Value="{StaticResource BrassFill}" />
<Setter Property="Background" Value="#0A0F1A" />
<Setter Property="BorderBrush" Value="{StaticResource BrassTrim}" />
<Setter Property="BorderThickness" Value="1" />
</Style>
<!-- Custom title bar caption buttons -->
<Style Selector="Button.caption">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Width" Value="46" />
<Setter Property="Height" Value="36" />
<Setter Property="CornerRadius" Value="0" />
<Setter Property="Padding" Value="0" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
<Style Selector="Button.caption:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="#1B233A" />
</Style>
<Style Selector="Button.caption Path">
<Setter Property="Stroke" Value="#A37A2E" />
<Setter Property="StrokeThickness" Value="1" />
<Setter Property="HorizontalAlignment" Value="Center" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<Style Selector="Button.caption:pointerover Path">
<Setter Property="Stroke" Value="#F5D88A" />
</Style>
<Style Selector="Button.caption-close">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Width" Value="46" />
<Setter Property="Height" Value="36" />
<Setter Property="CornerRadius" Value="0" />
<Setter Property="Padding" Value="0" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
<Style Selector="Button.caption-close:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="#B94228" />
</Style>
<Style Selector="Button.caption-close Path">
<Setter Property="Stroke" Value="#A37A2E" />
<Setter Property="StrokeThickness" Value="1" />
<Setter Property="HorizontalAlignment" Value="Center" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<Style Selector="Button.caption-close:pointerover Path">
<Setter Property="Stroke" Value="#F5D88A" />
</Style>
</Window.Styles>
<!-- Outermost: main brass-bordered window + slide-out info panel (separate column, OUTSIDE the window) -->
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Border Grid.Column="0" Name="RootBorder" CornerRadius="10"
Background="{StaticResource BrassTrim}"
ClipToBounds="True">
<Border CornerRadius="8" Background="#0B1220" Margin="2" ClipToBounds="True">
<Panel Background="{StaticResource NoiseBrush}">
<Grid RowDefinitions="36,1,*">
<!-- Title bar -->
<Grid Grid.Row="0" Name="TitleBar" Background="{StaticResource TitleBarFill}"
PointerPressed="OnTitleBarPressed">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<!-- Magic glow halo behind icon + title text -->
<Grid Grid.Column="0" Margin="10 0 0 0" VerticalAlignment="Center" IsHitTestVisible="False">
<Ellipse Width="36" Height="36" HorizontalAlignment="Left"
Margin="-8 0 0 0" Fill="{StaticResource MagicGlow}" />
<StackPanel Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
<Image Source="/Assets/icon.png" Width="22" Height="22"
RenderOptions.BitmapInterpolationMode="HighQuality" />
<TextBlock Name="TitleText" Text="Brass &amp; Sigil Launcher"
Foreground="#F5D88A" FontSize="12" FontWeight="SemiBold"
VerticalAlignment="Center" />
</StackPanel>
</Grid>
<StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Stretch">
<Button Classes="caption" Click="OnRefreshClick" ToolTip.Tip="Check for updates">
<Path Width="11" Height="11"
Data="M 9,1.5 A 4,4 0 1 0 10.5,5 M 9,1.5 V 4 H 6.5" />
</Button>
<Button Classes="caption" Click="OnInfoClick" ToolTip.Tip="Pack info">
<Path Width="11" Height="11"
Data="M 5.5,0.5 A 5,5 0 1 1 5.499,0.5 Z M 5.5,4 V 9 M 5.5,2 V 2.5" />
</Button>
<Button Classes="caption" Click="OnMinimizeClick" ToolTip.Tip="Minimize">
<Path Width="10" Height="10" Data="M 0,5 H 10" />
</Button>
<Button Classes="caption-close" Click="OnCloseClick" ToolTip.Tip="Close">
<Path Width="10" Height="10" Data="M 0,0 L 10,10 M 10,0 L 0,10" />
</Button>
</StackPanel>
</Grid>
<!-- Brass divider hairline (own row so hover backgrounds can't paint over it) -->
<Border Grid.Row="1" Background="{StaticResource BrassTrim}" IsHitTestVisible="False" />
<!-- Main content -->
<Grid Grid.Row="2" RowDefinitions="Auto,Auto,*,Auto" Margin="14">
<!-- Update-available banner (hidden until manifest reports a newer launcherVersion) -->
<Border Name="UpdateBanner" Grid.Row="0" IsVisible="False"
Background="#13192A" BorderBrush="#D4A24C" BorderThickness="1"
CornerRadius="6" Padding="12 10" Margin="0 0 0 12">
<Grid ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center">
<TextBlock Grid.Column="0" Text="ⓘ" FontSize="16"
Foreground="#E8B95C" VerticalAlignment="Center" Margin="0 0 10 0" />
<TextBlock Grid.Column="1" Name="UpdateBannerText" VerticalAlignment="Center"
Foreground="#E8DFC8" FontSize="13" TextWrapping="Wrap"
Text="A newer launcher is available." />
<Button Grid.Column="2" Name="UpdateBannerDownloadButton"
Classes="secondary" Content="Download"
Click="OnUpdateBannerDownloadClick" />
</Grid>
</Border>
<!-- Header card -->
<ContentControl Grid.Row="1" Classes="brass-panel" Margin="0 0 0 14">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Orientation="Vertical" Spacing="4">
<TextBlock Name="PackNameText" Classes="h1" Text="Modpack" />
<TextBlock Name="PackVersionText" Classes="muted" Text="No pack synced yet" />
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center" Spacing="10">
<TextBlock Name="UserText" Classes="muted" Text="Not signed in" VerticalAlignment="Center" />
<TextBlock Name="WhitelistStatusText" Classes="muted" Text="" IsVisible="False"
VerticalAlignment="Center" FontStyle="Italic" />
<Button Name="RequestAccessButton" Classes="secondary" Content="Request access"
Click="OnRequestAccessClick" IsVisible="False"
ToolTip.Tip="Send the server admin a request to whitelist your account." />
<Button Name="LoginButton" Classes="secondary" Content="Sign in" Click="OnLoginClick" />
<Button Name="LogoutButton" Classes="secondary" Content="Sign out"
Click="OnLogoutClick" IsVisible="False" />
</StackPanel>
</Grid>
</ContentControl>
<!-- Action + log -->
<Grid Grid.Row="2" RowDefinitions="Auto,*">
<ContentControl Grid.Row="0" Classes="brass-panel" Margin="0 0 0 14">
<StackPanel Spacing="14">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" VerticalAlignment="Center" Spacing="2">
<TextBlock Name="StatusText" Classes="h1" Text="Ready" />
<TextBlock Name="StatusSubtext" Classes="muted"
Text="Click Play to sync the pack and launch Minecraft." />
</StackPanel>
<!-- Play button with subtle cyan glow halo behind -->
<Grid Grid.Column="1" Width="180" Height="48">
<Ellipse HorizontalAlignment="Center" VerticalAlignment="Center"
Width="200" Height="80" Fill="{StaticResource MagicGlow}"
Opacity="0.45" IsHitTestVisible="False" />
<Button Name="PlayButton" Classes="primary" Content="Play"
HorizontalAlignment="Stretch" VerticalAlignment="Stretch"
Click="OnPlayClick" />
</Grid>
</Grid>
<ProgressBar Name="ProgressBar" Classes="brass" Height="8"
Minimum="0" Maximum="100" Value="0" CornerRadius="4" />
</StackPanel>
</ContentControl>
<ContentControl Grid.Row="1" Classes="brass-panel">
<Grid RowDefinitions="Auto,*">
<Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="0 0 0 8">
<TextBlock Grid.Column="0" Classes="h1" Text="Log" FontSize="14" />
<Button Grid.Column="1" Classes="secondary" Content="Open install folder"
Click="OnOpenFolderClick" />
</Grid>
<Border Grid.Row="1" Background="#070B16" CornerRadius="4" Padding="10"
BorderBrush="#1B233A" BorderThickness="1">
<ScrollViewer Name="LogScroll" VerticalScrollBarVisibility="Auto">
<TextBlock Name="LogText" FontFamily="Cascadia Mono, Consolas, monospace"
FontSize="12" Foreground="#B7C0D6" TextWrapping="Wrap" />
</ScrollViewer>
</Border>
</Grid>
</ContentControl>
</Grid>
<!-- Footer / settings -->
<ContentControl Grid.Row="3" Classes="brass-panel" Margin="0 14 0 0">
<StackPanel Spacing="10">
<!-- RAM allocation -->
<Grid ColumnDefinitions="Auto,Auto,*,Auto">
<TextBlock Grid.Column="0" Text="RAM" VerticalAlignment="Center" Classes="muted" />
<NumericUpDown Grid.Column="1" Name="RamBox" Margin="10 0 10 0" Width="140"
Minimum="2048" Maximum="65536" Increment="1024" FormatString="0" />
<TextBlock Grid.Column="2" Text="MB" VerticalAlignment="Center" Classes="muted" Margin="0 0 10 0" />
<TextBlock Grid.Column="3" Name="RamWarningText" VerticalAlignment="Center"
FontSize="11" TextWrapping="Wrap" />
</Grid>
<!-- Install location -->
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Text="Install location" VerticalAlignment="Center" Classes="muted" />
<TextBlock Grid.Column="1" Name="InstallDirText" VerticalAlignment="Center" Margin="10 0 10 0"
TextTrimming="CharacterEllipsis" Foreground="#B7C0D6" FontSize="12" />
<Button Grid.Column="2" Classes="secondary" Content="Change..." Click="OnChangeInstallDirClick" />
</Grid>
<TextBlock Foreground="#5C6478" FontSize="10" TextWrapping="Wrap" HorizontalAlignment="Center"
Text="NOT AN OFFICIAL MINECRAFT PRODUCT. NOT APPROVED BY OR ASSOCIATED WITH MOJANG OR MICROSOFT." />
</StackPanel>
</ContentControl>
</Grid>
</Grid>
</Panel>
</Border>
</Border>
<!-- Slide-out container: starts at Width=0 so the Auto column contributes nothing.
InfoPanel inside has fixed Width=320; HorizontalAlignment=Right + ClipToBounds=True
on the container makes the panel "slide in" from the right as the container grows.
Window.Width is animated in lock-step with InfoPanelContainer.Width so the main
content (RootBorder, in the Star column) keeps its current width throughout.
Vertically: outer Grid is single-row (full window height) so this Border
stretches naturally; the inner ScrollViewer inherits it via its Auto,*
Grid row. No code-behind height management needed. -->
<Border Grid.Column="1" Name="InfoPanelContainer" Width="0"
ClipToBounds="True">
<Border Name="InfoPanel" HorizontalAlignment="Right" Width="320" Margin="14 0 0 0"
CornerRadius="10" Background="{StaticResource BrassTrim}"
ClipToBounds="True">
<Border CornerRadius="8" Background="#0F1622" Margin="2" ClipToBounds="True">
<Grid RowDefinitions="Auto,*">
<Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="14 12 8 8">
<TextBlock Grid.Column="0" Classes="h1" Text="Pack info" FontSize="14"
VerticalAlignment="Center" />
<Button Grid.Column="1" Classes="caption" Click="OnInfoClick" ToolTip.Tip="Close"
Width="32" Height="28">
<Path Width="9" Height="9" Data="M 0,0 L 9,9 M 9,0 L 0,9" />
</Button>
</Grid>
<ScrollViewer Grid.Row="1" Name="InfoScrollViewer"
Padding="14 0 14 40"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<StackPanel Name="InfoPanelContent" Spacing="14" Margin="0 0 0 8" />
</ScrollViewer>
</Grid>
</Border>
</Border>
</Border>
</Grid>
</Window>
+980
View File
@@ -0,0 +1,980 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Platform.Storage;
using Avalonia.Threading;
using CmlLib.Core.Auth;
using ModpackLauncher.Models;
using ModpackLauncher.Services;
namespace ModpackLauncher;
public partial class MainWindow : Window
{
private enum LauncherState
{
Checking, // initial / fetching manifest
ConfigError, // manifest URL not set or unreachable + nothing local
NeedsDownload, // no local pack, manifest reachable
NeedsUpdate, // local pack version != manifest version
ReadyNotSignedIn,// up-to-date but not signed in
Ready // up-to-date and signed in
}
private readonly LauncherConfig _config;
private readonly AuthService _auth;
private readonly ManifestSyncService _sync;
private LauncherSettings _settings;
private LaunchService? _launch;
private MSession? _session;
private Manifest? _remoteManifest;
private LauncherState _state = LauncherState.Checking;
private bool _busy;
private bool _suppressAutoSave;
private bool _infoPanelOpen;
// Cached "what the play button should show when not busy". Updated by ApplyState,
// restored by SetBusy(false). Avoids race between login flow's SetBusy and the
// RefreshStateAsync triggered by ApplySession.
private string _playButtonLabel = "Play";
private bool _playButtonEnabled;
public MainWindow()
{
InitializeComponent();
FileLog.Init();
_config = LauncherConfig.Load();
_auth = new AuthService(_config.MsalClientId);
_sync = new ManifestSyncService();
_sync.SetBasicAuth(_config.HttpUsername, _config.HttpPassword);
var version = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version;
var versionText = version is null ? "" : $" v{version.Major}.{version.Minor}.{version.Build}";
Title = $"{_config.PackName} Launcher{versionText}";
PackNameText.Text = _config.PackName;
TitleText.Text = $"{_config.PackName} Launcher{versionText}";
_settings = LauncherSettings.Load();
// Initialise RAM control without triggering auto-save
_suppressAutoSave = true;
RamBox.Value = _settings.MemoryMB ?? _config.MemoryMB;
_suppressAutoSave = false;
RamBox.ValueChanged += OnRamValueChanged;
UpdateInstallDirDisplay();
UpdateRamWarning((int)(RamBox.Value ?? _config.MemoryMB));
var localVersion = _sync.GetLocalPackVersion(GetInstallDir());
PackVersionText.Text = localVersion?.Version is { } v
? $"Installed: v{v}"
: "No pack synced yet";
// Maximize is disabled on this launcher -- the slide-out animation gets unhappy
// when the window can't actually resize. If something (Win+Up, Aero Snap, etc.)
// pushes us to Maximized anyway, snap back to Normal.
PropertyChanged += (_, e) =>
{
if (e.Property == WindowStateProperty && WindowState == WindowState.Maximized)
{
WindowState = WindowState.Normal;
}
};
Opened += async (_, _) =>
{
CheckSystemRequirements();
await TrySilentSignInAsync();
await RefreshStateAsync(refetchManifest: true);
};
// Auto-refresh manifest when window regains focus (e.g. user alt-tabs back)
Activated += async (_, _) =>
{
if (_busy) return;
await RefreshStateAsync(refetchManifest: true);
};
}
private async void OnRefreshClick(object? sender, RoutedEventArgs e)
{
if (_busy) return;
_remoteManifest = null; // force a fresh fetch
await RefreshStateAsync(refetchManifest: true);
}
/// <summary>
/// Compare our running assembly version to the manifest's launcherVersion field.
/// If the manifest reports something newer, surface a non-blocking banner that
/// links to the public download URL. Doesn't auto-update -- friends decide when.
/// </summary>
private void CheckLauncherVersion(Manifest manifest)
{
UpdateBanner.IsVisible = false;
if (string.IsNullOrWhiteSpace(manifest.LauncherVersion)) return;
var current = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version;
if (current is null) return;
if (!Version.TryParse(manifest.LauncherVersion, out var advertised)) return;
if (current >= advertised) return;
UpdateBannerText.Text = $"A newer launcher (v{advertised}) is available -- you're on v{current.Major}.{current.Minor}.{current.Build}.";
UpdateBannerDownloadButton.Tag = manifest.LauncherUrl ?? "https://sijbers.uk/pack/BrassAndSigil-Launcher.exe";
UpdateBanner.IsVisible = true;
AppendLog($"[update] Newer launcher available: v{advertised} (running v{current})");
}
private void OnUpdateBannerDownloadClick(object? sender, RoutedEventArgs e)
{
var url = UpdateBannerDownloadButton.Tag as string
?? "https://sijbers.uk/pack/BrassAndSigil-Launcher.exe";
try
{
Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true });
}
catch (Exception ex)
{
AppendLog($"[update] Couldn't open browser: {ex.Message}");
}
}
private const double InfoPanelExtraWidth = 334; // 320 panel + 14 gap
private void OnInfoClick(object? sender, RoutedEventArgs e)
{
_infoPanelOpen = !_infoPanelOpen;
ApplyInfoPanelState();
}
private void ApplyInfoPanelState()
{
var currentContainerWidth = InfoPanelContainer.Width;
var targetContainerWidth = _infoPanelOpen ? InfoPanelExtraWidth : 0;
var deltaWidth = targetContainerWidth - currentContainerWidth;
AnimateSlideOut(currentContainerWidth, targetContainerWidth, Width, Width + deltaWidth);
}
private DispatcherTimer? _widthAnimTimer;
private void AnimateSlideOut(double containerStart, double containerEnd,
double windowStart, double windowEnd,
Action? onComplete = null)
{
_widthAnimTimer?.Stop();
var startTime = DateTime.UtcNow;
const double durationMs = 220;
_widthAnimTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(15) };
_widthAnimTimer.Tick += (_, _) =>
{
var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds;
var t = Math.Min(1.0, elapsed / durationMs);
var eased = 1 - Math.Pow(1 - t, 3); // ease-out cubic
// Animate both in lockstep so the Star column (RootBorder) keeps its width.
InfoPanelContainer.Width = containerStart + (containerEnd - containerStart) * eased;
Width = windowStart + (windowEnd - windowStart) * eased;
if (t >= 1.0)
{
InfoPanelContainer.Width = containerEnd;
Width = windowEnd;
_widthAnimTimer?.Stop();
_widthAnimTimer = null;
onComplete?.Invoke();
}
};
_widthAnimTimer.Start();
}
private void PopulateInfoPanel()
{
InfoPanelContent.Children.Clear();
if (_remoteManifest is null)
{
InfoPanelContent.Children.Add(new TextBlock
{
Text = "Pack info will appear once the manifest has been fetched.",
Foreground = new SolidColorBrush(Color.Parse("#9F8E72")),
TextWrapping = TextWrapping.Wrap,
FontSize = 12
});
return;
}
AddInfoSection("Pack",
("Name", _remoteManifest.Name ?? "(unnamed)"),
("Version", _remoteManifest.Version ?? "?"));
AddInfoSection("Minecraft",
("Version", _remoteManifest.Minecraft.Version),
("Loader", _remoteManifest.Loader is null
? "vanilla"
: $"{_remoteManifest.Loader.Type} {_remoteManifest.Loader.Version}"));
var modFiles = _remoteManifest.Files
.Where(f => f.Path.StartsWith("mods/", StringComparison.OrdinalIgnoreCase))
.ToList();
AddModListSection($"Mods ({modFiles.Count})", modFiles);
var others = _remoteManifest.Files
.Where(f => !f.Path.StartsWith("mods/", StringComparison.OrdinalIgnoreCase))
.ToList();
if (others.Count > 0)
{
AddOtherFilesSection($"Other files ({others.Count})", others);
}
}
private void AddInfoSection(string title, params (string Label, string Value)[] rows)
{
var stack = new StackPanel { Spacing = 4 };
stack.Children.Add(new TextBlock
{
Text = title.ToUpper(),
FontSize = 10,
FontWeight = FontWeight.Bold,
Foreground = new SolidColorBrush(Color.Parse("#E8B95C")),
Margin = new Thickness(0, 0, 0, 4)
});
foreach (var (label, value) in rows)
{
var row = new Grid { ColumnDefinitions = new ColumnDefinitions("Auto,*") };
row.Children.Add(new TextBlock
{
Text = label, Margin = new Thickness(0, 0, 8, 0),
Foreground = new SolidColorBrush(Color.Parse("#7A8497")),
FontSize = 12,
MinWidth = 60
});
var valueBlock = new TextBlock
{
Text = value,
Foreground = new SolidColorBrush(Color.Parse("#E8DFC8")),
FontSize = 12,
TextWrapping = TextWrapping.Wrap
};
Grid.SetColumn(valueBlock, 1);
row.Children.Add(valueBlock);
stack.Children.Add(row);
}
InfoPanelContent.Children.Add(stack);
}
private void AddModListSection(string title, System.Collections.Generic.List<ManifestFile> mods)
{
var stack = new StackPanel { Spacing = 6 };
stack.Children.Add(new TextBlock
{
Text = title.ToUpper(),
FontSize = 10,
FontWeight = FontWeight.Bold,
Foreground = new SolidColorBrush(Color.Parse("#E8B95C")),
Margin = new Thickness(0, 0, 0, 4)
});
foreach (var mod in mods)
{
var (name, version) = ParseModFilename(System.IO.Path.GetFileName(mod.Path));
var row = new StackPanel { Spacing = 1 };
row.Children.Add(new TextBlock
{
Text = name,
Foreground = new SolidColorBrush(Color.Parse("#E8DFC8")),
FontSize = 12,
FontWeight = FontWeight.Medium
});
row.Children.Add(new TextBlock
{
Text = version,
Foreground = new SolidColorBrush(Color.Parse("#7A8497")),
FontSize = 11
});
stack.Children.Add(row);
}
InfoPanelContent.Children.Add(stack);
}
private void AddOtherFilesSection(string title, System.Collections.Generic.List<ManifestFile> files)
{
var stack = new StackPanel { Spacing = 4 };
stack.Children.Add(new TextBlock
{
Text = title.ToUpper(),
FontSize = 10,
FontWeight = FontWeight.Bold,
Foreground = new SolidColorBrush(Color.Parse("#E8B95C")),
Margin = new Thickness(0, 0, 0, 4)
});
foreach (var f in files)
{
stack.Children.Add(new TextBlock
{
Text = f.Path,
Foreground = new SolidColorBrush(Color.Parse("#B7C0D6")),
FontSize = 11,
TextWrapping = TextWrapping.Wrap
});
}
InfoPanelContent.Children.Add(stack);
}
private static (string Name, string Version) ParseModFilename(string filename)
{
var stem = System.IO.Path.GetFileNameWithoutExtension(filename);
// Split on both '-' and '_' -- Modrinth filenames mix both conventions
// (e.g. Terralith_1.21.x_v2.5.8.jar uses underscores).
var parts = stem.Split(new[] { '-', '_' });
// Recognise version segments that start with a digit OR with 'v' followed
// by a digit (e.g. "v2.5.8"). Find the smallest index that matches so the
// entire trailing version chain is captured.
int versionIdx = -1;
for (int i = parts.Length - 1; i >= 0; i--)
{
var p = parts[i];
if (p.Length == 0) continue;
var c0 = p[0];
var startsWithDigit = char.IsDigit(c0);
var startsWithVDigit = (c0 == 'v' || c0 == 'V') && p.Length >= 2 && char.IsDigit(p[1]);
if (startsWithDigit || startsWithVDigit) versionIdx = i;
}
if (versionIdx <= 0) return (stem, "");
var skipWords = new System.Collections.Generic.HashSet<string>(StringComparer.OrdinalIgnoreCase)
{ "neoforge", "forge", "fabric", "bundled" };
var acronyms = new System.Collections.Generic.HashSet<string>(StringComparer.OrdinalIgnoreCase)
{ "ftb", "tfmg", "jei", "rei", "emi", "ae2", "ic2", "kubejs", "rpl", "c2me", "yungs" };
var nameParts = parts.Take(versionIdx)
.Where(s => !skipWords.Contains(s))
.Select(s => acronyms.Contains(s) ? s.ToUpper() : (s.Length > 0 ? char.ToUpper(s[0]) + s.Substring(1) : s));
var name = string.Join(" ", nameParts);
if (string.IsNullOrWhiteSpace(name)) name = stem;
var version = string.Join("-", parts.Skip(versionIdx));
return (name, version);
}
private void OnTitleBarPressed(object? sender, PointerPressedEventArgs e)
{
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
{
BeginMoveDrag(e);
}
}
private void OnMinimizeClick(object? sender, RoutedEventArgs e)
{
WindowState = WindowState.Minimized;
}
private void OnCloseClick(object? sender, RoutedEventArgs e)
{
Close();
}
private void CheckSystemRequirements()
{
// WebView2 Runtime is required for the default sign-in flow. Preinstalled on
// most modern Windows but not guaranteed -- surface a helpful message at startup.
if (!_auth.HasCustomClientId && !WebView2Check.IsInstalled())
{
AppendLog("[system] Microsoft Edge WebView2 Runtime not detected.");
AppendLog($"[system] Sign-in won't work until it's installed: {WebView2Check.DownloadUrl}");
UpdateStatus("WebView2 Runtime missing",
$"Sign-in needs Microsoft Edge WebView2 Runtime -- install from {WebView2Check.DownloadUrl}");
}
}
private async Task TrySilentSignInAsync()
{
var session = await _auth.TryAuthenticateSilentlyAsync();
if (session != null)
{
_session = session;
ApplySession(session);
}
}
private void ApplySession(MSession session)
{
_session = session;
UserText.Text = $"Signed in as {session.Username}";
LoginButton.IsVisible = false;
LogoutButton.IsVisible = true;
_ = RefreshStateAsync();
_ = RefreshWhitelistStatusAsync();
}
private void ClearSession()
{
_session = null;
UserText.Text = "Not signed in";
LoginButton.IsVisible = true;
LogoutButton.IsVisible = false;
WhitelistStatusText.IsVisible = false;
RequestAccessButton.IsVisible = false;
_ = RefreshStateAsync();
}
private async Task RefreshWhitelistStatusAsync()
{
if (_session is null || _remoteManifest is null) return;
var panelUrl = _remoteManifest.PanelUrl;
if (string.IsNullOrWhiteSpace(panelUrl))
{
// No panel configured in the manifest -- feature disabled.
WhitelistStatusText.IsVisible = false;
RequestAccessButton.IsVisible = false;
return;
}
var status = await new WhitelistRequestService().GetStatusAsync(panelUrl, _session.Username ?? "");
ApplyWhitelistStatus(status);
}
private void ApplyWhitelistStatus(string status)
{
// Status values: "pending", "approved", "denied", "unknown" (no record), "" (network error).
switch (status)
{
case "pending":
WhitelistStatusText.Text = "Whitelist request pending";
WhitelistStatusText.IsVisible = true;
RequestAccessButton.IsVisible = false;
break;
case "approved":
// Once approved, the server removes the record; this branch is rare
// (status returns "unknown" almost immediately after approve fires).
WhitelistStatusText.Text = "Whitelisted ✓";
WhitelistStatusText.IsVisible = true;
RequestAccessButton.IsVisible = false;
break;
case "denied":
WhitelistStatusText.Text = "Request denied";
WhitelistStatusText.IsVisible = true;
RequestAccessButton.IsVisible = true; // allow retry
RequestAccessButton.Content = "Request again";
break;
case "":
// Network error -- hide both, don't claim anything.
WhitelistStatusText.IsVisible = false;
RequestAccessButton.IsVisible = false;
break;
default: // "unknown" -- never requested
WhitelistStatusText.IsVisible = false;
RequestAccessButton.IsVisible = true;
RequestAccessButton.Content = "Request access";
break;
}
}
private async void OnRequestAccessClick(object? sender, RoutedEventArgs e)
{
if (_session is null || _remoteManifest?.PanelUrl is not { } panelUrl) return;
RequestAccessButton.IsEnabled = false;
try
{
var resp = await new WhitelistRequestService().SubmitAsync(panelUrl, _session.Username ?? "", null);
if (resp.Ok)
{
AppendLog($"[whitelist] Request sent for {_session.Username}.");
ApplyWhitelistStatus("pending");
}
else
{
AppendLog($"[whitelist] Request failed: {resp.Error ?? "unknown"}");
UpdateStatus("Request failed", resp.Error ?? "Couldn't reach server.");
}
}
finally { RequestAccessButton.IsEnabled = true; }
}
private async Task RefreshStateAsync(bool refetchManifest = false)
{
if (_busy) return;
if (string.IsNullOrWhiteSpace(_config.ManifestUrl) ||
_config.ManifestUrl.Contains("example.com"))
{
ApplyState(LauncherState.ConfigError, "Setup needed",
"Set 'manifestUrl' in launcher-config.json and rebuild.");
return;
}
if (refetchManifest || _remoteManifest == null)
{
ApplyState(LauncherState.Checking, "Checking...", "Looking for updates");
try
{
_remoteManifest = await _sync.FetchManifestOnlyAsync(_config.ManifestUrl);
PopulateInfoPanel();
CheckLauncherVersion(_remoteManifest);
_ = RefreshWhitelistStatusAsync();
}
catch (Exception ex)
{
AppendLog($"[manifest fetch] {ex.GetType().Name}: {ex.Message}");
// Offline fallback -- allow play if we have a local pack
var fallback = _sync.GetLocalPackVersion(GetInstallDir());
if (fallback?.Version != null)
{
if (_session != null)
{
ApplyState(LauncherState.Ready, "Play",
$"Offline: pack server unreachable, using local v{fallback.Version}");
}
else
{
ApplyState(LauncherState.ReadyNotSignedIn, "Play",
"Offline mode -- sign in to play");
}
}
else
{
ApplyState(LauncherState.ConfigError, "Connection error",
$"Couldn't reach pack server: {ex.Message}");
}
return;
}
}
var local = _sync.GetLocalPackVersion(GetInstallDir());
var remote = _remoteManifest!;
if (local == null)
{
ApplyState(LauncherState.NeedsDownload, "Download",
$"{remote.Name ?? "Pack"} v{remote.Version ?? "?"} ready to download");
return;
}
if (!string.Equals(local.Version, remote.Version, StringComparison.Ordinal))
{
ApplyState(LauncherState.NeedsUpdate, "Update",
$"Update available: v{local.Version} → v{remote.Version}");
return;
}
// Version matches -- but also verify the actual files are on disk. Catches AV
// quarantines, manual deletions, and interrupted downloads.
var missing = _sync.FindMissingFiles(remote, GetInstallDir());
if (missing.Count > 0)
{
ApplyState(LauncherState.NeedsUpdate, "Repair",
$"Pack files missing ({missing.Count}). Click to redownload.");
return;
}
if (_session == null)
{
ApplyState(LauncherState.ReadyNotSignedIn, "Play",
$"Up to date (v{local.Version}). Sign in to play.");
}
else
{
ApplyState(LauncherState.Ready, "Play",
$"Up to date (v{local.Version}). Ready to launch.");
}
}
private void ApplyState(LauncherState state, string buttonLabel, string subtext)
{
_state = state;
_playButtonLabel = buttonLabel;
_playButtonEnabled = state is LauncherState.NeedsDownload
or LauncherState.NeedsUpdate
or LauncherState.Ready;
if (!_busy)
{
PlayButton.Content = _playButtonLabel;
PlayButton.IsEnabled = _playButtonEnabled;
}
StatusText.Text = state switch
{
LauncherState.Ready or LauncherState.ReadyNotSignedIn => "Ready",
LauncherState.NeedsDownload => "Download required",
LauncherState.NeedsUpdate => "Update available",
LauncherState.Checking => "Checking...",
LauncherState.ConfigError => "Setup needed",
_ => "..."
};
StatusSubtext.Text = subtext;
}
private async void OnLoginClick(object? sender, RoutedEventArgs e)
{
if (_busy) return;
try
{
SetBusy(true);
UpdateStatus("Signing in...", "A browser window should appear.");
var session = await _auth.SignInInteractivelyAsync();
ApplySession(session);
UpdateStatus("Signed in", $"Welcome, {session.Username}.");
}
catch (Exception ex)
{
LogException("auth error", ex);
UpdateStatus("Sign-in failed", ex.Message);
}
finally
{
SetBusy(false);
// ApplySession's fire-and-forget RefreshStateAsync was a no-op because
// _busy was still true. Re-run state now that we're idle so the Play
// button enables based on the new session + current pack state.
await RefreshStateAsync();
}
}
private void LogException(string label, Exception ex)
{
AppendLog($"[{label}] {ex.GetType().Name}: {ex.Message}");
var inner = ex.InnerException;
var depth = 0;
while (inner != null && depth < 5)
{
AppendLog($" ↳ {inner.GetType().Name}: {inner.Message}");
inner = inner.InnerException;
depth++;
}
// Reflectively surface any Code / XErr / Redirect / StatusCode properties (XboxAuthException, etc.)
foreach (var prop in ex.GetType().GetProperties())
{
if (prop.Name is "Code" or "XErr" or "ErrorCode" or "StatusCode" or "Redirect" or "Identity" or "Message" or "Source" or "InnerException" or "TargetSite" or "StackTrace" or "Data" or "HelpLink" or "HResult")
{
if (prop.Name is "Message" or "Source" or "InnerException" or "TargetSite" or "StackTrace" or "Data" or "HelpLink" or "HResult") continue;
try
{
var val = prop.GetValue(ex);
if (val != null) AppendLog($" • {prop.Name}: {val}");
}
catch { }
}
}
}
private async void OnLogoutClick(object? sender, RoutedEventArgs e)
{
await _auth.SignOutAsync();
ClearSession();
UpdateStatus("Signed out", "Click Sign in to authenticate again.");
}
private async void OnPlayClick(object? sender, RoutedEventArgs e)
{
if (_busy) return;
switch (_state)
{
case LauncherState.NeedsDownload:
case LauncherState.NeedsUpdate:
await DoSyncAndInstallAsync();
break;
case LauncherState.Ready:
await DoLaunchAsync();
break;
// Other states leave the button disabled -- shouldn't reach here.
}
}
private async Task DoSyncAndInstallAsync()
{
try
{
SetBusy(true);
var installDir = GetInstallDir();
Directory.CreateDirectory(installDir);
var progress = new Progress<ProgressReport>(OnProgress);
UpdateStatus("Syncing pack...", "Fetching manifest and downloading mods.");
var syncResult = await _sync.SyncAsync(_config.ManifestUrl, installDir, progress);
_remoteManifest = syncResult.Manifest;
AppendLog($"Sync: {syncResult.Downloaded} downloaded, {syncResult.Removed} removed.");
PackVersionText.Text = syncResult.Manifest.Version is { } v
? $"Installed: v{v}"
: "Installed";
// Pre-populate the multiplayer server list so friends don't have to
// hand-type the address. Idempotent -- match-by-IP, won't duplicate.
if (syncResult.Manifest.DefaultServer is { } ds && !string.IsNullOrEmpty(ds.Ip))
{
try
{
new ServerListService().EnsureServer(installDir, ds.Name, ds.Ip);
AppendLog($"Multiplayer list seeded: {ds.Name} ({ds.Ip}).");
}
catch (Exception ex)
{
AppendLog($"[server-list] Couldn't update servers.dat: {ex.Message}");
}
}
// Pre-enable a default shader pack on fresh installs (Iris reads
// config/iris.properties at startup). Does nothing if the user has
// already chosen a different shader.
if (!string.IsNullOrEmpty(syncResult.Manifest.DefaultShader))
{
try
{
new IrisConfigService().SetDefaultShader(installDir, syncResult.Manifest.DefaultShader);
AppendLog($"Default shader: {syncResult.Manifest.DefaultShader} (only set if no shader was previously chosen).");
}
catch (Exception ex)
{
AppendLog($"[iris] Couldn't set default shader: {ex.Message}");
}
}
_launch ??= new LaunchService(installDir);
UpdateStatus("Installing Minecraft...",
"Downloading client, libraries, assets, and mod loader. First run may take a few minutes.");
var versionId = await _launch.InstallVersionAsync(
syncResult.Manifest, progress, CancellationToken.None);
AppendLog($"Version ready: {versionId}");
ResetProgress();
}
catch (Exception ex)
{
AppendLog($"[error] {ex.GetType().Name}: {ex.Message}");
UpdateStatus("Install failed", ex.Message);
}
finally
{
SetBusy(false);
await RefreshStateAsync();
}
}
private async Task DoLaunchAsync()
{
if (_session == null || _remoteManifest == null) return;
try
{
SetBusy(true);
var progress = new Progress<ProgressReport>(OnProgress);
var installDir = GetInstallDir();
_launch ??= new LaunchService(installDir);
// Pre-launch sanity: any manifest files missing? If so, re-sync.
var missing = _sync.FindMissingFiles(_remoteManifest, installDir);
if (missing.Count > 0)
{
UpdateStatus("Repairing pack...",
$"{missing.Count} file(s) missing -- redownloading before launch.");
AppendLog($"Pre-launch check: {missing.Count} files missing, re-syncing.");
var syncResult = await _sync.SyncAsync(_config.ManifestUrl, installDir, progress);
_remoteManifest = syncResult.Manifest;
AppendLog($"Re-sync: {syncResult.Downloaded} downloaded, {syncResult.Removed} removed.");
}
UpdateStatus("Verifying installation...",
"Checking Minecraft, mod loader, and libraries.");
// InstallVersionAsync is idempotent + always runs the library verifier afterwards,
// so this catches any post-install gaps (e.g. CmlLib's bootstraplauncher quirk).
var versionId = await _launch.InstallVersionAsync(
_remoteManifest, progress, CancellationToken.None);
var ram = (int)(RamBox.Value ?? _config.MemoryMB);
var minRam = ram;
var maxRam = ram;
UpdateStatus("Launching Minecraft...", "");
ResetProgress();
_ = Task.Run(async () =>
{
try
{
await _launch.LaunchAsync(versionId, _session!, minRam, maxRam, progress, CancellationToken.None);
}
catch (Exception ex)
{
await Dispatcher.UIThread.InvokeAsync(() =>
{
AppendLog($"[launch error] {ex.GetType().Name}: {ex.Message}");
UpdateStatus("Launch failed", ex.Message);
});
}
finally
{
await Dispatcher.UIThread.InvokeAsync(async () =>
{
SetBusy(false);
await RefreshStateAsync();
});
}
});
}
catch (Exception ex)
{
AppendLog($"[error] {ex.GetType().Name}: {ex.Message}");
UpdateStatus("Launch failed", ex.Message);
SetBusy(false);
await RefreshStateAsync();
}
}
private string GetInstallDir() => _config.GetInstallDir(_settings.InstallDirOverride);
private void UpdateInstallDirDisplay()
{
InstallDirText.Text = GetInstallDir();
}
private void UpdateRamWarning(int ramMb)
{
var totalMb = SystemInfo.TotalPhysicalMemoryMB;
var safeMax = SystemInfo.SafeMaxAllocationMB;
if (totalMb < 12 * 1024)
{
RamWarningText.Text = $"Only {totalMb / 1024} GB system RAM detected -- pack may not run smoothly (12+ GB recommended)";
RamWarningText.Foreground = new SolidColorBrush(Color.Parse("#E8B95C"));
}
else if (ramMb > safeMax)
{
RamWarningText.Text = $"Above safe limit for {totalMb / 1024} GB system (max {safeMax} MB recommended)";
RamWarningText.Foreground = new SolidColorBrush(Color.Parse("#DC6E28"));
}
else if (ramMb < 6144)
{
RamWarningText.Text = "Below recommended (6 GB+ for Distant Horizons)";
RamWarningText.Foreground = new SolidColorBrush(Color.Parse("#9F8E72"));
}
else
{
RamWarningText.Text = $"OK ({totalMb / 1024} GB system)";
RamWarningText.Foreground = new SolidColorBrush(Color.Parse("#7A8497"));
}
}
private void OnRamValueChanged(object? sender, NumericUpDownValueChangedEventArgs e)
{
if (_suppressAutoSave) return;
var ram = (int)(RamBox.Value ?? _config.MemoryMB);
_settings.MemoryMB = ram;
_settings.Save();
UpdateRamWarning(ram);
}
private async void OnChangeInstallDirClick(object? sender, RoutedEventArgs e)
{
var folders = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
{
Title = "Choose install location",
AllowMultiple = false
});
if (folders.Count == 0) return;
var picked = folders[0].TryGetLocalPath();
if (string.IsNullOrEmpty(picked)) return;
_settings.InstallDirOverride = picked;
_settings.Save();
UpdateInstallDirDisplay();
AppendLog($"Install location changed to: {picked}");
AppendLog("Pack will need to be re-downloaded at the new location on next Play.");
_launch = null; // force re-create with new path next time
_remoteManifest = null;
await RefreshStateAsync(refetchManifest: true);
}
private void OnOpenFolderClick(object? sender, RoutedEventArgs e)
{
var dir = GetInstallDir();
Directory.CreateDirectory(dir);
try
{
Process.Start(new ProcessStartInfo
{
FileName = dir,
UseShellExecute = true
});
}
catch (Exception ex)
{
AppendLog($"[open folder] {ex.Message}");
}
}
private void OnProgress(ProgressReport report)
{
if (Dispatcher.UIThread.CheckAccess())
{
ApplyProgress(report);
}
else
{
Dispatcher.UIThread.Post(() => ApplyProgress(report));
}
}
private void ApplyProgress(ProgressReport report)
{
switch (report.Kind)
{
case ProgressKind.Status:
StatusSubtext.Text = report.Message;
break;
case ProgressKind.Progress:
if (report.Percent >= 0) ProgressBar.Value = Math.Clamp(report.Percent, 0, 100);
StatusSubtext.Text = report.Message;
break;
case ProgressKind.Log:
AppendLog(report.Message);
break;
case ProgressKind.Error:
AppendLog($"[error] {report.Message}");
break;
}
}
private void UpdateStatus(string title, string subtitle)
{
StatusText.Text = title;
StatusSubtext.Text = subtitle;
}
private void ResetProgress()
{
ProgressBar.Value = 0;
}
private void AppendLog(string message)
{
if (string.IsNullOrEmpty(message)) return;
LogText.Text += (LogText.Text?.Length > 0 ? "\n" : "") + message;
LogScroll.ScrollToEnd();
FileLog.Write(message);
}
private void SetBusy(bool busy)
{
_busy = busy;
if (busy)
{
PlayButton.IsEnabled = false;
PlayButton.Content = "Working...";
}
else
{
// Restore from the cached state-derived label (avoids races with concurrent state refreshes).
PlayButton.Content = _playButtonLabel;
PlayButton.IsEnabled = _playButtonEnabled;
}
LoginButton.IsEnabled = !busy;
LogoutButton.IsEnabled = !busy;
}
}
+106
View File
@@ -0,0 +1,106 @@
using System;
using System.IO;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ModpackLauncher.Models;
public sealed class LauncherConfig
{
[JsonPropertyName("packName")]
public string PackName { get; set; } = "Modpack";
[JsonPropertyName("manifestUrl")]
public string ManifestUrl { get; set; } = "";
/// <summary>
/// Subfolder name appended under the install location (sidecar exe folder
/// by default, or the folder the user picked via "Change..."). Acts as
/// a safety net so picking a generic location like "D:\" doesn't dump
/// thousands of files at the drive root, and signals at a glance that
/// this is the launcher's data, not the launcher itself.
/// </summary>
[JsonPropertyName("installDirName")]
public string InstallDirName { get; set; } = "BrassAndSigilData";
[JsonPropertyName("memoryMB")]
public int MemoryMB { get; set; } = 8192;
[JsonPropertyName("msalClientId")]
public string MsalClientId { get; set; } = "";
/// <summary>Optional HTTP Basic auth username for the manifest URL and mod file URLs.</summary>
[JsonPropertyName("httpUsername")]
public string? HttpUsername { get; set; }
/// <summary>Optional HTTP Basic auth password (paired with HttpUsername).</summary>
[JsonPropertyName("httpPassword")]
public string? HttpPassword { get; set; }
public static LauncherConfig Load()
{
// 1. External override beside the exe (dev convenience / per-deploy override)
var sidecar = Path.Combine(AppContext.BaseDirectory, "launcher-config.json");
if (File.Exists(sidecar))
{
return ParseSafe(File.ReadAllText(sidecar));
}
// 2. Embedded launcher-config.json (set at build time from local copy)
var asm = typeof(LauncherConfig).Assembly;
using (var stream = asm.GetManifestResourceStream("launcher-config.json"))
{
if (stream != null)
{
using var reader = new StreamReader(stream);
return ParseSafe(reader.ReadToEnd());
}
}
// 3. Fall back to embedded template (so fresh clones still run, with placeholders)
using (var stream = asm.GetManifestResourceStream("launcher-config.template.json"))
{
if (stream != null)
{
using var reader = new StreamReader(stream);
return ParseSafe(reader.ReadToEnd());
}
}
return new LauncherConfig();
}
private static LauncherConfig ParseSafe(string json)
{
var opts = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
return JsonSerializer.Deserialize<LauncherConfig>(json, opts) ?? new LauncherConfig();
}
/// <summary>
/// Resolve the absolute install directory. The launcher behaves as a
/// portable app: by default it installs alongside the exe in
/// <c>&lt;exe-folder&gt;/&lt;InstallDirName&gt;/</c>. The user can override
/// via the "Change..." picker, which stores the chosen *parent* folder
/// in <c>InstallDirOverride</c>; we then append <see cref="InstallDirName"/>
/// to it (same safety reasoning as the default).
///
/// Smart-skip: if the parent path already ends in InstallDirName, we
/// don't double up. Lets users re-pick their existing install folder
/// (e.g. "D:\Games\BrassAndSigilData") without ending up at
/// "D:\Games\BrassAndSigilData\BrassAndSigilData".
/// </summary>
public string GetInstallDir(string? overrideDir = null)
{
var parent = !string.IsNullOrWhiteSpace(overrideDir)
? overrideDir!
: AppContext.BaseDirectory;
var trimmed = parent.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
if (string.Equals(Path.GetFileName(trimmed), InstallDirName, StringComparison.OrdinalIgnoreCase))
{
return trimmed;
}
return Path.Combine(parent, InstallDirName);
}
}
+54
View File
@@ -0,0 +1,54 @@
using System;
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ModpackLauncher.Models;
public sealed class LauncherSettings
{
[JsonPropertyName("memoryMB")]
public int? MemoryMB { get; set; }
[JsonPropertyName("installDirOverride")]
public string? InstallDirOverride { get; set; }
/// <summary>
/// Settings live next to the launcher exe ("sidecar"), so each copy of
/// the launcher has its own independent state. Drop the launcher in a
/// new folder on a different machine, or alongside the existing one in
/// a separate directory, and they remember their own install paths,
/// memory choices, etc. Matches the portable-app convention.
/// </summary>
private static string FilePath
=> Path.Combine(AppContext.BaseDirectory, "launcher-settings.json");
public static LauncherSettings Load()
{
try
{
if (!File.Exists(FilePath)) return new LauncherSettings();
return JsonSerializer.Deserialize<LauncherSettings>(File.ReadAllText(FilePath))
?? new LauncherSettings();
}
catch
{
return new LauncherSettings();
}
}
public void Save()
{
try
{
File.WriteAllText(
FilePath,
JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true })
);
}
catch
{
// best-effort
}
}
}
+110
View File
@@ -0,0 +1,110 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace ModpackLauncher.Models;
public sealed class Manifest
{
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("version")]
public string? Version { get; set; }
[JsonPropertyName("minecraft")]
public MinecraftSpec Minecraft { get; set; } = new();
[JsonPropertyName("loader")]
public LoaderSpec? Loader { get; set; }
[JsonPropertyName("files")]
public List<ManifestFile> Files { get; set; } = new();
/// <summary>
/// Optional. The launcher version that the modpack publisher expects clients
/// to be running. If a client's assembly version is lower than this, the launcher
/// surfaces a "newer version available" banner pointing at <see cref="LauncherUrl"/>.
/// </summary>
[JsonPropertyName("launcherVersion")]
public string? LauncherVersion { get; set; }
/// <summary>Public download URL for the latest launcher (shown in the banner).</summary>
[JsonPropertyName("launcherUrl")]
public string? LauncherUrl { get; set; }
/// <summary>
/// Optional. If present, the launcher writes this entry into the player's
/// <c>servers.dat</c> on first install so the modpack's server appears in
/// the multiplayer list automatically -- no copy-paste needed.
/// </summary>
[JsonPropertyName("defaultServer")]
public DefaultServer? DefaultServer { get; set; }
/// <summary>
/// Optional. Filename (in shaderpacks/) of a shader pack to enable by default
/// for fresh installs. Existing installs with a different shader chosen are
/// left alone -- this is a default, not a forced override.
/// </summary>
[JsonPropertyName("defaultShader")]
public string? DefaultShader { get; set; }
/// <summary>
/// Optional. Public base URL of the brass-sigil-server admin panel (e.g.
/// https://bns-admin.sijbers.uk). The launcher uses this to send whitelist
/// requests on the player's behalf -- nothing else.
/// </summary>
[JsonPropertyName("panelUrl")]
public string? PanelUrl { get; set; }
}
public sealed class DefaultServer
{
[JsonPropertyName("name")]
public string Name { get; set; } = "";
[JsonPropertyName("ip")]
public string Ip { get; set; } = "";
}
public sealed class MinecraftSpec
{
[JsonPropertyName("version")]
public string Version { get; set; } = "";
}
public sealed class LoaderSpec
{
/// <summary>"forge" | "fabric" | "neoforge" | "vanilla" (or null)</summary>
[JsonPropertyName("type")]
public string Type { get; set; } = "vanilla";
[JsonPropertyName("version")]
public string Version { get; set; } = "";
}
public sealed class ManifestFile
{
[JsonPropertyName("path")]
public string Path { get; set; } = "";
[JsonPropertyName("url")]
public string Url { get; set; } = "";
[JsonPropertyName("sha1")]
public string? Sha1 { get; set; }
[JsonPropertyName("size")]
public long? Size { get; set; }
}
public sealed class PackVersionRecord
{
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("version")]
public string? Version { get; set; }
[JsonPropertyName("syncedAt")]
public string? SyncedAt { get; set; }
}
+65
View File
@@ -0,0 +1,65 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<!-- net8.0-windows is required for the XboxAuthNet WebView2 OAuth flow:
the netstandard2.0 build of XboxAuthNet has no WebUI implementation. -->
<TargetFramework>net8.0-windows</TargetFramework>
<UseWindowsForms>true</UseWindowsForms>
<Nullable>enable</Nullable>
<ApplicationManifest>app.manifest</ApplicationManifest>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<RootNamespace>ModpackLauncher</RootNamespace>
<AssemblyName>ModpackLauncher</AssemblyName>
<Version>0.4.5</Version>
<ApplicationIcon Condition="Exists('Assets\icon.ico')">Assets\icon.ico</ApplicationIcon>
<!-- Single-file self-contained publish defaults (Windows-only now due to WebView2) -->
<RuntimeIdentifier Condition="'$(RuntimeIdentifier)' == ''">win-x64</RuntimeIdentifier>
<RuntimeIdentifiers>win-x64</RuntimeIdentifiers>
<PublishSingleFile>true</PublishSingleFile>
<SelfContained>true</SelfContained>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
<DebugType>embedded</DebugType>
<CopyDebugSymbolFilesFromPackages>false</CopyDebugSymbolFilesFromPackages>
<CopyDocumentationFilesFromPackages>false</CopyDocumentationFilesFromPackages>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="12.0.2" />
<PackageReference Include="Avalonia.Desktop" Version="12.0.2" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.2" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.2" />
<PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.1">
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference>
<PackageReference Include="CmlLib.Core" Version="4.0.6" />
<PackageReference Include="CmlLib.Core.Auth.Microsoft" Version="3.3.1" />
<PackageReference Include="CmlLib.Core.Installer.Forge" Version="1.1.1" />
<PackageReference Include="CmlLib.Core.Installer.NeoForge" Version="4.0.0" />
<PackageReference Include="XboxAuthNet.Game.Msal" Version="0.1.3" />
<PackageReference Include="fNbt" Version="0.7.1" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="launcher-config.json" Condition="Exists('launcher-config.json')">
<LogicalName>launcher-config.json</LogicalName>
</EmbeddedResource>
<EmbeddedResource Include="launcher-config.template.json">
<LogicalName>launcher-config.template.json</LogicalName>
</EmbeddedResource>
</ItemGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\icon.png" />
<AvaloniaResource Include="Assets\noise.png" />
</ItemGroup>
<Target Name="StripNativePdbs" AfterTargets="Publish">
<ItemGroup>
<_StripPdb Include="$(PublishDir)*.pdb" Exclude="$(PublishDir)$(AssemblyName).pdb" />
</ItemGroup>
<Delete Files="@(_StripPdb)" />
</Target>
</Project>
+24
View File
@@ -0,0 +1,24 @@
using Avalonia;
using System;
namespace ModpackLauncher;
class Program
{
// Initialization code. Don't use any Avalonia, third-party APIs or any
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
// yet and stuff might break.
[STAThread]
public static void Main(string[] args) => BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
// Avalonia configuration, don't remove; also used by visual designer.
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
.UsePlatformDetect()
#if DEBUG
.WithDeveloperTools()
#endif
.WithInterFont()
.LogToTrace();
}
+112
View File
@@ -0,0 +1,112 @@
# Brass & Sigil Launcher
A custom Minecraft Java Edition launcher built for distributing the private
"Brass & Sigil" modpack (Create + aeronautics + tech + magic + Distant
Horizons) to a small friend group.
> **NOT AN OFFICIAL MINECRAFT PRODUCT. NOT APPROVED BY OR ASSOCIATED WITH
> MOJANG OR MICROSOFT.**
## What it does
1. Fetches a JSON manifest from a self-hosted server and syncs the modpack
files (mods, configs, resourcepacks) to the player's local install
directory. SHA-1 hashing ensures only changed files are downloaded.
2. Authenticates each player with their own personal Microsoft account via
the standard MSAL OAuth + Xbox Live + Minecraft Services chain, using the
open-source `CmlLib.Core.Auth.Microsoft` library without modification.
3. Installs the appropriate Minecraft Java Edition version and Forge mod
loader, then launches the game with the player's authenticated session.
## Stack
- **C# / .NET 8**
- **Avalonia 12** -- desktop UI
- **CmlLib.Core 4.x** -- Minecraft install + launch
- **CmlLib.Core.Auth.Microsoft 3.x + XboxAuthNet.Game.Msal** -- Microsoft auth
- **CmlLib.Core.Installer.Forge** -- Forge support
## Build
Requires the .NET 8 SDK.
```powershell
dotnet build
```
To produce the shippable single-file executable (~46 MB):
```powershell
dotnet publish -c Release -r win-x64 --self-contained true
```
Output: `bin/Release/net8.0/win-x64/publish/ModpackLauncher.exe` -- a single
file with no other dependencies, ready to send to a friend.
## Configuration
The repo ships with a `launcher-config.template.json`. Copy it to
`launcher-config.json` and fill in real values before building:
```powershell
Copy-Item launcher-config.template.json launcher-config.json
```
`launcher-config.json` is gitignored so local values (manifest URL, Azure
client ID) never get committed.
### Fields
| Field | Purpose |
| --- | --- |
| `packName` | Display name shown in the launcher |
| `manifestUrl` | URL of the hosted manifest JSON |
| `installDirName` | Folder name under `%APPDATA%` for game files |
| `memoryMinMB` / `memoryMaxMB` | JVM memory defaults |
| `msalClientId` | Azure App Registration client ID for Microsoft auth |
The config is **embedded into the exe** at build time, so the launcher ships
as a single self-contained file. A sidecar `launcher-config.json` placed
beside the exe will override the embedded copy at runtime (handy for testing).
## Manifest format
See `manifest.example.json` for the schema. Minimum:
```json
{
"name": "Brass & Sigil",
"version": "1.0.0",
"minecraft": { "version": "1.20.1" },
"loader": { "type": "forge", "version": "47.2.0" },
"files": [
{ "path": "mods/example.jar", "url": "https://...", "sha1": "..." }
]
}
```
The launcher diffs the manifest against the local install dir using SHA-1
hashes and downloads only what has changed. Files removed from the manifest
are pruned from managed folders (`mods/`, `config/`, `resourcepacks/`,
`shaderpacks/`, `kubejs/`, `defaultconfigs/`).
## Privacy
The launcher does not collect, store, or transmit any user data beyond what
the standard Microsoft and Minecraft authentication APIs require. Auth tokens
are cached locally via the MSAL token cache. No telemetry, no analytics, no
third-party services beyond Microsoft and Mojang.
Local data is written to:
- `%APPDATA%\BrassAndSigil\` -- launcher settings + log file
- `%APPDATA%\<installDirName>\` -- modpack and Minecraft installation
## License
MIT -- see [LICENSE](LICENSE).
## Author
Matt Sijbers -- [https://sijbers.uk/matt](https://sijbers.uk/matt) /
[project page](https://sijbers.uk/brass-and-sigil)
+95
View File
@@ -0,0 +1,95 @@
using System;
using System.Threading.Tasks;
using CmlLib.Core.Auth;
using CmlLib.Core.Auth.Microsoft;
using XboxAuthNet.Game.Msal;
using XboxAuthNet.Game.Msal.OAuth;
namespace ModpackLauncher.Services;
/// <summary>
/// Microsoft auth wrapper. Two modes:
/// 1. Custom Azure AD client ID (msalClientId set) -> MSAL flow. Requires Microsoft
/// to have approved the app for Minecraft API access.
/// 2. No custom client ID (default) -> CmlLib's BuildDefault() which uses the
/// WebView2-driven Microsoft Live OAuth flow with the Xbox Live SDK client ID.
/// Doesn't require an Azure registration; works out of the box on any Win10/11
/// machine that has the WebView2 Runtime installed (preinstalled since 2021).
/// </summary>
public sealed class AuthService
{
private readonly string _clientId;
public AuthService(string clientId)
{
_clientId = clientId;
}
/// <summary>True when the user has provided their own Azure App Registration ID.</summary>
public bool HasCustomClientId => !string.IsNullOrWhiteSpace(_clientId)
&& _clientId != "00000000-0000-0000-0000-000000000000";
/// <summary>Auth is always available now (BuildDefault provides a fallback).</summary>
public bool IsConfigured => true;
public async Task<MSession> AuthenticateAsync()
{
var loginHandler = await BuildLoginHandlerAsync();
try
{
return await loginHandler.AuthenticateSilently();
}
catch
{
return await loginHandler.AuthenticateInteractively();
}
}
public async Task<MSession> SignInInteractivelyAsync()
{
var loginHandler = await BuildLoginHandlerAsync();
return await loginHandler.AuthenticateInteractively();
}
public async Task<MSession?> TryAuthenticateSilentlyAsync()
{
try
{
var loginHandler = await BuildLoginHandlerAsync();
return await loginHandler.AuthenticateSilently();
}
catch
{
return null;
}
}
public async Task SignOutAsync()
{
try
{
var loginHandler = await BuildLoginHandlerAsync();
await loginHandler.Signout();
}
catch
{
// best-effort
}
}
private async Task<JELoginHandler> BuildLoginHandlerAsync()
{
if (HasCustomClientId)
{
// Custom Azure AD MSAL flow -- requires the app to be approved by Microsoft.
var app = await MsalClientHelper.BuildApplicationWithCache(_clientId);
return new JELoginHandlerBuilder()
.WithOAuthProvider(new MsalCodeFlowProvider(app))
.Build();
}
// Default path: WebView2 + Xbox Live SDK community client ID. No Azure registration.
// Note: requires WebView2 Runtime on Windows (preinstalled on Win10/11 since 2021).
return JELoginHandlerBuilder.BuildDefault();
}
}
+45
View File
@@ -0,0 +1,45 @@
using System;
using System.IO;
namespace ModpackLauncher.Services;
public static class FileLog
{
private static readonly object _lock = new();
public static string LogPath { get; } = BuildPath();
private static string BuildPath()
{
var dir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"BrassAndSigil"
);
Directory.CreateDirectory(dir);
return Path.Combine(dir, "launcher.log");
}
public static void Init()
{
try
{
// Truncate per launch so we always have the most recent run.
File.WriteAllText(LogPath,
$"=== ModpackLauncher launched {DateTime.Now:yyyy-MM-dd HH:mm:ss} ==={Environment.NewLine}");
}
catch { }
}
public static void Write(string message)
{
try
{
lock (_lock)
{
File.AppendAllText(LogPath,
$"[{DateTime.Now:HH:mm:ss}] {message}{Environment.NewLine}");
}
}
catch { }
}
}
+65
View File
@@ -0,0 +1,65 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace ModpackLauncher.Services;
/// <summary>
/// Pre-seeds Iris's <c>config/iris.properties</c> with a default shader pack
/// for fresh installs, so friends launch the game and the recommended shader
/// is already on rather than them having to dig through Video Settings.
///
/// Respects user choice: if the file already exists with a non-empty
/// <c>shaderPack=...</c> entry, we leave it alone -- only fresh installs (or
/// installs where Iris has never been opened) get the default.
/// </summary>
public sealed class IrisConfigService
{
public void SetDefaultShader(string gameDir, string shaderPackFilename)
{
if (string.IsNullOrWhiteSpace(shaderPackFilename)) return;
var configDir = Path.Combine(gameDir, "config");
Directory.CreateDirectory(configDir);
var path = Path.Combine(configDir, "iris.properties");
var lines = File.Exists(path) ? File.ReadAllLines(path).ToList() : new List<string>();
// If a shaderPack is already chosen and non-empty (the user picked
// something), respect it and bail.
var existingShader = lines
.Select(l => l.TrimStart())
.Where(l => l.Length > 0 && l[0] != '#')
.Select(l => { var i = l.IndexOf('='); return i < 0 ? null : new { Key = l.Substring(0, i).Trim(), Value = l.Substring(i + 1).Trim() }; })
.Where(p => p != null && p.Key.Equals("shaderPack", System.StringComparison.OrdinalIgnoreCase))
.Select(p => p!.Value)
.FirstOrDefault();
if (!string.IsNullOrEmpty(existingShader)) return;
// No shader set -- write our defaults. Update existing keys in-place if
// they exist (e.g. shaderPack="" placeholder), append otherwise.
var defaults = new Dictionary<string, string>(System.StringComparer.OrdinalIgnoreCase)
{
{ "shaderPack", shaderPackFilename },
{ "enableShaders", "true" },
};
var seen = new HashSet<string>(System.StringComparer.OrdinalIgnoreCase);
for (int i = 0; i < lines.Count; i++)
{
var trimmed = lines[i].TrimStart();
if (trimmed.Length == 0 || trimmed[0] == '#') continue;
var idx = trimmed.IndexOf('=');
if (idx < 0) continue;
var key = trimmed.Substring(0, idx).Trim();
if (defaults.TryGetValue(key, out var val))
{
lines[i] = $"{key}={val}";
seen.Add(key);
}
}
foreach (var (k, v) in defaults)
{
if (!seen.Contains(k)) lines.Add($"{k}={v}");
}
File.WriteAllLines(path, lines);
}
}
+227
View File
@@ -0,0 +1,227 @@
using System;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using CmlLib.Core;
using CmlLib.Core.Auth;
using CmlLib.Core.Installers;
using CmlLib.Core.ProcessBuilder;
using CmlLib.Core.Installer.Forge;
using CmlLib.Core.Installer.NeoForge;
using ModpackLauncher.Models;
namespace ModpackLauncher.Services;
public sealed class LaunchService
{
private readonly MinecraftLauncher _launcher;
private readonly ForgeInstaller _forgeInstaller;
private readonly NeoForgeInstaller _neoForgeInstaller;
private readonly string _installDir;
public LaunchService(string installDir)
{
_installDir = installDir;
var path = new MinecraftPath(installDir);
_launcher = new MinecraftLauncher(path);
_forgeInstaller = new ForgeInstaller(_launcher);
_neoForgeInstaller = new NeoForgeInstaller(_launcher);
}
public MinecraftLauncher Launcher => _launcher;
/// <summary>
/// Belt-and-braces check after the loader installer runs: parse the version JSON
/// and download any libraries that are listed but missing on disk. Works around
/// a known CmlLib quirk where libraries with @jar suffix in the Maven coordinate
/// (e.g. "cpw.mods:bootstraplauncher:2.0.2@jar" used by NeoForge 21.1.x) get
/// skipped by the installer's library downloader.
/// </summary>
private async Task VerifyVersionLibrariesAsync(string versionId,
IProgress<ProgressReport> progress,
CancellationToken ct)
{
var versionJsonPath = Path.Combine(_installDir, "versions", versionId, $"{versionId}.json");
if (!File.Exists(versionJsonPath)) return;
using var doc = JsonDocument.Parse(await File.ReadAllTextAsync(versionJsonPath, ct).ConfigureAwait(false));
if (!doc.RootElement.TryGetProperty("libraries", out var libsArr)) return;
var libsDir = Path.Combine(_installDir, "libraries");
var missing = new System.Collections.Generic.List<(string Path, string Url, string? Sha1)>();
foreach (var lib in libsArr.EnumerateArray())
{
if (!lib.TryGetProperty("downloads", out var dls)) continue;
if (!dls.TryGetProperty("artifact", out var art)) continue;
if (!art.TryGetProperty("path", out var pPath) || !art.TryGetProperty("url", out var pUrl)) continue;
var relPath = pPath.GetString();
var url = pUrl.GetString();
var sha1 = art.TryGetProperty("sha1", out var pSha1) ? pSha1.GetString() : null;
if (string.IsNullOrEmpty(relPath) || string.IsNullOrEmpty(url)) continue;
var fullPath = Path.Combine(libsDir, relPath.Replace('/', Path.DirectorySeparatorChar));
if (!File.Exists(fullPath))
{
missing.Add((fullPath, url, sha1));
}
}
if (missing.Count == 0) return;
progress.Report(new ProgressReport(
ProgressKind.Status,
$"Verifying loader libraries: {missing.Count} missing, fetching..."));
using var http = new HttpClient();
http.Timeout = TimeSpan.FromMinutes(5);
for (int i = 0; i < missing.Count; i++)
{
ct.ThrowIfCancellationRequested();
var (path, url, _) = missing[i];
progress.Report(new ProgressReport(
ProgressKind.Log,
$" Library {i + 1}/{missing.Count}: {Path.GetFileName(path)}"));
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
var bytes = await http.GetByteArrayAsync(url, ct).ConfigureAwait(false);
await File.WriteAllBytesAsync(path, bytes, ct).ConfigureAwait(false);
}
progress.Report(new ProgressReport(
ProgressKind.Status,
"Loader libraries verified."));
}
public async Task<string> InstallVersionAsync(
Manifest manifest,
IProgress<ProgressReport> progress,
CancellationToken ct)
{
EventHandler<InstallerProgressChangedEventArgs> fileProgressHandler = (_, args) =>
{
var pct = args.TotalTasks > 0
? (args.ProgressedTasks * 100.0 / args.TotalTasks)
: -1;
progress.Report(new ProgressReport(
ProgressKind.Progress,
$"{args.EventType}: {args.Name ?? ""}",
Current: args.ProgressedTasks,
Total: args.TotalTasks,
Percent: pct
));
};
EventHandler<ByteProgress> byteProgressHandler = (_, args) =>
{
if (args.TotalBytes <= 0) return;
var pct = args.ToRatio() * 100.0;
progress.Report(new ProgressReport(
ProgressKind.Progress,
$"{args.ProgressedBytes:N0} / {args.TotalBytes:N0} bytes",
Current: (int)Math.Min(args.ProgressedBytes, int.MaxValue),
Total: (int)Math.Min(args.TotalBytes, int.MaxValue),
Percent: pct
));
};
_launcher.FileProgressChanged += fileProgressHandler;
_launcher.ByteProgressChanged += byteProgressHandler;
try
{
var mcVersion = manifest.Minecraft.Version;
var loader = manifest.Loader;
progress.Report(new ProgressReport(ProgressKind.Status, $"Installing Minecraft {mcVersion}..."));
await _launcher.InstallAsync(mcVersion, ct).AsTask().ConfigureAwait(false);
if (loader == null || string.IsNullOrEmpty(loader.Type) ||
loader.Type.Equals("vanilla", StringComparison.OrdinalIgnoreCase))
{
return mcVersion;
}
if (loader.Type.Equals("forge", StringComparison.OrdinalIgnoreCase))
{
progress.Report(new ProgressReport(
ProgressKind.Status,
$"Installing Forge {loader.Version} for {mcVersion}..."
));
var fid = await _forgeInstaller.Install(mcVersion, loader.Version).ConfigureAwait(false);
await VerifyVersionLibrariesAsync(fid, progress, ct).ConfigureAwait(false);
return fid;
}
if (loader.Type.Equals("neoforge", StringComparison.OrdinalIgnoreCase))
{
progress.Report(new ProgressReport(
ProgressKind.Status,
$"Installing NeoForge {loader.Version} for {mcVersion}..."
));
var nid = await _neoForgeInstaller.Install(mcVersion, loader.Version).ConfigureAwait(false);
await VerifyVersionLibrariesAsync(nid, progress, ct).ConfigureAwait(false);
return nid;
}
throw new NotSupportedException(
$"Loader '{loader.Type}' is not yet supported. Use 'forge', 'neoforge', or 'vanilla'."
);
}
finally
{
_launcher.FileProgressChanged -= fileProgressHandler;
_launcher.ByteProgressChanged -= byteProgressHandler;
}
}
public async Task<int> LaunchAsync(
string versionId,
MSession session,
int minMemoryMB,
int maxMemoryMB,
IProgress<ProgressReport> progress,
CancellationToken ct)
{
var option = new MLaunchOption
{
Session = session,
MaximumRamMb = maxMemoryMB,
MinimumRamMb = minMemoryMB,
// Generational ZGC: low-pause concurrent collector -- recommended by
// Distant Horizons (and broadly better than the default G1 for modded MC).
// Requires Java 21+ which CmlLib auto-installs for MC 1.21.1.
ExtraJvmArguments = new[]
{
new MArgument("-XX:+UseZGC"),
new MArgument("-XX:+ZGenerational"),
}
};
progress.Report(new ProgressReport(ProgressKind.Status, "Building launch process..."));
var process = await _launcher.BuildProcessAsync(versionId, option).ConfigureAwait(false);
var wrapper = new ProcessWrapper(process);
wrapper.OutputReceived += (_, log) =>
{
progress.Report(new ProgressReport(ProgressKind.Log, log));
};
wrapper.StartWithEvents();
progress.Report(new ProgressReport(
ProgressKind.Status,
$"Minecraft launched (PID {process.Id})"
));
var exitCode = await wrapper.WaitForExitTaskAsync().ConfigureAwait(false);
progress.Report(new ProgressReport(
ProgressKind.Status,
$"Game exited (code {exitCode})"
));
return exitCode;
}
}
+252
View File
@@ -0,0 +1,252 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using ModpackLauncher.Models;
namespace ModpackLauncher.Services;
public sealed class ManifestSyncService
{
private const string PackVersionFile = "pack-version.json";
private static readonly string[] ManagedRoots =
{
"mods", "config", "resourcepacks", "shaderpacks", "kubejs", "defaultconfigs"
};
private readonly HttpClient _http;
public ManifestSyncService(HttpClient? http = null)
{
_http = http ?? new HttpClient();
_http.Timeout = TimeSpan.FromMinutes(5);
}
/// <summary>Configure HTTP Basic auth for all subsequent requests. Pass null to clear.</summary>
public void SetBasicAuth(string? username, string? password)
{
if (string.IsNullOrEmpty(username))
{
_http.DefaultRequestHeaders.Authorization = null;
return;
}
var raw = $"{username}:{password ?? ""}";
var b64 = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(raw));
_http.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", b64);
}
public sealed record SyncResult(Manifest Manifest, int Downloaded, int Removed);
/// <summary>Just fetch the manifest JSON without doing the full file sync. Used for "is an update available?" checks on startup.</summary>
public async Task<Manifest> FetchManifestOnlyAsync(string manifestUrl, CancellationToken ct = default)
{
var json = await _http.GetStringAsync(manifestUrl, ct).ConfigureAwait(false);
var manifest = JsonSerializer.Deserialize<Manifest>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
if (manifest == null) throw new InvalidOperationException("Manifest is empty or invalid.");
manifest.Files ??= new System.Collections.Generic.List<ManifestFile>();
return manifest;
}
/// <summary>
/// Fast check (no hashing): which files listed in the manifest are missing on disk.
/// Used as a pre-launch sanity pass to catch AV quarantines / interrupted installs.
/// </summary>
public System.Collections.Generic.List<ManifestFile> FindMissingFiles(Manifest manifest, string installDir)
{
var missing = new System.Collections.Generic.List<ManifestFile>();
foreach (var file in manifest.Files)
{
var dest = Path.Combine(installDir, file.Path);
if (!File.Exists(dest)) missing.Add(file);
}
return missing;
}
public PackVersionRecord? GetLocalPackVersion(string installDir)
{
var path = Path.Combine(installDir, PackVersionFile);
if (!File.Exists(path)) return null;
try
{
return JsonSerializer.Deserialize<PackVersionRecord>(File.ReadAllText(path));
}
catch
{
return null;
}
}
public async Task<SyncResult> SyncAsync(
string manifestUrl,
string installDir,
IProgress<ProgressReport> progress,
CancellationToken ct = default)
{
progress.Report(new ProgressReport(ProgressKind.Status, "Fetching manifest..."));
var manifestJson = await _http.GetStringAsync(manifestUrl, ct).ConfigureAwait(false);
var manifest = JsonSerializer.Deserialize<Manifest>(manifestJson, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
}) ?? throw new InvalidOperationException("Manifest is empty or invalid.");
if (string.IsNullOrWhiteSpace(manifest.Minecraft.Version))
{
throw new InvalidOperationException("Manifest is missing minecraft.version.");
}
Directory.CreateDirectory(installDir);
var local = GetLocalPackVersion(installDir);
progress.Report(new ProgressReport(
ProgressKind.Status,
$"Pack: {manifest.Name ?? "modpack"} v{manifest.Version ?? "?"} (local: {local?.Version ?? "none"})"
));
var wantedPaths = new HashSet<string>(
manifest.Files.Select(f => NormalizePath(f.Path)),
StringComparer.OrdinalIgnoreCase
);
// Remove managed files no longer in manifest
var toRemove = new List<string>();
foreach (var root in ManagedRoots)
{
var rootDir = Path.Combine(installDir, root);
if (!Directory.Exists(rootDir)) continue;
foreach (var file in Directory.EnumerateFiles(rootDir, "*", SearchOption.AllDirectories))
{
var rel = NormalizePath(Path.GetRelativePath(installDir, file));
if (!wantedPaths.Contains(rel))
{
toRemove.Add(file);
}
}
}
foreach (var file in toRemove)
{
try
{
File.Delete(file);
progress.Report(new ProgressReport(
ProgressKind.Log,
$"Removed: {Path.GetRelativePath(installDir, file)}"
));
}
catch (Exception ex)
{
progress.Report(new ProgressReport(
ProgressKind.Log,
$"Could not remove {file}: {ex.Message}"
));
}
}
// Determine what to download
var toDownload = new List<ManifestFile>();
foreach (var file in manifest.Files)
{
ct.ThrowIfCancellationRequested();
var dest = Path.Combine(installDir, file.Path);
if (!File.Exists(dest))
{
toDownload.Add(file);
continue;
}
if (!string.IsNullOrEmpty(file.Sha1))
{
var actual = await ComputeSha1Async(dest, ct).ConfigureAwait(false);
if (!string.Equals(actual, file.Sha1, StringComparison.OrdinalIgnoreCase))
{
toDownload.Add(file);
}
}
}
progress.Report(new ProgressReport(
ProgressKind.Status,
toDownload.Count == 0 ? "Already up-to-date." : $"Downloading {toDownload.Count} file(s)..."
));
for (int i = 0; i < toDownload.Count; i++)
{
ct.ThrowIfCancellationRequested();
var file = toDownload[i];
var pct = toDownload.Count == 0 ? 100 : (i * 100.0 / toDownload.Count);
progress.Report(new ProgressReport(
ProgressKind.Progress,
$"Downloading {file.Path}",
Current: i + 1,
Total: toDownload.Count,
Percent: pct
));
var dest = Path.Combine(installDir, file.Path);
await DownloadFileAsync(file.Url, dest, file.Sha1, ct).ConfigureAwait(false);
}
var record = new PackVersionRecord
{
Name = manifest.Name,
Version = manifest.Version,
SyncedAt = DateTime.UtcNow.ToString("o")
};
await File.WriteAllTextAsync(
Path.Combine(installDir, PackVersionFile),
JsonSerializer.Serialize(record, new JsonSerializerOptions { WriteIndented = true }),
ct
).ConfigureAwait(false);
progress.Report(new ProgressReport(ProgressKind.Status, "Sync complete."));
return new SyncResult(manifest, toDownload.Count, toRemove.Count);
}
private async Task DownloadFileAsync(string url, string destPath, string? expectedSha1, CancellationToken ct)
{
Directory.CreateDirectory(Path.GetDirectoryName(destPath)!);
var tmp = destPath + ".part";
using (var response = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false))
{
response.EnsureSuccessStatusCode();
await using var src = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
await using var dst = File.Create(tmp);
await src.CopyToAsync(dst, ct).ConfigureAwait(false);
}
if (!string.IsNullOrEmpty(expectedSha1))
{
var actual = await ComputeSha1Async(tmp, ct).ConfigureAwait(false);
if (!string.Equals(actual, expectedSha1, StringComparison.OrdinalIgnoreCase))
{
File.Delete(tmp);
throw new InvalidOperationException(
$"Hash mismatch for {Path.GetFileName(destPath)} (expected {expectedSha1}, got {actual})"
);
}
}
if (File.Exists(destPath)) File.Delete(destPath);
File.Move(tmp, destPath);
}
private static async Task<string> ComputeSha1Async(string path, CancellationToken ct)
{
using var sha = SHA1.Create();
await using var stream = File.OpenRead(path);
var bytes = await sha.ComputeHashAsync(stream, ct).ConfigureAwait(false);
return Convert.ToHexString(bytes).ToLowerInvariant();
}
private static string NormalizePath(string p) => p.Replace('\\', '/').TrimStart('/');
}
+17
View File
@@ -0,0 +1,17 @@
namespace ModpackLauncher.Services;
public enum ProgressKind
{
Status,
Progress,
Log,
Error
}
public sealed record ProgressReport(
ProgressKind Kind,
string Message,
int Current = 0,
int Total = 0,
double Percent = -1
);
+101
View File
@@ -0,0 +1,101 @@
using System;
using System.IO;
using System.Linq;
using fNbt;
namespace ModpackLauncher.Services;
/// <summary>
/// Pre-populates Minecraft's multiplayer server list (`servers.dat`) so the
/// modpack's server is one click away on first launch.
///
/// servers.dat is uncompressed NBT (unlike level.dat which is gzipped). Schema:
/// compound {
/// servers : list[compound] {
/// name : string
/// ip : string
/// acceptTextures : byte (optional, 1 = enabled)
/// hidden : byte (optional, 0 = visible)
/// icon : string (optional, base64 PNG)
/// }
/// }
/// </summary>
public sealed class ServerListService
{
/// <summary>
/// Add or update an entry. Match-by-IP: if an entry with the same IP exists,
/// update its name; otherwise prepend a new entry so the friend's first
/// glance at multiplayer shows our server at the top.
/// </summary>
public void EnsureServer(string gameDir, string name, string ip)
{
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(ip)) return;
Directory.CreateDirectory(gameDir);
var path = Path.Combine(gameDir, "servers.dat");
NbtCompound root;
NbtList servers;
if (File.Exists(path))
{
try
{
var nbt = new NbtFile();
nbt.LoadFromFile(path, NbtCompression.None, _ => true);
root = nbt.RootTag;
if (root.TryGet("servers", out NbtList? existingList) && existingList is not null)
{
servers = existingList;
}
else
{
servers = new NbtList("servers", NbtTagType.Compound);
root.Add(servers);
}
}
catch
{
// Existing file unreadable -- start fresh rather than crashing the install.
root = new NbtCompound("");
servers = new NbtList("servers", NbtTagType.Compound);
root.Add(servers);
}
}
else
{
root = new NbtCompound("");
servers = new NbtList("servers", NbtTagType.Compound);
root.Add(servers);
}
// Match-by-IP. If found, update the display name; otherwise prepend.
for (int i = 0; i < servers.Count; i++)
{
if (servers[i] is not NbtCompound entry) continue;
if (entry.TryGet("ip", out NbtString? ipTag) && ipTag?.Value == ip)
{
entry["name"] = new NbtString("name", name);
Save(root, path);
return;
}
}
var newEntry = new NbtCompound
{
new NbtString("name", name),
new NbtString("ip", ip),
// acceptTextures = 1 lets the server send its resource pack without prompting
// (the player still gets the prompt; this just allows the option). Default
// value matches what vanilla MC writes when you click "Done" in the UI.
new NbtByte("acceptTextures", 1),
};
servers.Insert(0, newEntry);
Save(root, path);
}
private static void Save(NbtCompound root, string path)
{
var file = new NbtFile(root);
file.SaveToFile(path, NbtCompression.None);
}
}
+67
View File
@@ -0,0 +1,67 @@
using System;
using System.IO;
using System.Runtime.InteropServices;
namespace ModpackLauncher.Services;
public static class SystemInfo
{
private const long DefaultFallbackKB = 8L * 1024 * 1024; // assume 8 GB if detection fails
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetPhysicallyInstalledSystemMemory(out long memoryInKilobytes);
/// <summary>Total physically installed system RAM in megabytes.</summary>
public static int TotalPhysicalMemoryMB
{
get
{
try
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
if (GetPhysicallyInstalledSystemMemory(out var kb))
return (int)(kb / 1024);
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
// Read MemTotal from /proc/meminfo
foreach (var line in File.ReadAllLines("/proc/meminfo"))
{
if (line.StartsWith("MemTotal:", StringComparison.Ordinal))
{
var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length >= 2 && long.TryParse(parts[1], out var memKb))
return (int)(memKb / 1024);
}
}
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
// GC heap-size hint; not perfect but a reasonable fallback for macOS.
var gcInfo = GC.GetGCMemoryInfo();
if (gcInfo.TotalAvailableMemoryBytes > 0)
return (int)(gcInfo.TotalAvailableMemoryBytes / (1024 * 1024));
}
}
catch
{
// Fall through to default
}
return (int)(DefaultFallbackKB / 1024);
}
}
/// <summary>Recommended max user-allocatable RAM (leaves headroom for OS + other apps).</summary>
public static int SafeMaxAllocationMB
{
get
{
var total = TotalPhysicalMemoryMB;
// Leave at least 4 GB for OS + browser + Discord + everything else.
var headroom = total >= 32 * 1024 ? 6 * 1024 : 4 * 1024;
return Math.Max(2048, total - headroom);
}
}
}
+39
View File
@@ -0,0 +1,39 @@
using System;
using Microsoft.Win32;
namespace ModpackLauncher.Services;
/// <summary>
/// Detects whether Microsoft Edge WebView2 Runtime is installed.
/// Required by the Xbox Live SDK + WebView2 sign-in flow used by the launcher when
/// no custom Azure client ID is configured. Preinstalled on Windows 10/11 since
/// 2021 (came with Edge), but not guaranteed on older / cleaned Windows installs.
/// </summary>
public static class WebView2Check
{
private const string ClientGuid = "{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}"; // Microsoft Edge WebView2 Runtime
public const string DownloadUrl = "https://developer.microsoft.com/microsoft-edge/webview2/";
public static bool IsInstalled()
{
// The runtime registers in one of three places depending on machine vs. per-user install.
return GetVersion(RegistryHive.LocalMachine, $@"SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{ClientGuid}") is { } v1 && v1 != "0.0.0.0"
|| GetVersion(RegistryHive.LocalMachine, $@"SOFTWARE\Microsoft\EdgeUpdate\Clients\{ClientGuid}") is { } v2 && v2 != "0.0.0.0"
|| GetVersion(RegistryHive.CurrentUser, $@"Software\Microsoft\EdgeUpdate\Clients\{ClientGuid}") is { } v3 && v3 != "0.0.0.0";
}
private static string? GetVersion(RegistryHive hive, string keyPath)
{
try
{
using var baseKey = RegistryKey.OpenBaseKey(hive, RegistryView.Default);
using var key = baseKey.OpenSubKey(keyPath);
return key?.GetValue("pv") as string;
}
catch
{
return null;
}
}
}
@@ -0,0 +1,67 @@
using System;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
namespace ModpackLauncher.Services;
/// <summary>
/// Friend-side wrapper for the brass-sigil-server's public whitelist endpoints.
/// Exists so the launcher can:
/// - Send a "please add me" request without the friend needing to share their
/// MC username with the admin out-of-band.
/// - Poll status afterwards so the launcher UI reflects pending/approved/denied.
/// </summary>
public sealed class WhitelistRequestService
{
private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(10) };
public sealed class StatusResponse
{
[JsonPropertyName("ok")] public bool Ok { get; set; }
[JsonPropertyName("status")] public string? Status { get; set; }
}
public sealed class RequestResponse
{
[JsonPropertyName("ok")] public bool Ok { get; set; }
[JsonPropertyName("status")] public string? Status { get; set; }
[JsonPropertyName("error")] public string? Error { get; set; }
}
/// <summary>
/// Returns "pending" / "approved" / "denied" / "unknown" / "" (network error).
/// </summary>
public async Task<string> GetStatusAsync(string panelUrl, string username)
{
try
{
var url = $"{panelUrl.TrimEnd('/')}/api/whitelist/status?username={Uri.EscapeDataString(username)}";
var resp = await _http.GetFromJsonAsync<StatusResponse>(url);
return resp?.Status ?? "";
}
catch
{
return "";
}
}
public async Task<RequestResponse> SubmitAsync(string panelUrl, string username, string? message = null)
{
try
{
var url = $"{panelUrl.TrimEnd('/')}/api/whitelist/request";
var resp = await _http.PostAsJsonAsync(url, new { username, message });
var body = await resp.Content.ReadFromJsonAsync<RequestResponse>();
if (body is null) return new RequestResponse { Ok = false, Error = "Empty response." };
if (!resp.IsSuccessStatusCode && string.IsNullOrEmpty(body.Error))
body.Error = $"HTTP {(int)resp.StatusCode}";
return body;
}
catch (Exception ex)
{
return new RequestResponse { Ok = false, Error = ex.Message };
}
}
}
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<!-- This manifest is used on Windows only.
Don't remove it as it might cause problems with window transparency and embedded controls.
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
<assemblyIdentity version="1.0.0.0" name="ModpackLauncher.Desktop"/>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- A list of the Windows versions that this application has been tested on
and is designed to work with. Uncomment the appropriate elements
and Windows will automatically select the most compatible environment. -->
<!-- Windows 10 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>
+10
View File
@@ -0,0 +1,10 @@
{
"_comment": "Copy this file to launcher-config.json and fill in real values before building. The launcher-config.json file is gitignored so local values never get committed. httpUsername/httpPassword are optional and only needed if your manifest/file host uses HTTP Basic auth.",
"packName": "Brass & Sigil",
"manifestUrl": "https://your-server.example/pack/manifest.json",
"installDirName": "BrassAndSigilData",
"memoryMB": 8192,
"msalClientId": "00000000-0000-0000-0000-000000000000",
"httpUsername": null,
"httpPassword": null
}
+33
View File
@@ -0,0 +1,33 @@
{
"name": "Brass & Sigil",
"version": "0.1.0",
"minecraft": {
"version": "1.21.1"
},
"loader": {
"type": "neoforge",
"version": "21.1.95"
},
"files": [
{
"path": "mods/create-1.21.1-6.0.10.jar",
"url": "https://sijbers.uk/pack/files/mods/create-1.21.1-6.0.10.jar",
"sha1": "0e97e49837bed766e6f28a4c95b04885d6acc353"
},
{
"path": "mods/create-aeronautics-bundled-1.21.1-1.2.1.jar",
"url": "https://sijbers.uk/pack/files/mods/create-aeronautics-bundled-1.21.1-1.2.1.jar",
"sha1": "fdf1ae69e8b6437e0196b3a35dd2325aa904aba9"
},
{
"path": "mods/distant-horizons-3.0.2-b-1.21.1.jar",
"url": "https://sijbers.uk/pack/files/mods/distant-horizons-3.0.2-b-1.21.1.jar",
"sha1": "0000000000000000000000000000000000000000"
},
{
"path": "config/example.cfg",
"url": "https://sijbers.uk/pack/files/config/example.cfg",
"sha1": "0000000000000000000000000000000000000000"
}
]
}