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:
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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)"
|
||||
@@ -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 |
@@ -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 & 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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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><exe-folder>/<InstallDirName>/</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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 { }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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('/');
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user